lcode-cli 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.
- lcode/__init__.py +3 -0
- lcode/__main__.py +3 -0
- lcode/agent.py +376 -0
- lcode/catalog.py +114 -0
- lcode/cli.py +437 -0
- lcode/config.py +135 -0
- lcode/hardware.py +84 -0
- lcode/models.toml +128 -0
- lcode/ollama.py +163 -0
- lcode/permissions.py +90 -0
- lcode/render.py +43 -0
- lcode/repl.py +281 -0
- lcode/tools.py +533 -0
- lcode_cli-0.1.1.dist-info/METADATA +181 -0
- lcode_cli-0.1.1.dist-info/RECORD +18 -0
- lcode_cli-0.1.1.dist-info/WHEEL +4 -0
- lcode_cli-0.1.1.dist-info/entry_points.txt +2 -0
- lcode_cli-0.1.1.dist-info/licenses/LICENSE +22 -0
lcode/tools.py
ADDED
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
"""The tools the model can call: file reading/editing, search, shell and a todo list."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import difflib
|
|
6
|
+
import fnmatch
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import queue
|
|
10
|
+
import re
|
|
11
|
+
import shlex
|
|
12
|
+
import shutil
|
|
13
|
+
import signal
|
|
14
|
+
import subprocess
|
|
15
|
+
import tempfile
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import TYPE_CHECKING
|
|
20
|
+
|
|
21
|
+
from rich.panel import Panel
|
|
22
|
+
from rich.syntax import Syntax
|
|
23
|
+
from rich.text import Text
|
|
24
|
+
|
|
25
|
+
from lcode.permissions import bash_key, is_read_only
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from lcode.agent import Agent
|
|
29
|
+
|
|
30
|
+
MAX_TOOL_OUTPUT = 30_000 # characters returned to the model per tool call
|
|
31
|
+
IGNORE_DIRS = {
|
|
32
|
+
".git", "node_modules", "__pycache__", ".venv", "venv", "env", ".mypy_cache", ".pytest_cache",
|
|
33
|
+
".ruff_cache", ".tox", ".idea", ".vscode", "dist", "build", ".next", "target", ".cache", ".gradle",
|
|
34
|
+
"site-packages", ".eggs", ".DS_Store",
|
|
35
|
+
} # fmt: skip
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _fn(name: str, description: str, properties: dict, required: list[str]) -> dict:
|
|
39
|
+
return {
|
|
40
|
+
"type": "function",
|
|
41
|
+
"function": {
|
|
42
|
+
"name": name,
|
|
43
|
+
"description": description,
|
|
44
|
+
"parameters": {"type": "object", "properties": properties, "required": required},
|
|
45
|
+
},
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
SCHEMAS = [
|
|
50
|
+
_fn(
|
|
51
|
+
"read_file",
|
|
52
|
+
"Read a text file. Returns lines prefixed with line numbers (cat -n style). Reads up to `limit` lines "
|
|
53
|
+
"starting at `offset` (1-based). Read a file before editing it.",
|
|
54
|
+
{
|
|
55
|
+
"path": {"type": "string", "description": "File path, relative to the working directory or absolute"},
|
|
56
|
+
"offset": {"type": "integer", "description": "1-based line to start from (default 1)"},
|
|
57
|
+
"limit": {"type": "integer", "description": "Maximum lines to read (default 2000)"},
|
|
58
|
+
},
|
|
59
|
+
["path"],
|
|
60
|
+
),
|
|
61
|
+
_fn(
|
|
62
|
+
"write_file",
|
|
63
|
+
"Create a new file or completely overwrite an existing one with `content`. Parent directories are "
|
|
64
|
+
"created. For small changes to existing files prefer edit_file.",
|
|
65
|
+
{"path": {"type": "string"}, "content": {"type": "string", "description": "Full file content"}},
|
|
66
|
+
["path", "content"],
|
|
67
|
+
),
|
|
68
|
+
_fn(
|
|
69
|
+
"edit_file",
|
|
70
|
+
"Replace `old_string` with `new_string` in a file. `old_string` must match the file exactly (including "
|
|
71
|
+
"indentation) and be unique unless replace_all is true: include enough surrounding lines to make it "
|
|
72
|
+
"unique. Do NOT include the line-number prefixes from read_file output.",
|
|
73
|
+
{
|
|
74
|
+
"path": {"type": "string"},
|
|
75
|
+
"old_string": {"type": "string", "description": "Exact text to replace"},
|
|
76
|
+
"new_string": {"type": "string", "description": "Replacement text"},
|
|
77
|
+
"replace_all": {"type": "boolean", "description": "Replace every occurrence (default false)"},
|
|
78
|
+
},
|
|
79
|
+
["path", "old_string", "new_string"],
|
|
80
|
+
),
|
|
81
|
+
_fn(
|
|
82
|
+
"list_dir",
|
|
83
|
+
"Show a directory tree (skips .git, node_modules, virtualenvs and caches).",
|
|
84
|
+
{
|
|
85
|
+
"path": {"type": "string", "description": "Directory (default: working directory)"},
|
|
86
|
+
"depth": {"type": "integer", "description": "Maximum depth (default 2)"},
|
|
87
|
+
},
|
|
88
|
+
[],
|
|
89
|
+
),
|
|
90
|
+
_fn(
|
|
91
|
+
"glob",
|
|
92
|
+
"Find files by glob pattern, e.g. '**/*.py' or 'src/**/test_*.ts'. Newest first.",
|
|
93
|
+
{"pattern": {"type": "string"}, "path": {"type": "string", "description": "Base directory"}},
|
|
94
|
+
["pattern"],
|
|
95
|
+
),
|
|
96
|
+
_fn(
|
|
97
|
+
"grep",
|
|
98
|
+
"Search file contents with a regular expression. Returns path:line:text matches.",
|
|
99
|
+
{
|
|
100
|
+
"pattern": {"type": "string", "description": "Regular expression"},
|
|
101
|
+
"path": {"type": "string", "description": "File or directory to search (default: working directory)"},
|
|
102
|
+
"glob": {"type": "string", "description": "Only search files matching this glob, e.g. '*.py'"},
|
|
103
|
+
"ignore_case": {"type": "boolean"},
|
|
104
|
+
"context": {"type": "integer", "description": "Lines of context around each match"},
|
|
105
|
+
},
|
|
106
|
+
["pattern"],
|
|
107
|
+
),
|
|
108
|
+
_fn(
|
|
109
|
+
"bash",
|
|
110
|
+
"Run a shell command with bash in the working directory and return its output and exit code. The "
|
|
111
|
+
"working directory persists between calls (cd works). Use it to run scripts and tests, use git, install "
|
|
112
|
+
"packages, etc. Avoid interactive commands.",
|
|
113
|
+
{
|
|
114
|
+
"command": {"type": "string"},
|
|
115
|
+
"timeout": {"type": "integer", "description": "Seconds before the command is killed (default 180)"},
|
|
116
|
+
},
|
|
117
|
+
["command"],
|
|
118
|
+
),
|
|
119
|
+
_fn(
|
|
120
|
+
"todo_write",
|
|
121
|
+
"Create or update your task list for multi-step work. Pass the full list every time. Keep exactly one "
|
|
122
|
+
"item in_progress while working.",
|
|
123
|
+
{
|
|
124
|
+
"todos": {
|
|
125
|
+
"type": "array",
|
|
126
|
+
"items": {
|
|
127
|
+
"type": "object",
|
|
128
|
+
"properties": {
|
|
129
|
+
"content": {"type": "string"},
|
|
130
|
+
"status": {"type": "string", "enum": ["pending", "in_progress", "completed"]},
|
|
131
|
+
},
|
|
132
|
+
"required": ["content", "status"],
|
|
133
|
+
},
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
["todos"],
|
|
137
|
+
),
|
|
138
|
+
]
|
|
139
|
+
TOOL_NAMES = {s["function"]["name"] for s in SCHEMAS}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ----------------------------------------------------------------------------- helpers
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def truncate(text: str, limit: int = MAX_TOOL_OUTPUT) -> str:
|
|
146
|
+
if len(text) <= limit:
|
|
147
|
+
return text
|
|
148
|
+
head, tail = text[: limit * 2 // 3], text[-limit // 3 :]
|
|
149
|
+
return f"{head}\n\n... [{len(text) - len(head) - len(tail)} characters truncated] ...\n\n{tail}"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def is_binary(path: Path) -> bool:
|
|
153
|
+
try:
|
|
154
|
+
with open(path, "rb") as f:
|
|
155
|
+
return b"\0" in f.read(8192)
|
|
156
|
+
except OSError:
|
|
157
|
+
return False
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def walk_files(base: Path):
|
|
161
|
+
for root, dirs, files in os.walk(base):
|
|
162
|
+
dirs[:] = sorted(d for d in dirs if d not in IGNORE_DIRS and not d.endswith(".egg-info"))
|
|
163
|
+
for name in sorted(files):
|
|
164
|
+
if name not in IGNORE_DIRS:
|
|
165
|
+
yield Path(root) / name
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def tree(base: Path, depth: int = 2, limit: int = 400) -> str:
|
|
169
|
+
lines, count = [f"{base}/"], 0
|
|
170
|
+
|
|
171
|
+
def walk(directory: Path, prefix: str, level: int) -> None:
|
|
172
|
+
nonlocal count
|
|
173
|
+
try:
|
|
174
|
+
entries = sorted(directory.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower()))
|
|
175
|
+
except OSError:
|
|
176
|
+
return
|
|
177
|
+
entries = [e for e in entries if e.name not in IGNORE_DIRS]
|
|
178
|
+
for i, entry in enumerate(entries):
|
|
179
|
+
if count >= limit:
|
|
180
|
+
lines.append(f"{prefix}... (truncated)")
|
|
181
|
+
return
|
|
182
|
+
count += 1
|
|
183
|
+
last = i == len(entries) - 1
|
|
184
|
+
lines.append(f"{prefix}{'└── ' if last else '├── '}{entry.name}{'/' if entry.is_dir() else ''}")
|
|
185
|
+
if entry.is_dir() and level < depth:
|
|
186
|
+
walk(entry, prefix + (" " if last else "│ "), level + 1)
|
|
187
|
+
|
|
188
|
+
walk(base, "", 1)
|
|
189
|
+
return "\n".join(lines)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def fuzzy_replace(text: str, old: str, new: str) -> str | None:
|
|
193
|
+
"""Replace `old` with `new` matching lines while ignoring trailing whitespace. None unless exactly one match."""
|
|
194
|
+
t_lines = text.splitlines(keepends=True)
|
|
195
|
+
o_lines = [line.rstrip() for line in old.strip("\n").splitlines()]
|
|
196
|
+
if not o_lines:
|
|
197
|
+
return None
|
|
198
|
+
n = len(o_lines)
|
|
199
|
+
matches = [i for i in range(len(t_lines) - n + 1) if [ln.rstrip() for ln in t_lines[i : i + n]] == o_lines]
|
|
200
|
+
if len(matches) != 1:
|
|
201
|
+
return None
|
|
202
|
+
i = matches[0]
|
|
203
|
+
tail = "\n" if t_lines[i + n - 1].endswith("\n") else ""
|
|
204
|
+
replacement = new.strip("\n") + tail if new.strip("\n") else ""
|
|
205
|
+
return "".join(t_lines[:i]) + replacement + "".join(t_lines[i + n :])
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def parse_text_tool_calls(content: str) -> list[dict]:
|
|
209
|
+
"""Recover tool calls that a model printed as text instead of emitting structured calls."""
|
|
210
|
+
calls = []
|
|
211
|
+
for block in re.findall(r"<tool_call>(.*?)</tool_call>", content, re.S):
|
|
212
|
+
block = block.strip()
|
|
213
|
+
fn = re.search(r"<function=([\w.-]+)>(.*?)</function>", block, re.S)
|
|
214
|
+
if fn:
|
|
215
|
+
params = re.findall(r"<parameter=([\w.-]+)>(.*?)</parameter>", fn.group(2), re.S)
|
|
216
|
+
calls.append({"function": {"name": fn.group(1), "arguments": {k: v.strip("\n") for k, v in params}}})
|
|
217
|
+
continue
|
|
218
|
+
try:
|
|
219
|
+
obj = json.loads(block)
|
|
220
|
+
calls.append({"function": {"name": obj["name"], "arguments": obj.get("arguments", {})}})
|
|
221
|
+
except (json.JSONDecodeError, KeyError, TypeError):
|
|
222
|
+
pass
|
|
223
|
+
if calls:
|
|
224
|
+
return calls
|
|
225
|
+
# Bare JSON objects such as {"name": "grep", "arguments": {...}}, possibly inside ``` fences.
|
|
226
|
+
decoder = json.JSONDecoder()
|
|
227
|
+
for m in re.finditer(r"\{", content):
|
|
228
|
+
try:
|
|
229
|
+
obj, _ = decoder.raw_decode(content, m.start())
|
|
230
|
+
except json.JSONDecodeError:
|
|
231
|
+
continue
|
|
232
|
+
if isinstance(obj, dict) and obj.get("name") in TOOL_NAMES and isinstance(obj.get("arguments", {}), dict):
|
|
233
|
+
calls.append({"function": {"name": obj["name"], "arguments": obj.get("arguments", {})}})
|
|
234
|
+
return calls
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
class ToolError(Exception):
|
|
238
|
+
pass
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# ----------------------------------------------------------------------------- toolbox
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
class Toolbox:
|
|
245
|
+
def __init__(self, agent: Agent):
|
|
246
|
+
self.agent = agent
|
|
247
|
+
self.read_mtimes: dict[str, float] = {}
|
|
248
|
+
self.todos: list[dict] = []
|
|
249
|
+
|
|
250
|
+
@property
|
|
251
|
+
def console(self):
|
|
252
|
+
return self.agent.console
|
|
253
|
+
|
|
254
|
+
def resolve(self, path: str | None) -> Path:
|
|
255
|
+
p = Path(os.path.expanduser(path or "."))
|
|
256
|
+
return (p if p.is_absolute() else self.agent.cwd / p).resolve()
|
|
257
|
+
|
|
258
|
+
def rel(self, p: Path) -> str:
|
|
259
|
+
try:
|
|
260
|
+
return str(p.relative_to(self.agent.cwd)) or "."
|
|
261
|
+
except ValueError:
|
|
262
|
+
return str(p)
|
|
263
|
+
|
|
264
|
+
def run(self, name: str, args: dict) -> str:
|
|
265
|
+
fn = getattr(self, f"t_{name}", None)
|
|
266
|
+
if fn is None:
|
|
267
|
+
return f"Error: unknown tool '{name}'. Available: {', '.join(sorted(TOOL_NAMES))}"
|
|
268
|
+
try:
|
|
269
|
+
return fn(**args)
|
|
270
|
+
except ToolError as e:
|
|
271
|
+
return f"Error: {e}"
|
|
272
|
+
except TypeError as e:
|
|
273
|
+
return f"Error: bad arguments for {name}: {e}"
|
|
274
|
+
except Exception as e: # surface every failure to the model instead of crashing the session
|
|
275
|
+
return f"Error: {type(e).__name__}: {e}"
|
|
276
|
+
|
|
277
|
+
# -- read-only
|
|
278
|
+
def t_read_file(self, path: str, offset: int = 1, limit: int = 2000) -> str:
|
|
279
|
+
p = self.resolve(path)
|
|
280
|
+
if not p.exists():
|
|
281
|
+
raise ToolError(f"{path} does not exist")
|
|
282
|
+
if p.is_dir():
|
|
283
|
+
raise ToolError(f"{path} is a directory; use list_dir")
|
|
284
|
+
if is_binary(p):
|
|
285
|
+
raise ToolError(f"{path} is a binary file ({p.stat().st_size} bytes)")
|
|
286
|
+
lines = p.read_text(errors="replace").splitlines()
|
|
287
|
+
offset, limit = max(1, int(offset or 1)), max(1, int(limit or 2000))
|
|
288
|
+
chunk = lines[offset - 1 : offset - 1 + limit]
|
|
289
|
+
self.read_mtimes[str(p)] = p.stat().st_mtime
|
|
290
|
+
if not lines:
|
|
291
|
+
return "(empty file)"
|
|
292
|
+
out = "\n".join(f"{i:6}\t{line[:2000]}" for i, line in enumerate(chunk, start=offset))
|
|
293
|
+
end = offset - 1 + len(chunk)
|
|
294
|
+
if end < len(lines) or offset > 1:
|
|
295
|
+
out += f"\n\n[Showing lines {offset}-{end} of {len(lines)}. Use offset/limit to read more.]"
|
|
296
|
+
return truncate(out, 120_000)
|
|
297
|
+
|
|
298
|
+
def t_list_dir(self, path: str = ".", depth: int = 2) -> str:
|
|
299
|
+
p = self.resolve(path)
|
|
300
|
+
if not p.is_dir():
|
|
301
|
+
raise ToolError(f"{path} is not a directory")
|
|
302
|
+
return tree(p, depth=max(1, min(int(depth or 2), 6)))
|
|
303
|
+
|
|
304
|
+
def t_glob(self, pattern: str, path: str = ".") -> str:
|
|
305
|
+
base = self.resolve(path)
|
|
306
|
+
pattern = pattern.removeprefix("./")
|
|
307
|
+
patterns = [pattern] + ([pattern[3:]] if pattern.startswith("**/") else [])
|
|
308
|
+
hits = [
|
|
309
|
+
p for p in walk_files(base) if any(fnmatch.fnmatch(p.relative_to(base).as_posix(), x) for x in patterns)
|
|
310
|
+
]
|
|
311
|
+
hits.sort(key=lambda p: p.stat().st_mtime, reverse=True)
|
|
312
|
+
if not hits:
|
|
313
|
+
return "No files found."
|
|
314
|
+
more = f"\n... and {len(hits) - 200} more" if len(hits) > 200 else ""
|
|
315
|
+
return "\n".join(self.rel(p) for p in hits[:200]) + more
|
|
316
|
+
|
|
317
|
+
def t_grep(
|
|
318
|
+
self, pattern: str, path: str = ".", glob: str | None = None, ignore_case: bool = False, context: int = 0
|
|
319
|
+
) -> str:
|
|
320
|
+
p = self.resolve(path)
|
|
321
|
+
if shutil.which("rg"):
|
|
322
|
+
cmd = ["rg", "--line-number", "--no-heading", "--with-filename", "--hidden", "--color", "never"]
|
|
323
|
+
cmd += ["--max-columns", "400"]
|
|
324
|
+
if ignore_case:
|
|
325
|
+
cmd.append("-i")
|
|
326
|
+
if context:
|
|
327
|
+
cmd += ["-C", str(int(context))]
|
|
328
|
+
if glob:
|
|
329
|
+
cmd += ["-g", glob]
|
|
330
|
+
for d in IGNORE_DIRS:
|
|
331
|
+
cmd += ["-g", f"!{d}/"]
|
|
332
|
+
cmd += ["-e", pattern, str(p)]
|
|
333
|
+
r = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
|
|
334
|
+
if r.returncode == 2:
|
|
335
|
+
raise ToolError(r.stderr.strip())
|
|
336
|
+
out = r.stdout
|
|
337
|
+
else:
|
|
338
|
+
try:
|
|
339
|
+
rx = re.compile(pattern, re.I if ignore_case else 0)
|
|
340
|
+
except re.error as e:
|
|
341
|
+
raise ToolError(f"invalid regex: {e}") from e
|
|
342
|
+
results = []
|
|
343
|
+
for f in [p] if p.is_file() else walk_files(p):
|
|
344
|
+
if (glob and not fnmatch.fnmatch(f.name, glob)) or is_binary(f):
|
|
345
|
+
continue
|
|
346
|
+
try:
|
|
347
|
+
for n, line in enumerate(f.read_text(errors="replace").splitlines(), 1):
|
|
348
|
+
if rx.search(line):
|
|
349
|
+
results.append(f"{f}:{n}:{line[:400]}")
|
|
350
|
+
except OSError:
|
|
351
|
+
continue
|
|
352
|
+
out = "\n".join(results)
|
|
353
|
+
out = out.replace(str(self.agent.cwd) + os.sep, "")
|
|
354
|
+
lines = out.splitlines()
|
|
355
|
+
if not lines:
|
|
356
|
+
return "No matches."
|
|
357
|
+
if len(lines) > 400:
|
|
358
|
+
return "\n".join(lines[:400]) + f"\n... [{len(lines) - 400} more lines; narrow the search]"
|
|
359
|
+
return out
|
|
360
|
+
|
|
361
|
+
# -- file changes
|
|
362
|
+
def _check_fresh(self, p: Path) -> None:
|
|
363
|
+
key = str(p)
|
|
364
|
+
if key not in self.read_mtimes:
|
|
365
|
+
raise ToolError(f"You must read_file {self.rel(p)} before modifying it.")
|
|
366
|
+
if p.stat().st_mtime > self.read_mtimes[key] + 1e-6:
|
|
367
|
+
raise ToolError(f"{self.rel(p)} changed on disk since you read it. Read it again first.")
|
|
368
|
+
|
|
369
|
+
def _diff(self, p: Path, old: str, new: str) -> Syntax:
|
|
370
|
+
diff = "".join(
|
|
371
|
+
difflib.unified_diff(
|
|
372
|
+
old.splitlines(True), new.splitlines(True), f"a/{self.rel(p)}", f"b/{self.rel(p)}", n=3
|
|
373
|
+
)
|
|
374
|
+
)
|
|
375
|
+
if len(diff) > 12_000:
|
|
376
|
+
diff = diff[:12_000] + "\n... (diff truncated for display)\n"
|
|
377
|
+
return Syntax(diff or "(no changes)", "diff", theme="monokai", word_wrap=True)
|
|
378
|
+
|
|
379
|
+
def _write(self, p: Path, content: str) -> None:
|
|
380
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
381
|
+
p.write_text(content)
|
|
382
|
+
self.read_mtimes[str(p)] = p.stat().st_mtime
|
|
383
|
+
|
|
384
|
+
def t_write_file(self, path: str, content: str) -> str:
|
|
385
|
+
p = self.resolve(path)
|
|
386
|
+
exists = p.exists()
|
|
387
|
+
if exists:
|
|
388
|
+
if p.is_dir():
|
|
389
|
+
raise ToolError(f"{path} is a directory")
|
|
390
|
+
self._check_fresh(p)
|
|
391
|
+
body = self._diff(p, p.read_text(errors="replace"), content)
|
|
392
|
+
else:
|
|
393
|
+
lines = content.splitlines()
|
|
394
|
+
preview = "\n".join(lines[:60]) + (f"\n... ({len(lines) - 60} more lines)" if len(lines) > 60 else "")
|
|
395
|
+
body = Syntax(preview, Syntax.guess_lexer(str(p), content), theme="monokai", line_numbers=True)
|
|
396
|
+
verb = "Overwrite" if exists else "Create"
|
|
397
|
+
ok, feedback = self.agent.perms.request("edit", "edit", f"{verb} {self.rel(p)}", body)
|
|
398
|
+
if not ok:
|
|
399
|
+
return feedback
|
|
400
|
+
self._write(p, content)
|
|
401
|
+
n = len(content.splitlines())
|
|
402
|
+
self.console.print(f" [green]✓[/] {'Updated' if exists else 'Created'} {self.rel(p)} ({n} lines)")
|
|
403
|
+
return f"{'Overwrote' if exists else 'Created'} {self.rel(p)} ({n} lines)."
|
|
404
|
+
|
|
405
|
+
def t_edit_file(self, path: str, old_string: str, new_string: str, replace_all: bool = False) -> str:
|
|
406
|
+
p = self.resolve(path)
|
|
407
|
+
if not p.exists():
|
|
408
|
+
raise ToolError(f"{path} does not exist (use write_file to create it)")
|
|
409
|
+
self._check_fresh(p)
|
|
410
|
+
if old_string == new_string:
|
|
411
|
+
raise ToolError("old_string and new_string are identical")
|
|
412
|
+
if not old_string:
|
|
413
|
+
raise ToolError("old_string is empty; use write_file to create or overwrite files")
|
|
414
|
+
text = p.read_text(errors="replace")
|
|
415
|
+
count = text.count(old_string)
|
|
416
|
+
if count == 0:
|
|
417
|
+
new_text = fuzzy_replace(text, old_string, new_string)
|
|
418
|
+
if new_text is None:
|
|
419
|
+
raise ToolError(
|
|
420
|
+
f"old_string not found in {self.rel(p)}. It must match exactly, including indentation. "
|
|
421
|
+
"Re-read the file and copy the text precisely (without line-number prefixes)."
|
|
422
|
+
)
|
|
423
|
+
count = 1
|
|
424
|
+
elif count > 1 and not replace_all:
|
|
425
|
+
raise ToolError(
|
|
426
|
+
f"old_string occurs {count} times in {self.rel(p)}. Add surrounding lines to make it unique, "
|
|
427
|
+
"or set replace_all=true."
|
|
428
|
+
)
|
|
429
|
+
else:
|
|
430
|
+
new_text = text.replace(old_string, new_string) if replace_all else text.replace(old_string, new_string, 1)
|
|
431
|
+
ok, feedback = self.agent.perms.request("edit", "edit", f"Edit {self.rel(p)}", self._diff(p, text, new_text))
|
|
432
|
+
if not ok:
|
|
433
|
+
return feedback
|
|
434
|
+
self._write(p, new_text)
|
|
435
|
+
self.console.print(f" [green]✓[/] Edited {self.rel(p)}")
|
|
436
|
+
idx = new_text.find(new_string) if new_string else -1
|
|
437
|
+
if idx < 0:
|
|
438
|
+
return f"Edited {self.rel(p)} ({count} replacement(s))."
|
|
439
|
+
# Show the model the edited region so it can verify the result.
|
|
440
|
+
start = new_text.count("\n", 0, idx) + 1
|
|
441
|
+
lines = new_text.splitlines()
|
|
442
|
+
lo, hi = max(1, start - 3), min(len(lines), start + new_string.count("\n") + 3)
|
|
443
|
+
snippet = "\n".join(f"{i:6}\t{lines[i - 1]}" for i in range(lo, hi + 1))
|
|
444
|
+
return f"Edited {self.rel(p)} ({count} replacement(s)). Result:\n{snippet}"
|
|
445
|
+
|
|
446
|
+
# -- shell
|
|
447
|
+
def t_bash(self, command: str, timeout: int = 180) -> str:
|
|
448
|
+
if not is_read_only(command):
|
|
449
|
+
body = Syntax(command, "bash", theme="monokai", word_wrap=True)
|
|
450
|
+
ok, feedback = self.agent.perms.request(
|
|
451
|
+
bash_key(command), "bash", f"Run command (in {self.agent.cwd})", body
|
|
452
|
+
)
|
|
453
|
+
if not ok:
|
|
454
|
+
return feedback
|
|
455
|
+
self.console.print(Text(f" $ {command}", style="bold cyan"))
|
|
456
|
+
fd, cwd_file = tempfile.mkstemp(prefix="lcode_cwd_")
|
|
457
|
+
os.close(fd)
|
|
458
|
+
script = f"{command}\n__lcode_ec=$?\npwd -P > {shlex.quote(cwd_file)}\nexit $__lcode_ec\n"
|
|
459
|
+
proc = subprocess.Popen(
|
|
460
|
+
["bash", "-c", script],
|
|
461
|
+
cwd=self.agent.cwd,
|
|
462
|
+
stdout=subprocess.PIPE,
|
|
463
|
+
stderr=subprocess.STDOUT,
|
|
464
|
+
stdin=subprocess.DEVNULL,
|
|
465
|
+
text=True,
|
|
466
|
+
errors="replace",
|
|
467
|
+
start_new_session=True,
|
|
468
|
+
bufsize=1,
|
|
469
|
+
)
|
|
470
|
+
lines: queue.Queue[str | None] = queue.Queue()
|
|
471
|
+
|
|
472
|
+
def pump() -> None:
|
|
473
|
+
assert proc.stdout is not None
|
|
474
|
+
for line in proc.stdout:
|
|
475
|
+
lines.put(line)
|
|
476
|
+
lines.put(None)
|
|
477
|
+
|
|
478
|
+
threading.Thread(target=pump, daemon=True).start()
|
|
479
|
+
out: list[str] = []
|
|
480
|
+
shown, status = 0, ""
|
|
481
|
+
deadline = time.time() + int(timeout or 180)
|
|
482
|
+
try:
|
|
483
|
+
while True:
|
|
484
|
+
try:
|
|
485
|
+
line = lines.get(timeout=0.2)
|
|
486
|
+
except queue.Empty:
|
|
487
|
+
if time.time() > deadline:
|
|
488
|
+
os.killpg(proc.pid, signal.SIGKILL)
|
|
489
|
+
status = f"\n[Command timed out after {timeout}s and was killed]"
|
|
490
|
+
break
|
|
491
|
+
continue
|
|
492
|
+
if line is None:
|
|
493
|
+
break
|
|
494
|
+
out.append(line)
|
|
495
|
+
if shown <= 40:
|
|
496
|
+
msg = " ... (more output hidden)" if shown == 40 else " " + line.rstrip("\n")[:300]
|
|
497
|
+
self.console.print(Text(msg, style="dim"))
|
|
498
|
+
shown += 1
|
|
499
|
+
except KeyboardInterrupt:
|
|
500
|
+
os.killpg(proc.pid, signal.SIGKILL)
|
|
501
|
+
raise
|
|
502
|
+
code = proc.wait()
|
|
503
|
+
try:
|
|
504
|
+
recorded = Path(cwd_file).read_text().strip()
|
|
505
|
+
if recorded and Path(recorded).is_dir() and Path(recorded).resolve() != self.agent.cwd:
|
|
506
|
+
self.agent.cwd = Path(recorded).resolve()
|
|
507
|
+
status += f"\n[Working directory is now {self.agent.cwd}]"
|
|
508
|
+
except OSError:
|
|
509
|
+
pass
|
|
510
|
+
finally:
|
|
511
|
+
Path(cwd_file).unlink(missing_ok=True)
|
|
512
|
+
self.console.print(Text(f" exit code {code}", style="green" if code == 0 else "red"))
|
|
513
|
+
return truncate("".join(out)) + status + f"\n[exit code: {code}]"
|
|
514
|
+
|
|
515
|
+
# -- planning
|
|
516
|
+
def t_todo_write(self, todos: list | str) -> str:
|
|
517
|
+
if isinstance(todos, str):
|
|
518
|
+
todos = json.loads(todos)
|
|
519
|
+
self.todos = list(todos)
|
|
520
|
+
self.show_todos()
|
|
521
|
+
return "Todo list updated."
|
|
522
|
+
|
|
523
|
+
def show_todos(self) -> None:
|
|
524
|
+
icons = {"completed": "[green]✔[/]", "in_progress": "[yellow]◐[/]", "pending": "[dim]○[/]"}
|
|
525
|
+
rows = []
|
|
526
|
+
for todo in self.todos:
|
|
527
|
+
status, text = todo.get("status", "pending"), todo.get("content", "")
|
|
528
|
+
if status == "completed":
|
|
529
|
+
text = f"[strike dim]{text}[/]"
|
|
530
|
+
elif status == "in_progress":
|
|
531
|
+
text = f"[bold]{text}[/]"
|
|
532
|
+
rows.append(f"{icons.get(status, '○')} {text}")
|
|
533
|
+
self.console.print(Panel("\n".join(rows) or "(empty)", title="Todos", title_align="left", border_style="blue"))
|