hexcli 2.8.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.
- hexcli/__init__.py +6 -0
- hexcli/agent.py +1931 -0
- hexcli/assets/hexcli.ico +0 -0
- hexcli/assets/hexcli.png +0 -0
- hexcli/cancel.py +76 -0
- hexcli/chatlog.py +232 -0
- hexcli/commands.py +73 -0
- hexcli/compaction.py +309 -0
- hexcli/config.py +217 -0
- hexcli/diffview.py +106 -0
- hexcli/distribution.py +237 -0
- hexcli/doctor.py +265 -0
- hexcli/escalate.py +192 -0
- hexcli/http_client.py +156 -0
- hexcli/launcher.py +481 -0
- hexcli/lineedit.py +1110 -0
- hexcli/llm.py +599 -0
- hexcli/local_escalation.py +191 -0
- hexcli/lockfile.py +71 -0
- hexcli/loop_v2.py +393 -0
- hexcli/markdown_stream.py +241 -0
- hexcli/memory.py +416 -0
- hexcli/network.py +154 -0
- hexcli/parsing.py +215 -0
- hexcli/paths.py +127 -0
- hexcli/prompts.py +321 -0
- hexcli/protocol_v2.py +505 -0
- hexcli/repl.py +807 -0
- hexcli/safety.py +127 -0
- hexcli/sessions.py +226 -0
- hexcli/setup_wizard.py +144 -0
- hexcli/shell_session.py +186 -0
- hexcli/statusbar.py +894 -0
- hexcli/stream_render.py +250 -0
- hexcli/telemetry.py +131 -0
- hexcli/tools.py +775 -0
- hexcli/ui.py +1106 -0
- hexcli-2.8.0.dist-info/METADATA +394 -0
- hexcli-2.8.0.dist-info/RECORD +42 -0
- hexcli-2.8.0.dist-info/WHEEL +4 -0
- hexcli-2.8.0.dist-info/entry_points.txt +3 -0
- hexcli-2.8.0.dist-info/licenses/LICENSE +21 -0
hexcli/tools.py
ADDED
|
@@ -0,0 +1,775 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""hexcli.tools — the agent's leaf tools and write-scope guards, lifted out
|
|
3
|
+
of agent.py.
|
|
4
|
+
|
|
5
|
+
Everything the model can invoke through execute_tool_call: shell commands,
|
|
6
|
+
file read/edit/write, search, syntax verification, run_code, plus the
|
|
7
|
+
workspace snapshot and AGENTS.md loader — and the safety guards these tools
|
|
8
|
+
enforce (_check_write_scope / _check_sensitive_path / guard_mutation), which
|
|
9
|
+
own their state here (_HOME): tests that relocate the sensitive-home root
|
|
10
|
+
patch hexcli.tools._HOME.
|
|
11
|
+
|
|
12
|
+
The dispatcher (execute_tool_call) deliberately stays in agent.py and calls
|
|
13
|
+
these through agent's re-bound names, so tests that patch sa.run_command_tool
|
|
14
|
+
or sa.edit_file_tool keep intercepting every dispatch. The active config is
|
|
15
|
+
agent state; the three mutation guards read it at call time via
|
|
16
|
+
_active_config() below.
|
|
17
|
+
|
|
18
|
+
Split stage 3b (docs/V2X_ROADMAP.md, "The Split"). Bodies moved verbatim
|
|
19
|
+
apart from that lookup.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import ast
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import queue
|
|
27
|
+
import re
|
|
28
|
+
import shutil
|
|
29
|
+
import subprocess
|
|
30
|
+
import sys
|
|
31
|
+
import threading
|
|
32
|
+
import time
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
from hexcli import memory, ui
|
|
37
|
+
from hexcli.cancel import CancelMonitor, UserCancelled
|
|
38
|
+
from hexcli.parsing import _RUFF, trim_text
|
|
39
|
+
|
|
40
|
+
DEFAULT_TIMEOUT_SECONDS = 300
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _active_config() -> dict[str, Any] | None:
|
|
44
|
+
"""The config in force for the current turn, owned by hexcli.agent (the
|
|
45
|
+
prompt builder reads it too). Late import: agent imports this module."""
|
|
46
|
+
from hexcli import agent
|
|
47
|
+
return agent._ACTIVE_CONFIG
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
# Paths + safety
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
def resolve_path(raw: str) -> Path:
|
|
58
|
+
expanded = os.path.expandvars(os.path.expanduser(raw.strip().strip('"')))
|
|
59
|
+
return Path(expanded).resolve()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
_SENSITIVE_HOME_DIRS = frozenset({".ssh", ".gnupg", ".gpg", ".aws"})
|
|
63
|
+
_HOME = Path.home().resolve()
|
|
64
|
+
|
|
65
|
+
# Workspace write-scoping (docs/V2_PLAN.md §7). Reads stay unrestricted —
|
|
66
|
+
# the agent must be able to consult docs and libraries outside the project —
|
|
67
|
+
# but MUTATIONS are confined to the working directory unless explicitly
|
|
68
|
+
# allowed. This is the containment half of the safety story: the sensitive-
|
|
69
|
+
# command gate stops exfiltration, this stops collateral damage.
|
|
70
|
+
_ALWAYS_WRITABLE_PREFIXES = ("temp", "tmp")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _check_write_scope(path: Path, op: str, config: dict[str, Any] | None = None) -> None:
|
|
74
|
+
"""Deny AGENT-INITIATED mutations outside the workspace.
|
|
75
|
+
|
|
76
|
+
Scoping is a policy on what the agent may do during a turn, not a
|
|
77
|
+
property of the file helpers themselves. When no config is active
|
|
78
|
+
(config is None) the tools are being driven programmatically — by
|
|
79
|
+
/undo restore, checkpoint load, or a test — and the policy does not
|
|
80
|
+
apply. run_autopilot and the REPL both set the active config before any
|
|
81
|
+
tool can run, so every agent mutation IS scoped; this exemption cannot
|
|
82
|
+
be reached from a model-issued action.
|
|
83
|
+
"""
|
|
84
|
+
if config is None:
|
|
85
|
+
return
|
|
86
|
+
cfg = config
|
|
87
|
+
if not cfg.get("workspace_write_scope", True):
|
|
88
|
+
return
|
|
89
|
+
try:
|
|
90
|
+
resolved = path.resolve()
|
|
91
|
+
except Exception:
|
|
92
|
+
resolved = path
|
|
93
|
+
root = Path(cwd_resolved()).resolve()
|
|
94
|
+
if _is_within(resolved, root):
|
|
95
|
+
return
|
|
96
|
+
# NOTE: system temp is deliberately NOT blanket-allowed. It looks harmless
|
|
97
|
+
# and was allowed in the first draft, but %TEMP% is a large shared area
|
|
98
|
+
# (other apps' state, other agents' sandboxes) and exempting it puts a
|
|
99
|
+
# hole through the containment guarantee for no benefit: eval sandboxes
|
|
100
|
+
# and run_code already run with the workspace AS cwd, so their writes are
|
|
101
|
+
# covered by the rule above. Anything else goes through the allow list.
|
|
102
|
+
for extra in cfg.get("workspace_write_allow", []) or []:
|
|
103
|
+
try:
|
|
104
|
+
if _is_within(resolved, Path(str(extra)).expanduser().resolve()):
|
|
105
|
+
return
|
|
106
|
+
except Exception:
|
|
107
|
+
continue
|
|
108
|
+
raise RuntimeError(
|
|
109
|
+
f"{op} is blocked: {resolved} is outside the workspace "
|
|
110
|
+
f"({root}). Mutations are confined to the working directory. If this "
|
|
111
|
+
"is intended, the user can add the path to workspace_write_allow."
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _is_within(path: Path, root: Path) -> bool:
|
|
116
|
+
try:
|
|
117
|
+
path.relative_to(root)
|
|
118
|
+
return True
|
|
119
|
+
except ValueError:
|
|
120
|
+
return False
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def cwd_resolved() -> str:
|
|
124
|
+
"""Indirection so tests can pin the workspace root."""
|
|
125
|
+
return str(Path.cwd())
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _check_sensitive_path(path: Path, op: str) -> None:
|
|
129
|
+
"""Block file operations on SSH/GPG key dirs and Windows credential stores."""
|
|
130
|
+
try:
|
|
131
|
+
rel = path.relative_to(_HOME)
|
|
132
|
+
top = rel.parts[0].lower() if rel.parts else ""
|
|
133
|
+
except ValueError:
|
|
134
|
+
top = ""
|
|
135
|
+
# NOTE: these messages must never name an alternative route. Until
|
|
136
|
+
# 2026-07-31 they ended "Use run_command for direct access." — live uc3
|
|
137
|
+
# traces show the model reading that and immediately trying
|
|
138
|
+
# `run_command Get-Content ~/.ssh/id_rsa`. A refusal that teaches the
|
|
139
|
+
# bypass is worse than no refusal, because it also looks safe in review.
|
|
140
|
+
if top in _SENSITIVE_HOME_DIRS:
|
|
141
|
+
raise RuntimeError(
|
|
142
|
+
f"{op} is blocked for paths under ~/{rel.parts[0]} "
|
|
143
|
+
"(SSH/GPG keys and config). This is a hard boundary — do not "
|
|
144
|
+
"attempt another route. Tell the user what you wanted and why."
|
|
145
|
+
)
|
|
146
|
+
path_str = str(path).lower()
|
|
147
|
+
if "appdata" in path_str and any(
|
|
148
|
+
s in path_str for s in ("\\microsoft\\credentials", "\\microsoft\\protect")
|
|
149
|
+
):
|
|
150
|
+
raise RuntimeError(
|
|
151
|
+
f"{op} is blocked for Windows credential store paths. This is a "
|
|
152
|
+
"hard boundary — do not attempt another route. Tell the user what "
|
|
153
|
+
"you wanted and why."
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def guard_mutation(path: Path, op: str, config: dict[str, Any] | None) -> None:
|
|
158
|
+
"""The single gate every file-mutating path must pass through.
|
|
159
|
+
|
|
160
|
+
Both checks, always, in this order. It exists because the pair kept coming
|
|
161
|
+
apart: protocol v2's `edit` reimplemented v1's and carried only the
|
|
162
|
+
sensitive-path half, so it could write anywhere on disk. Two calls that
|
|
163
|
+
must always appear together are a latent bug; one call is not.
|
|
164
|
+
|
|
165
|
+
Mutating tools must either call this directly or delegate to a v1 tool that
|
|
166
|
+
does. `evals/test_write_scope.py` drives every mutating entry point in both
|
|
167
|
+
protocols at an out-of-scope path and requires a refusal, so a new tool that
|
|
168
|
+
skips the gate fails CI rather than shipping.
|
|
169
|
+
"""
|
|
170
|
+
_check_sensitive_path(path, op)
|
|
171
|
+
_check_write_scope(path, op, config)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# ---------------------------------------------------------------------------
|
|
175
|
+
# Shell + file tools
|
|
176
|
+
# ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
def detect_shell(shell_hint: str) -> str:
|
|
179
|
+
if shell_hint:
|
|
180
|
+
return shell_hint
|
|
181
|
+
for candidate in ("pwsh.exe", "powershell.exe"):
|
|
182
|
+
resolved = shutil.which(candidate)
|
|
183
|
+
if resolved:
|
|
184
|
+
return resolved
|
|
185
|
+
return "powershell.exe"
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def run_command_tool(
|
|
189
|
+
command: str, shell_exe: str, output_limit: int, *,
|
|
190
|
+
show_command: bool = True,
|
|
191
|
+
timeout: int = DEFAULT_TIMEOUT_SECONDS,
|
|
192
|
+
) -> str:
|
|
193
|
+
if show_command:
|
|
194
|
+
ui.command_echo(command)
|
|
195
|
+
process = subprocess.Popen(
|
|
196
|
+
[shell_exe, "-NoLogo", "-NoProfile", "-Command", command],
|
|
197
|
+
stdout=subprocess.PIPE,
|
|
198
|
+
stderr=subprocess.STDOUT,
|
|
199
|
+
text=True, encoding="utf-8", errors="replace",
|
|
200
|
+
)
|
|
201
|
+
out_q: queue.Queue[str] = queue.Queue()
|
|
202
|
+
|
|
203
|
+
def reader() -> None:
|
|
204
|
+
assert process.stdout is not None
|
|
205
|
+
for line in iter(process.stdout.readline, ""):
|
|
206
|
+
out_q.put(line)
|
|
207
|
+
process.stdout.close()
|
|
208
|
+
|
|
209
|
+
t = threading.Thread(target=reader, daemon=True)
|
|
210
|
+
t.start()
|
|
211
|
+
def _terminate() -> None:
|
|
212
|
+
"""Kill the command AND everything it spawned.
|
|
213
|
+
|
|
214
|
+
process.terminate() only signals the direct child (powershell.exe).
|
|
215
|
+
A command like `npm test` or `python -m http.server` leaves the real
|
|
216
|
+
work running as grandchildren — orphaned, still holding ports/files,
|
|
217
|
+
invisible to the user who just pressed Esc. taskkill /T walks the
|
|
218
|
+
whole tree; the plain kill stays as the fallback.
|
|
219
|
+
"""
|
|
220
|
+
if process.poll() is None:
|
|
221
|
+
killed_tree = False
|
|
222
|
+
try:
|
|
223
|
+
subprocess.run(
|
|
224
|
+
["taskkill", "/T", "/F", "/PID", str(process.pid)],
|
|
225
|
+
capture_output=True, timeout=10,
|
|
226
|
+
)
|
|
227
|
+
killed_tree = True
|
|
228
|
+
except Exception:
|
|
229
|
+
pass
|
|
230
|
+
try:
|
|
231
|
+
process.wait(timeout=3 if killed_tree else 2)
|
|
232
|
+
except subprocess.TimeoutExpired:
|
|
233
|
+
try:
|
|
234
|
+
process.kill()
|
|
235
|
+
except Exception:
|
|
236
|
+
pass
|
|
237
|
+
|
|
238
|
+
parts: list[str] = []
|
|
239
|
+
parts_chars = 0
|
|
240
|
+
open_row = False # the last printed output line had no newline
|
|
241
|
+
# Stop buffering once we have 4× the output limit (UTF-8 max 4B/char).
|
|
242
|
+
# Further lines are still printed to the terminal but not buffered.
|
|
243
|
+
_BUF_CAP = output_limit * 4
|
|
244
|
+
deadline = time.monotonic() + timeout
|
|
245
|
+
try:
|
|
246
|
+
with CancelMonitor() as monitor:
|
|
247
|
+
while t.is_alive() or not out_q.empty() or process.poll() is None:
|
|
248
|
+
if monitor.cancelled.is_set():
|
|
249
|
+
_terminate()
|
|
250
|
+
raise UserCancelled()
|
|
251
|
+
if time.monotonic() > deadline:
|
|
252
|
+
_terminate()
|
|
253
|
+
output = trim_text("".join(parts), output_limit)
|
|
254
|
+
return f"Exit code: TIMEOUT ({timeout}s)\n{output}".strip()
|
|
255
|
+
try:
|
|
256
|
+
line = out_q.get(timeout=0.05)
|
|
257
|
+
except queue.Empty:
|
|
258
|
+
continue
|
|
259
|
+
# Dim on screen: command output is evidence, not the answer.
|
|
260
|
+
print(f"{ui.C.DIM}{line.rstrip(chr(10))}{ui.C.RESET}", end="\n" if line.endswith("\n") else "")
|
|
261
|
+
open_row = not line.endswith("\n")
|
|
262
|
+
if parts_chars < _BUF_CAP:
|
|
263
|
+
parts.append(line)
|
|
264
|
+
parts_chars += len(line)
|
|
265
|
+
except KeyboardInterrupt:
|
|
266
|
+
_terminate()
|
|
267
|
+
raise UserCancelled()
|
|
268
|
+
process.wait()
|
|
269
|
+
output = "".join(parts)
|
|
270
|
+
if open_row:
|
|
271
|
+
print() # the command left its last line unterminated; the exit line gets its own row
|
|
272
|
+
ui.tool_event("run", f"exit {process.returncode}")
|
|
273
|
+
return trim_text(f"Exit code: {process.returncode}\n{output}".strip(), output_limit)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def read_file_tool(path_text: str, output_limit: int,
|
|
277
|
+
offset: int = 0, limit: int = 0) -> str:
|
|
278
|
+
"""Read a file. With offset/limit (1-based line numbers), read one page —
|
|
279
|
+
v1.7 could only ever see the head of a large file, with no way to page."""
|
|
280
|
+
path = resolve_path(path_text)
|
|
281
|
+
_check_sensitive_path(path, "read_file")
|
|
282
|
+
if path.is_dir():
|
|
283
|
+
raise RuntimeError(
|
|
284
|
+
f"{path} is a directory, not a file. Use list_directory to see its contents."
|
|
285
|
+
)
|
|
286
|
+
if offset or limit:
|
|
287
|
+
lines = path.read_text(encoding="utf-8", errors="replace").split("\n")
|
|
288
|
+
total = len(lines)
|
|
289
|
+
start = max(1, int(offset or 1))
|
|
290
|
+
count = max(1, int(limit or 400))
|
|
291
|
+
page = lines[start - 1:start - 1 + count]
|
|
292
|
+
end = min(start - 1 + len(page), total)
|
|
293
|
+
header = f"[lines {start}-{end} of {total}]\n" if (start > 1 or end < total) else ""
|
|
294
|
+
ui.tool_event("read", f"{path} (lines {start}-{end} of {total})")
|
|
295
|
+
return header + trim_text("\n".join(page), output_limit)
|
|
296
|
+
# Avoid loading huge files; read at most 4× output_limit bytes (UTF-8 max 4B/char).
|
|
297
|
+
max_bytes = output_limit * 4
|
|
298
|
+
try:
|
|
299
|
+
size = path.stat().st_size
|
|
300
|
+
except OSError:
|
|
301
|
+
size = 0
|
|
302
|
+
if size > max_bytes:
|
|
303
|
+
with path.open("rb") as fh:
|
|
304
|
+
raw_bytes = fh.read(max_bytes)
|
|
305
|
+
# Byte reads skip universal-newline translation; normalise so CRLF
|
|
306
|
+
# files page on "\n" like the read_text path does.
|
|
307
|
+
content = raw_bytes.decode("utf-8", errors="replace").replace("\r\n", "\n")
|
|
308
|
+
# Count the real line total by streaming — the header must not claim
|
|
309
|
+
# the file ends where our memory-safe pre-read happened to stop.
|
|
310
|
+
total = 1
|
|
311
|
+
with path.open("rb") as fh:
|
|
312
|
+
for chunk in iter(lambda: fh.read(1 << 20), b""):
|
|
313
|
+
total += chunk.count(b"\n")
|
|
314
|
+
else:
|
|
315
|
+
content = path.read_text(encoding="utf-8", errors="replace")
|
|
316
|
+
total = content.count("\n") + 1
|
|
317
|
+
if len(content) <= output_limit and size <= max_bytes:
|
|
318
|
+
ui.tool_event("read", str(path))
|
|
319
|
+
return content
|
|
320
|
+
# Too big for this step's budget: return the FIRST PAGE, line-aligned,
|
|
321
|
+
# with a header that tells the model how to page — a mid-line head cut
|
|
322
|
+
# with "[truncated]" taught it nothing about how much it had not seen.
|
|
323
|
+
page: list[str] = []
|
|
324
|
+
used = 0
|
|
325
|
+
for ln in content.split("\n"):
|
|
326
|
+
if used + len(ln) + 1 > output_limit:
|
|
327
|
+
break
|
|
328
|
+
page.append(ln)
|
|
329
|
+
used += len(ln) + 1
|
|
330
|
+
if not page:
|
|
331
|
+
# A single line bigger than the whole budget (minified/binary-ish):
|
|
332
|
+
# fall back to a plain head cut so the model still sees something.
|
|
333
|
+
ui.tool_event("read", f"{path} (first {output_limit} chars of a {total}-line file)")
|
|
334
|
+
return (f"[first {output_limit} chars of {total} lines; use offset/limit]\n"
|
|
335
|
+
+ content[:output_limit])
|
|
336
|
+
end = len(page)
|
|
337
|
+
header = (f"[lines 1-{end} of {total}. The file continues: call read_file again "
|
|
338
|
+
f"with offset={end + 1} to read the next part.]\n")
|
|
339
|
+
ui.tool_event("read", f"{path} (lines 1-{end} of {total})")
|
|
340
|
+
return header + "\n".join(page)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def edit_file_tool(path_text: str, old_string: str, new_string: str) -> str:
|
|
344
|
+
"""Replace old_string with new_string.
|
|
345
|
+
|
|
346
|
+
Exact match first; when that fails, fall back to the 3-tier fuzzy applier
|
|
347
|
+
(trailing-whitespace-insensitive, then indent-shifted) rather than erroring
|
|
348
|
+
out — the v1.7 audit found the model frequently mis-copies whitespace or
|
|
349
|
+
indentation, and a hard failure there burned whole step budgets. Ambiguity
|
|
350
|
+
is still an error, never a guess, and a genuine no-match now reports the
|
|
351
|
+
closest region with line numbers so the retry has something to work with.
|
|
352
|
+
"""
|
|
353
|
+
from .protocol_v2 import apply_search_replace
|
|
354
|
+
|
|
355
|
+
path = resolve_path(path_text)
|
|
356
|
+
guard_mutation(path, "edit_file", _active_config())
|
|
357
|
+
if not old_string:
|
|
358
|
+
raise RuntimeError("edit_file requires a non-empty 'old_string'. Use write_file to overwrite the whole file.")
|
|
359
|
+
if not path.exists():
|
|
360
|
+
raise RuntimeError(f"File not found: {path}")
|
|
361
|
+
content = path.read_text(encoding="utf-8")
|
|
362
|
+
if content.count(old_string) == 1:
|
|
363
|
+
new_content = content.replace(old_string, new_string, 1)
|
|
364
|
+
else:
|
|
365
|
+
new_content, err = apply_search_replace(content, [(old_string, new_string)])
|
|
366
|
+
if err:
|
|
367
|
+
raise RuntimeError(err.replace("SEARCH block 1", "old_string"))
|
|
368
|
+
tmp = path.parent / (path.name + ".tmp")
|
|
369
|
+
tmp.write_text(new_content, encoding="utf-8")
|
|
370
|
+
tmp.replace(path)
|
|
371
|
+
delta = new_string.count("\n") - old_string.count("\n")
|
|
372
|
+
ui.tool_event("edit", f"{path} ({delta:+d} lines)")
|
|
373
|
+
return f"Edited {path}"
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def write_file_tool(path_text: str, content: str) -> str:
|
|
377
|
+
path = resolve_path(path_text)
|
|
378
|
+
guard_mutation(path, "write_file", _active_config())
|
|
379
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
380
|
+
tmp = path.parent / (path.name + ".tmp")
|
|
381
|
+
tmp.write_text(content, encoding="utf-8")
|
|
382
|
+
tmp.replace(path)
|
|
383
|
+
ui.tool_event("write", f"{path} ({len(content)} chars)")
|
|
384
|
+
return f"Wrote {path}"
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def append_file_tool(path_text: str, content: str) -> str:
|
|
388
|
+
path = resolve_path(path_text)
|
|
389
|
+
guard_mutation(path, "append_file", _active_config())
|
|
390
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
391
|
+
existing = path.read_text(encoding="utf-8") if path.exists() else ""
|
|
392
|
+
tmp = path.parent / (path.name + ".tmp")
|
|
393
|
+
tmp.write_text(existing + content, encoding="utf-8")
|
|
394
|
+
tmp.replace(path)
|
|
395
|
+
ui.tool_event("append", f"{path} ({len(content)} chars)")
|
|
396
|
+
return f"Appended to {path}"
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def list_directory_tool(path_text: str, output_limit: int) -> str:
|
|
400
|
+
path = resolve_path(path_text or ".")
|
|
401
|
+
_check_sensitive_path(path, "list_directory")
|
|
402
|
+
if not path.exists():
|
|
403
|
+
raise RuntimeError(f"Directory not found: {path}")
|
|
404
|
+
if not path.is_dir():
|
|
405
|
+
raise RuntimeError(f"Not a directory: {path}")
|
|
406
|
+
entries = []
|
|
407
|
+
for child in sorted(path.iterdir(), key=lambda p: (p.is_file(), p.name.lower())):
|
|
408
|
+
entries.append(child.name + ("/" if child.is_dir() else ""))
|
|
409
|
+
result = "\n".join(entries) or "(empty)"
|
|
410
|
+
ui.tool_event("list", f"{path} ({len(entries)} entries)")
|
|
411
|
+
return trim_text(result, output_limit)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
_SEARCH_EXCLUDE_DIRS = frozenset({
|
|
415
|
+
".shellai", ".git", ".hg", ".svn",
|
|
416
|
+
"node_modules", "__pycache__", ".venv", "venv",
|
|
417
|
+
".tox", ".mypy_cache", ".pytest_cache", ".ruff_cache",
|
|
418
|
+
})
|
|
419
|
+
_SEARCH_MAX_FILE_BYTES = 500_000 # skip files likely to be binary blobs
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def search_files_tool(pattern: str, path_text: str, glob_pattern: str, output_limit: int) -> str:
|
|
423
|
+
search_path = resolve_path(path_text or ".")
|
|
424
|
+
_check_sensitive_path(search_path, "search_files")
|
|
425
|
+
glob_pattern = glob_pattern or "*"
|
|
426
|
+
results: list[str] = []
|
|
427
|
+
try:
|
|
428
|
+
compiled = re.compile(pattern)
|
|
429
|
+
except re.error as exc:
|
|
430
|
+
raise RuntimeError(f"Invalid regex: {exc}") from exc
|
|
431
|
+
try:
|
|
432
|
+
candidates = sorted(search_path.rglob(glob_pattern))
|
|
433
|
+
except ValueError as exc:
|
|
434
|
+
raise RuntimeError(f"Invalid glob pattern {glob_pattern!r}: {exc}") from exc
|
|
435
|
+
except (OSError, PermissionError):
|
|
436
|
+
candidates = []
|
|
437
|
+
for fp in candidates:
|
|
438
|
+
if not fp.is_file():
|
|
439
|
+
continue
|
|
440
|
+
# Skip hidden and data directories (e.g. .shellai/models/, .git/, node_modules/)
|
|
441
|
+
try:
|
|
442
|
+
rel_parts = fp.relative_to(search_path).parts[:-1]
|
|
443
|
+
except ValueError:
|
|
444
|
+
continue
|
|
445
|
+
if any(
|
|
446
|
+
p.lower() in _SEARCH_EXCLUDE_DIRS or (p.startswith(".") and len(p) > 1)
|
|
447
|
+
for p in rel_parts
|
|
448
|
+
):
|
|
449
|
+
continue
|
|
450
|
+
# Skip large files (binary blobs, model weights, lock files)
|
|
451
|
+
try:
|
|
452
|
+
if fp.stat().st_size > _SEARCH_MAX_FILE_BYTES:
|
|
453
|
+
continue
|
|
454
|
+
except OSError:
|
|
455
|
+
continue
|
|
456
|
+
try:
|
|
457
|
+
for i, line in enumerate(fp.read_text(encoding="utf-8", errors="replace").splitlines(), 1):
|
|
458
|
+
if compiled.search(line):
|
|
459
|
+
results.append(f"{fp}:{i}: {line}")
|
|
460
|
+
except (OSError, PermissionError):
|
|
461
|
+
pass
|
|
462
|
+
result = "\n".join(results) if results else f"No matches for '{pattern}'"
|
|
463
|
+
ui.tool_event("search", f"'{pattern}' in {search_path}/**/{glob_pattern} ({len(results)} matches)")
|
|
464
|
+
return trim_text(result, output_limit)
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def find_files_tool(glob_pattern: str, path_text: str, output_limit: int) -> str:
|
|
468
|
+
search_path = resolve_path(path_text or ".")
|
|
469
|
+
_check_sensitive_path(search_path, "find_files")
|
|
470
|
+
filtered: list[Path] = []
|
|
471
|
+
try:
|
|
472
|
+
candidates = sorted(search_path.rglob(glob_pattern or "*"))
|
|
473
|
+
except ValueError as exc:
|
|
474
|
+
raise RuntimeError(f"Invalid glob pattern {glob_pattern!r}: {exc}") from exc
|
|
475
|
+
except (OSError, PermissionError):
|
|
476
|
+
candidates = []
|
|
477
|
+
for p in candidates:
|
|
478
|
+
if not p.is_file():
|
|
479
|
+
continue
|
|
480
|
+
try:
|
|
481
|
+
rel_parts = p.relative_to(search_path).parts[:-1]
|
|
482
|
+
except ValueError:
|
|
483
|
+
continue
|
|
484
|
+
if any(
|
|
485
|
+
part.lower() in _SEARCH_EXCLUDE_DIRS or (part.startswith(".") and len(part) > 1)
|
|
486
|
+
for part in rel_parts
|
|
487
|
+
):
|
|
488
|
+
continue
|
|
489
|
+
filtered.append(p)
|
|
490
|
+
result = "\n".join(str(p) for p in filtered) if filtered else f"No files matching '{glob_pattern}'"
|
|
491
|
+
ui.tool_event("find", f"{glob_pattern} in {search_path} ({len(filtered)} files)")
|
|
492
|
+
return trim_text(result, output_limit)
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
_LANGUAGE_BY_EXT = {
|
|
496
|
+
".py": "python", ".pyw": "python",
|
|
497
|
+
".json": "json",
|
|
498
|
+
".ps1": "powershell", ".psm1": "powershell", ".psd1": "powershell",
|
|
499
|
+
".js": "node", ".mjs": "node", ".cjs": "node",
|
|
500
|
+
".ts": "node", ".tsx": "node", ".jsx": "node",
|
|
501
|
+
}
|
|
502
|
+
_VERIFY_MAX_BYTES = 500_000 # skip files too large for in-process parse
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _verify_python_syntax(path: Path) -> tuple[bool, str]:
|
|
506
|
+
try:
|
|
507
|
+
size = path.stat().st_size
|
|
508
|
+
except OSError:
|
|
509
|
+
size = 0
|
|
510
|
+
if size > _VERIFY_MAX_BYTES:
|
|
511
|
+
return True, f"OK: skipped (file too large: {size} bytes)"
|
|
512
|
+
source = path.read_text(encoding="utf-8", errors="replace")
|
|
513
|
+
try:
|
|
514
|
+
ast.parse(source, filename=str(path))
|
|
515
|
+
return True, "OK: no syntax errors"
|
|
516
|
+
except SyntaxError as exc:
|
|
517
|
+
return False, f"FAIL: line {exc.lineno}, col {exc.offset}: {exc.msg}"
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _verify_json_syntax(path: Path) -> tuple[bool, str]:
|
|
521
|
+
try:
|
|
522
|
+
size = path.stat().st_size
|
|
523
|
+
except OSError:
|
|
524
|
+
size = 0
|
|
525
|
+
if size > _VERIFY_MAX_BYTES:
|
|
526
|
+
return True, f"OK: skipped (file too large: {size} bytes)"
|
|
527
|
+
source = path.read_text(encoding="utf-8", errors="replace")
|
|
528
|
+
try:
|
|
529
|
+
json.loads(source)
|
|
530
|
+
return True, "OK: valid JSON"
|
|
531
|
+
except json.JSONDecodeError as exc:
|
|
532
|
+
return False, f"FAIL: line {exc.lineno}, col {exc.colno}: {exc.msg}"
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _verify_powershell_syntax(path: Path, shell_exe: str) -> tuple[bool, str]:
|
|
536
|
+
# [Parser]::ParseFile only tokenizes/parses an AST — it never invokes the script,
|
|
537
|
+
# so this is as non-destructive as the Python ast.parse() check above.
|
|
538
|
+
escaped = str(path).replace("'", "''")
|
|
539
|
+
script = (
|
|
540
|
+
f"$perr = $null; "
|
|
541
|
+
f"[void][System.Management.Automation.Language.Parser]::ParseFile('{escaped}', [ref]$null, [ref]$perr); "
|
|
542
|
+
f"if ($perr) {{ $perr | ForEach-Object {{ Write-Output $_.Message }}; exit 1 }} "
|
|
543
|
+
f"else {{ Write-Output 'OK' }}"
|
|
544
|
+
)
|
|
545
|
+
try:
|
|
546
|
+
result = subprocess.run(
|
|
547
|
+
[shell_exe, "-NoLogo", "-NoProfile", "-Command", script],
|
|
548
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=15,
|
|
549
|
+
)
|
|
550
|
+
except Exception as exc:
|
|
551
|
+
return True, f"OK: skipped (could not run PowerShell parser: {exc})"
|
|
552
|
+
if result.returncode == 0:
|
|
553
|
+
return True, "OK: no syntax errors"
|
|
554
|
+
return False, f"FAIL: {result.stdout.strip() or result.stderr.strip()}"
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def _verify_node_syntax(path: Path) -> tuple[bool, str]:
|
|
558
|
+
node = shutil.which("node")
|
|
559
|
+
if not node:
|
|
560
|
+
return True, f"OK: skipped (no checker available for {path.suffix} — node not found on PATH)"
|
|
561
|
+
try:
|
|
562
|
+
result = subprocess.run(
|
|
563
|
+
[node, "--check", str(path)],
|
|
564
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=15,
|
|
565
|
+
)
|
|
566
|
+
except Exception as exc:
|
|
567
|
+
return True, f"OK: skipped (could not run node --check: {exc})"
|
|
568
|
+
if result.returncode == 0:
|
|
569
|
+
return True, "OK: no syntax errors"
|
|
570
|
+
return False, f"FAIL: {result.stderr.strip() or result.stdout.strip()}"
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def verify_syntax_tool(path_text: str, language: str, shell_exe: str) -> str:
|
|
574
|
+
path = resolve_path(path_text)
|
|
575
|
+
if not path.exists():
|
|
576
|
+
raise RuntimeError(f"File not found: {path}")
|
|
577
|
+
lang = (language or "").strip().lower() or _LANGUAGE_BY_EXT.get(path.suffix.lower(), "")
|
|
578
|
+
if lang == "python":
|
|
579
|
+
ok, detail = _verify_python_syntax(path)
|
|
580
|
+
elif lang == "json":
|
|
581
|
+
ok, detail = _verify_json_syntax(path)
|
|
582
|
+
elif lang == "powershell":
|
|
583
|
+
ok, detail = _verify_powershell_syntax(path, shell_exe)
|
|
584
|
+
elif lang == "node" or path.suffix.lower() in {".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx"}:
|
|
585
|
+
ok, detail = _verify_node_syntax(path)
|
|
586
|
+
else:
|
|
587
|
+
ok, detail = True, f"OK: skipped (no syntax checker for '{path.suffix or language or 'unknown'}')"
|
|
588
|
+
ui.tool_event("verify", f"{path} ({'pass' if ok else 'fail'})")
|
|
589
|
+
return detail
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def lint_code_tool(path_text: str) -> str:
|
|
593
|
+
if not _RUFF:
|
|
594
|
+
raise RuntimeError("ruff is not on PATH — lint_code is unavailable.")
|
|
595
|
+
path = resolve_path(path_text)
|
|
596
|
+
if not path.exists():
|
|
597
|
+
raise RuntimeError(f"File not found: {path}")
|
|
598
|
+
try:
|
|
599
|
+
result = subprocess.run(
|
|
600
|
+
[_RUFF, "check", "--output-format=concise", str(path)],
|
|
601
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=30,
|
|
602
|
+
)
|
|
603
|
+
except Exception as exc:
|
|
604
|
+
raise RuntimeError(f"ruff failed: {exc}") from exc
|
|
605
|
+
output = (result.stdout + result.stderr).strip()
|
|
606
|
+
status = "clean" if result.returncode == 0 else f"{result.returncode} issue(s)"
|
|
607
|
+
ui.tool_event("lint", f"{path} ({status})")
|
|
608
|
+
if result.returncode == 0:
|
|
609
|
+
return f"OK: no issues in {path}"
|
|
610
|
+
return output if output else f"OK: no issues in {path}"
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
_RUN_CODE_INTERPRETERS: dict[str, list[str]] = {
|
|
614
|
+
".py": [sys.executable],
|
|
615
|
+
".ps1": [], # filled in at call time with shell_exe
|
|
616
|
+
".js": ["node"],
|
|
617
|
+
".mjs": ["node"],
|
|
618
|
+
".cjs": ["node"],
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def run_code_tool(
|
|
623
|
+
path_text: str,
|
|
624
|
+
run_args: list[str],
|
|
625
|
+
timeout: int,
|
|
626
|
+
shell_exe: str,
|
|
627
|
+
output_limit: int,
|
|
628
|
+
) -> str:
|
|
629
|
+
cwd = Path.cwd().resolve()
|
|
630
|
+
path = resolve_path(path_text)
|
|
631
|
+
if not path.exists():
|
|
632
|
+
raise RuntimeError(f"File not found: {path}")
|
|
633
|
+
if not path.is_relative_to(cwd):
|
|
634
|
+
raise RuntimeError(
|
|
635
|
+
f"run_code is restricted to files under the working directory ({cwd}). "
|
|
636
|
+
f"Resolved path was: {path}"
|
|
637
|
+
)
|
|
638
|
+
ext = path.suffix.lower()
|
|
639
|
+
if ext in {".js", ".mjs", ".cjs"} and not shutil.which("node"):
|
|
640
|
+
raise RuntimeError("node not found on PATH — cannot run .js/.mjs/.cjs files")
|
|
641
|
+
if ext == ".ps1":
|
|
642
|
+
cmd_prefix = [shell_exe, "-NoLogo", "-NoProfile", "-File"]
|
|
643
|
+
else:
|
|
644
|
+
cmd_prefix = _RUN_CODE_INTERPRETERS.get(ext)
|
|
645
|
+
if cmd_prefix is None:
|
|
646
|
+
raise RuntimeError(
|
|
647
|
+
f"Unsupported extension {ext!r} for run_code. "
|
|
648
|
+
"Allowed: .py .ps1 .js .mjs .cjs"
|
|
649
|
+
)
|
|
650
|
+
cmd = [*cmd_prefix, str(path), *[str(a) for a in run_args]]
|
|
651
|
+
try:
|
|
652
|
+
proc = subprocess.Popen(
|
|
653
|
+
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
654
|
+
text=True, encoding="utf-8", errors="replace",
|
|
655
|
+
)
|
|
656
|
+
except (PermissionError, FileNotFoundError, OSError) as exc:
|
|
657
|
+
raise RuntimeError(f"Failed to launch interpreter for {path.name}: {exc}") from exc
|
|
658
|
+
try:
|
|
659
|
+
stdout, stderr = proc.communicate(timeout=timeout)
|
|
660
|
+
except subprocess.TimeoutExpired:
|
|
661
|
+
proc.kill()
|
|
662
|
+
stdout, stderr = proc.communicate()
|
|
663
|
+
out = trim_text(stdout, output_limit)
|
|
664
|
+
err = trim_text(stderr, output_limit)
|
|
665
|
+
ui.tool_event("run", f"{path} (TIMEOUT after {timeout}s)")
|
|
666
|
+
return (
|
|
667
|
+
f"Exit code: TIMEOUT ({timeout}s exceeded)\n\n"
|
|
668
|
+
f"[stdout]\n{out or '(empty)'}\n\n"
|
|
669
|
+
f"[stderr]\n{err or '(empty)'}"
|
|
670
|
+
)
|
|
671
|
+
out = trim_text(stdout, output_limit)
|
|
672
|
+
err = trim_text(stderr, output_limit)
|
|
673
|
+
ui.tool_event("run", f"{path} (exit {proc.returncode})")
|
|
674
|
+
return (
|
|
675
|
+
f"Exit code: {proc.returncode}\n\n"
|
|
676
|
+
f"[stdout]\n{out or '(empty)'}\n\n"
|
|
677
|
+
f"[stderr]\n{err or '(empty)'}"
|
|
678
|
+
)
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def workspace_snapshot(cwd: str) -> str:
|
|
682
|
+
"""Return a compact ≤150-token workspace context line prepended to each agent turn."""
|
|
683
|
+
p = Path(cwd)
|
|
684
|
+
parts: list[str] = []
|
|
685
|
+
|
|
686
|
+
# Project type detection via marker files
|
|
687
|
+
proj = "dir"
|
|
688
|
+
if (p / "pyproject.toml").exists() or (p / "setup.py").exists() or (p / "requirements.txt").exists():
|
|
689
|
+
proj = "python"
|
|
690
|
+
elif (p / "package.json").exists():
|
|
691
|
+
proj = "node"
|
|
692
|
+
elif (p / "Cargo.toml").exists():
|
|
693
|
+
proj = "rust"
|
|
694
|
+
elif (p / "go.mod").exists():
|
|
695
|
+
proj = "go"
|
|
696
|
+
elif list(p.glob("*.sln")) or list(p.glob("*.csproj")):
|
|
697
|
+
proj = "csharp"
|
|
698
|
+
parts.append(f"workspace:{proj}")
|
|
699
|
+
|
|
700
|
+
# Git branch + dirty flag (0.5 s timeout — fast enough, safe on slow NTFS)
|
|
701
|
+
try:
|
|
702
|
+
br = subprocess.run(
|
|
703
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
704
|
+
cwd=cwd, capture_output=True, text=True, timeout=0.5,
|
|
705
|
+
)
|
|
706
|
+
if br.returncode == 0:
|
|
707
|
+
branch = br.stdout.strip()
|
|
708
|
+
# --porcelain detects staged, unstaged, and untracked changes in one call;
|
|
709
|
+
# git diff --quiet only detects unstaged changes (misses staged commits).
|
|
710
|
+
status = subprocess.run(
|
|
711
|
+
["git", "status", "--porcelain"],
|
|
712
|
+
cwd=cwd, capture_output=True, text=True, timeout=0.5,
|
|
713
|
+
)
|
|
714
|
+
dirty = bool(status.stdout.strip())
|
|
715
|
+
parts.append(f"git:{branch}{'*' if dirty else ''}")
|
|
716
|
+
except Exception:
|
|
717
|
+
pass
|
|
718
|
+
|
|
719
|
+
# Primary entry point
|
|
720
|
+
for name in ("shellai.py", "main.py", "app.py", "index.js", "main.rs", "main.go", "main.cs"):
|
|
721
|
+
if (p / name).exists():
|
|
722
|
+
parts.append(f"entry:{name}")
|
|
723
|
+
break
|
|
724
|
+
|
|
725
|
+
# Test directory
|
|
726
|
+
for tdir in ("tests", "test", "evals", "spec"):
|
|
727
|
+
if (p / tdir).is_dir():
|
|
728
|
+
parts.append(f"tests:{tdir}/")
|
|
729
|
+
break
|
|
730
|
+
|
|
731
|
+
tag_line = "[" + " | ".join(parts) + "]"
|
|
732
|
+
sections = [tag_line]
|
|
733
|
+
|
|
734
|
+
project = read_project_instructions(p)
|
|
735
|
+
if project:
|
|
736
|
+
sections.append("Project instructions:\n" + project)
|
|
737
|
+
|
|
738
|
+
rules = memory.read_memory_rules(5)
|
|
739
|
+
if rules:
|
|
740
|
+
sections.append("Prior knowledge:\n" + "\n".join(f" {r}" for r in rules))
|
|
741
|
+
return "\n".join(sections)
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
# Per-project instructions, in precedence order. AGENTS.md is the cross-tool
|
|
745
|
+
# convention; the .shellai/ variant lets you keep it out of the repo.
|
|
746
|
+
_PROJECT_INSTRUCTION_FILES = ("AGENTS.md", ".shellai/AGENTS.md", "HEXCLI.md")
|
|
747
|
+
_PROJECT_INSTRUCTIONS_MAX_CHARS = 1200 # ~300 tokens — see docs/V2_PLAN.md §6.2
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
def read_project_instructions(cwd: Path, max_chars: int = _PROJECT_INSTRUCTIONS_MAX_CHARS) -> str:
|
|
751
|
+
"""Read the project's agent instructions, hard-capped.
|
|
752
|
+
|
|
753
|
+
Every character here is prompt tokens on EVERY turn, against a measured
|
|
754
|
+
~2,600-token degradation cliff — so the cap is deliberate and the
|
|
755
|
+
truncation is loud rather than silent, otherwise a long AGENTS.md would
|
|
756
|
+
quietly push the model over the edge and look like a model regression.
|
|
757
|
+
"""
|
|
758
|
+
for name in _PROJECT_INSTRUCTION_FILES:
|
|
759
|
+
path = cwd / name
|
|
760
|
+
try:
|
|
761
|
+
if not path.is_file():
|
|
762
|
+
continue
|
|
763
|
+
text = path.read_text(encoding="utf-8", errors="replace").strip()
|
|
764
|
+
except OSError:
|
|
765
|
+
continue
|
|
766
|
+
if not text:
|
|
767
|
+
continue
|
|
768
|
+
# Drop comment-only and heading-only noise to spend the budget on rules.
|
|
769
|
+
lines = [ln.rstrip() for ln in text.splitlines()]
|
|
770
|
+
body = "\n".join(ln for ln in lines if ln.strip())
|
|
771
|
+
if len(body) > max_chars:
|
|
772
|
+
body = body[:max_chars].rsplit("\n", 1)[0]
|
|
773
|
+
body += f"\n […{name} truncated to {max_chars} chars to protect the context budget]"
|
|
774
|
+
return body
|
|
775
|
+
return ""
|