-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtools.py
More file actions
627 lines (569 loc) · 25.5 KB
/
Copy pathtools.py
File metadata and controls
627 lines (569 loc) · 25.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
"""
Tools that agents can use.
Each tool has:
1. An implementation (plain Python function)
2. An OpenAI-format JSON schema describing it for the LLM
"""
import os
import json
import subprocess
import httpx
from config import WORKSPACE_DIR
def _resolve_path(path: str) -> str:
"""Normalize any workspace-relative path to an absolute path.
Agents often pass 'workspace/file.py' as a relative path. If the user
runs nnn from a different directory, that relative path breaks.
This converts any path starting with 'workspace/' to use the absolute
WORKSPACE_DIR so tools work regardless of CWD.
Also handles:
'workspace' (no slash) → WORKSPACE_DIR
'.' → WORKSPACE_DIR (agents treat workspace as cwd)
"""
path = os.path.expanduser(path)
if path in ("workspace", ".", "./"):
return WORKSPACE_DIR
if path.startswith("workspace/"):
return os.path.join(WORKSPACE_DIR, path[len("workspace/"):])
if path.startswith("./"):
return os.path.join(WORKSPACE_DIR, path[2:])
return path
# ═══════════════════════════════════════════════════════════
# FILE SYSTEM TOOLS
# ═══════════════════════════════════════════════════════════
def read_file(path: str) -> str:
"""Read the contents of a file with line numbers."""
path = _resolve_path(path)
try:
with open(path, "r", encoding="utf-8", errors="replace") as f:
lines = f.readlines()
# Show line numbers so agents can use edit_lines
numbered = []
for i, line in enumerate(lines, 1):
numbered.append(f"{i:4d} | {line.rstrip()}")
content = "\n".join(numbered)
# cap at ~8k chars so we don't blow context
if len(content) > 8000:
return content[:8000] + f"\n\n... (truncated, {len(lines)} lines total)"
return content
except Exception as e:
return f"Error reading {path}: {e}"
def write_file(path: str, content: str) -> str:
"""Write content to a file. Creates parent directories if needed."""
path = _resolve_path(path)
# Strip markdown fences that small models accidentally include in content
if content.startswith("```"):
lines = content.split("\n")
# Remove first line (```python or ```) and last line (```)
if lines and lines[0].startswith("```"):
lines = lines[1:]
if lines and lines[-1].strip() == "```":
lines = lines[:-1]
content = "\n".join(lines)
try:
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
f.write(content)
return f"Wrote {len(content)} chars to {path}"
except Exception as e:
return f"Error writing {path}: {e}"
def delete_file(path: str) -> str:
"""Delete a file from the workspace."""
path = _resolve_path(path)
try:
os.remove(path)
return f"Deleted {path}"
except FileNotFoundError:
return f"Error: {path} does not exist"
except Exception as e:
return f"Error deleting {path}: {e}"
def edit_file(path: str, old_code: str, new_code: str) -> str:
"""Replace a specific section of code in an existing file.
Use this instead of write_file when you only need to change part of a file.
The old_code must match exactly (including whitespace).
"""
path = _resolve_path(path)
try:
with open(path, "r", encoding="utf-8", errors="replace") as f:
content = f.read()
except FileNotFoundError:
return f"Error: {path} does not exist. Use write_file to create new files."
except Exception as e:
return f"Error reading {path}: {e}"
# Try exact match first
if old_code in content:
new_content = content.replace(old_code, new_code, 1)
with open(path, "w", encoding="utf-8") as f:
f.write(new_content)
return f"Edited {path} — replaced {len(old_code)} chars with {len(new_code)} chars"
# Try with stripped whitespace (fuzzy match)
old_stripped = old_code.strip()
if old_stripped and old_stripped in content:
new_content = content.replace(old_stripped, new_code.strip(), 1)
with open(path, "w", encoding="utf-8") as f:
f.write(new_content)
return f"Edited {path} (fuzzy match) — replaced section"
return f"Error: could not find the old_code section in {path}. Use edit_lines with line numbers instead."
def edit_lines(path: str, start_line: int, end_line: int, new_code: str) -> str:
"""Replace lines start_line through end_line (inclusive) with new_code.
Line numbers are 1-based (as shown by read_file). Use this when edit_file fails
or when you know the exact line range to replace.
Pass empty new_code to delete lines.
"""
# Cast in case the LLM sends "5" instead of 5
try:
start_line = int(start_line)
end_line = int(end_line)
except (ValueError, TypeError):
return f"Error: start_line and end_line must be integers"
path = _resolve_path(path)
try:
with open(path, "r", encoding="utf-8", errors="replace") as f:
lines = f.readlines()
except FileNotFoundError:
return f"Error: {path} does not exist."
except Exception as e:
return f"Error reading {path}: {e}"
total = len(lines)
if start_line < 1 or end_line < start_line or start_line > total:
return f"Error: invalid line range {start_line}-{end_line} (file has {total} lines)"
# Clamp end_line
end_line = min(end_line, total)
# Build new content
before = lines[:start_line - 1]
after = lines[end_line:]
# Split new_code into lines, handling trailing newline artifact
if not new_code or new_code.strip() == "":
new_with_newlines = [] # delete lines
else:
new_lines = new_code.split("\n")
# Remove trailing empty string from split (artifact of trailing \n)
if new_lines and new_lines[-1] == "":
new_lines.pop()
new_with_newlines = [l + "\n" for l in new_lines]
result = before + new_with_newlines + after
with open(path, "w", encoding="utf-8") as f:
f.writelines(result)
replaced = len(new_with_newlines)
return f"Edited {path} lines {start_line}-{end_line} → {replaced} new lines"
def insert_code(path: str, position: str, code: str) -> str:
"""Insert code at a specific position in an existing file.
Args:
path: File to modify
position: 'top', 'bottom', or a line number (inserts AFTER that line, use '0' for very top)
code: Code to insert
"""
path = _resolve_path(path)
try:
with open(path, "r", encoding="utf-8", errors="replace") as f:
lines = f.readlines()
except FileNotFoundError:
return f"Error: {path} does not exist. Use write_file to create new files."
except Exception as e:
return f"Error reading {path}: {e}"
pos = position.strip().lower()
if pos == "top":
new_lines = code.rstrip("\n").split("\n")
new_with_nl = [l + "\n" for l in new_lines]
result = new_with_nl + lines
elif pos == "bottom":
new_lines = code.rstrip("\n").split("\n")
new_with_nl = [l + "\n" for l in new_lines]
result = lines + ["\n"] + new_with_nl
else:
# Line number — insert AFTER that line
try:
line_num = int(pos)
except ValueError:
return f"Error: position must be 'top', 'bottom', or a line number, got '{position}'"
if line_num < 0 or line_num > len(lines):
return f"Error: line {line_num} out of range (file has {len(lines)} lines)"
new_lines = code.rstrip("\n").split("\n")
new_with_nl = [l + "\n" for l in new_lines]
result = lines[:line_num] + new_with_nl + lines[line_num:]
with open(path, "w", encoding="utf-8") as f:
f.writelines(result)
code_lines = len(code.rstrip("\n").split("\n"))
return f"Inserted {code_lines} lines at {position} of {path}"
def list_files(directory: str = WORKSPACE_DIR) -> str:
"""List files and directories recursively (max depth 3)."""
directory = _resolve_path(directory)
results = []
try:
for root, dirs, files in os.walk(directory):
depth = root.replace(directory, "").count(os.sep)
if depth >= 3:
dirs.clear()
continue
indent = " " * depth
results.append(f"{indent}{os.path.basename(root)}/")
for f in sorted(files)[:50]: # cap per directory
results.append(f"{indent} {f}")
return "\n".join(results[:200]) or "Empty directory"
except Exception as e:
return f"Error listing {directory}: {e}"
def search_code(pattern: str, directory: str = "workspace") -> str:
"""Search for a text pattern in files using grep."""
directory = _resolve_path(directory)
try:
result = subprocess.run(
["grep", "-rnI",
"--exclude-dir=venv", "--exclude-dir=.git",
"--exclude-dir=__pycache__", "--exclude-dir=node_modules",
"--include=*.py", "--include=*.js", "--include=*.ts",
"--include=*.java", "--include=*.go", "--include=*.rs", "--include=*.c",
"--include=*.cpp", "--include=*.h", "--include=*.md", "--include=*.txt",
"--include=*.json", "--include=*.yaml", "--include=*.yml",
"--include=*.html", "--include=*.css",
pattern, directory],
capture_output=True, text=True, timeout=10
)
output = result.stdout.strip()
if not output:
return f"No matches found for '{pattern}'"
lines = output.split("\n")
if len(lines) > 30:
return "\n".join(lines[:30]) + f"\n\n... ({len(lines)} matches total)"
return output
except Exception as e:
return f"Search error: {e}"
# ═══════════════════════════════════════════════════════════
# EXECUTION TOOLS
# ═══════════════════════════════════════════════════════════
def run_command(command: str, cwd: str = WORKSPACE_DIR) -> str:
"""Run a shell command and return its output."""
cwd = _resolve_path(cwd)
# Guard: detect server launches that will hang forever
# e.g. "node app.js", "python3 server.py" on files with app.listen/Flask
SERVER_PATTERNS = ["app.listen", "createServer", ".listen(", "uvicorn", "flask", "fastapi", "http.server"]
cmd_parts = command.strip().split()
if len(cmd_parts) >= 2 and cmd_parts[0] in ("node", "python3", "python"):
target_file = cmd_parts[-1]
# Resolve the file path
check_path = os.path.join(cwd, target_file) if not os.path.isabs(target_file) else target_file
if os.path.isfile(check_path):
try:
with open(check_path, "r", errors="replace") as f:
content = f.read(3000)
if any(pat in content for pat in SERVER_PATTERNS):
return (
f"REFUSED: {target_file} is a server (contains listener). "
"Running it would hang forever. Do NOT run servers with run_command. "
"The code is already written — just verify it by reading the file."
)
except Exception:
pass
try:
result = subprocess.run(
command, shell=True, capture_output=True, text=True,
timeout=30, cwd=cwd
)
output = ""
if result.stdout:
output += result.stdout
if result.stderr:
output += "\nSTDERR:\n" + result.stderr
if result.returncode != 0:
output += f"\n(exit code: {result.returncode})"
output = output.strip()
if len(output) > 4000:
output = output[:4000] + "\n... (truncated)"
return output or "(no output)"
except subprocess.TimeoutExpired:
return "Command timed out after 30s — this is likely a server or long-running process. Do NOT retry."
except Exception as e:
return f"Command error: {e}"
# ═══════════════════════════════════════════════════════════
# WEB TOOLS
# ═══════════════════════════════════════════════════════════
def web_search(query: str) -> str:
"""Search the web using DuckDuckGo (no API key needed)."""
import re
# Truncate overly long queries — DDG chokes on them
query = " ".join(query.split()[:8])
results = []
# Method 1: DuckDuckGo Instant Answer JSON API
try:
resp = httpx.get(
"https://api.duckduckgo.com/",
params={"q": query, "format": "json", "no_redirect": "1", "no_html": "1"},
headers={"User-Agent": "Mozilla/5.0"},
timeout=15,
follow_redirects=True,
)
data = resp.json()
# Pull from AbstractText, RelatedTopics
if data.get("AbstractText"):
results.append(f"Summary: {data['AbstractText']}\nSource: {data.get('AbstractURL','')}\n")
for topic in data.get("RelatedTopics", [])[:6]:
if isinstance(topic, dict) and topic.get("Text"):
url = topic.get("FirstURL", "")
results.append(f"- {topic['Text']}\n {url}\n")
except Exception:
pass
# Method 2: Fallback — scrape HTML version
if not results:
try:
resp = httpx.get(
"https://html.duckduckgo.com/html/",
params={"q": query},
headers={"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"},
timeout=15,
follow_redirects=True,
)
text = resp.text
# Try multiple class name patterns (DDG changes them)
titles = re.findall(r'class="[^"]*result[^"]*a[^"]*"[^>]*>(.*?)</a', text, re.DOTALL)
snippets = re.findall(r'class="[^"]*snippet[^"]*"[^>]*>(.*?)</(?:a|span|div)', text, re.DOTALL)
for i, (t, s) in enumerate(zip(titles[:6], snippets[:6])):
ct = re.sub(r'<[^>]+>', '', t).strip()
cs = re.sub(r'<[^>]+>', '', s).strip()
if ct and cs:
results.append(f"{i+1}. {ct}\n {cs}\n")
except Exception as e:
return f"Web search error: {e}"
return "\n".join(results) if results else "No results found. Try read_url with a specific URL instead."
def read_url(url: str) -> str:
"""Fetch the text content of a URL."""
try:
resp = httpx.get(
url,
headers={"User-Agent": "Mozilla/5.0"},
timeout=15,
follow_redirects=True,
)
# Simple HTML to text: strip tags
import re
text = re.sub(r'<script[^>]*>.*?</script>', '', resp.text, flags=re.DOTALL)
text = re.sub(r'<style[^>]*>.*?</style>', '', text, flags=re.DOTALL)
text = re.sub(r'<[^>]+>', ' ', text)
text = re.sub(r'\s+', ' ', text).strip()
if len(text) > 1500:
text = text[:1500] + "\n... (truncated)"
return text
except Exception as e:
return f"Error fetching URL: {e}"
# ═════════════════════════════════════════════════════
# WORKSPACE TOOLS
# ════════════════════════════════════════════════════
def write_plan(filename: str, content: str) -> str:
"""Write a plan or notes to the shared workspace."""
path = os.path.join(WORKSPACE_DIR, filename)
return write_file(path, content)
def read_workspace(filename: str = "") -> str:
"""Read a file from the shared workspace, or list all workspace files."""
if not filename:
return list_files(WORKSPACE_DIR)
path = os.path.join(WORKSPACE_DIR, filename)
return read_file(path)
# ═══════════════════════════════════════════════════════════
# TOOL SCHEMAS (OpenAI function-calling format)
# ═══════════════════════════════════════════════════════════
TOOL_SCHEMAS = {
"read_file": {
"type": "function",
"function": {
"name": "read_file",
"description": "Read the contents of a file at the given path.",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "Path to the file to read"}
},
"required": ["path"]
}
}
},
"write_file": {
"type": "function",
"function": {
"name": "write_file",
"description": "Write content to a file. Creates parent directories if needed. WARNING: This OVERWRITES the entire file. To change only part of a file, use edit_file instead.",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "Path to the file to write"},
"content": {"type": "string", "description": "Content to write to the file"}
},
"required": ["path", "content"]
}
}
}, "delete_file": {
"type": "function",
"function": {
"name": "delete_file",
"description": "Delete a file. Use this to remove unwanted files from the workspace.",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "Path to the file to delete"}
},
"required": ["path"]
}
}
}, "edit_file": {
"type": "function",
"function": {
"name": "edit_file",
"description": "Replace a specific section of code in an existing file. The old_code must match text in the file exactly. If this fails, use edit_lines instead.",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "Path to the file to edit"},
"old_code": {"type": "string", "description": "The exact existing code to find and replace"},
"new_code": {"type": "string", "description": "The new code to replace it with"}
},
"required": ["path", "old_code", "new_code"]
}
}
},
"edit_lines": {
"type": "function",
"function": {
"name": "edit_lines",
"description": "REPLACE specific lines in a file. Use read_file first to see line numbers, then specify which lines to replace. To DELETE lines, set new_code to empty string. This is the MOST RELIABLE way to edit existing files.",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "Path to the file (e.g. workspace/app.py)"},
"start_line": {"type": "integer", "description": "First line to replace (1-based, from read_file output)"},
"end_line": {"type": "integer", "description": "Last line to replace (inclusive, 1-based)"},
"new_code": {"type": "string", "description": "New code to replace those lines with. Use empty string to delete lines."}
},
"required": ["path", "start_line", "end_line", "new_code"]
}
}
},
"insert_code": {
"type": "function",
"function": {
"name": "insert_code",
"description": "Insert NEW code into an existing file WITHOUT replacing anything. Use read_file first to see line numbers, then set position to the line number to insert AFTER. Example: position='3' inserts your code after line 3. position='0' inserts before line 1. position='top' or 'bottom' for start/end of file.",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "Path to the file (e.g. workspace/app.py)"},
"position": {"type": "string", "description": "Line number to insert AFTER (e.g. '3'), or 'top'/'bottom'"},
"code": {"type": "string", "description": "The new code to insert (will NOT replace existing code)"}
},
"required": ["path", "position", "code"]
}
}
},
"list_files": {
"type": "function",
"function": {
"name": "list_files",
"description": "List files and directories recursively (up to depth 3).",
"parameters": {
"type": "object",
"properties": {
"directory": {"type": "string", "description": "Directory to list (default: workspace/)"}
},
"required": []
}
}
},
"search_code": {
"type": "function",
"function": {
"name": "search_code",
"description": "Search for a text pattern in source code files using grep.",
"parameters": {
"type": "object",
"properties": {
"pattern": {"type": "string", "description": "Text pattern to search for"},
"directory": {"type": "string", "description": "Directory to search in (default: workspace/)"}
},
"required": ["pattern"]
}
}
},
"run_command": {
"type": "function",
"function": {
"name": "run_command",
"description": "Run a shell command. Already runs from the workspace/ directory by default, so do NOT add 'cd workspace' to your command. Just use filenames directly: 'python3 app.py'.",
"parameters": {
"type": "object",
"properties": {
"command": {"type": "string", "description": "Shell command to execute (runs from workspace/ dir, no need to cd)"},
"cwd": {"type": "string", "description": "Working directory (default: workspace/)"}
},
"required": ["command"]
}
}
},
"web_search": {
"type": "function",
"function": {
"name": "web_search",
"description": "Search the web for information. Returns top results with titles, URLs, and snippets.",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Search query"}
},
"required": ["query"]
}
}
},
"read_url": {
"type": "function",
"function": {
"name": "read_url",
"description": "Fetch and read the text content of a web page.",
"parameters": {
"type": "object",
"properties": {
"url": {"type": "string", "description": "URL to fetch"}
},
"required": ["url"]
}
}
},
"write_plan": {
"type": "function",
"function": {
"name": "write_plan",
"description": "Write a plan, architecture doc, or notes to the shared workspace for other agents to see.",
"parameters": {
"type": "object",
"properties": {
"filename": {"type": "string", "description": "Filename to write in the workspace (e.g. 'architecture.md')"},
"content": {"type": "string", "description": "Content to write"}
},
"required": ["filename", "content"]
}
}
},
"read_workspace": {
"type": "function",
"function": {
"name": "read_workspace",
"description": "Read a file from the shared workspace, or list all workspace files if no filename given.",
"parameters": {
"type": "object",
"properties": {
"filename": {"type": "string", "description": "File to read from workspace (empty = list all files)"}
},
"required": []
}
}
},
}
# Map of tool name → function
TOOL_FUNCTIONS = {
"read_file": read_file,
"write_file": write_file,
"delete_file": delete_file,
"edit_file": edit_file,
"edit_lines": edit_lines,
"insert_code": insert_code,
"list_files": list_files,
"search_code": search_code,
"run_command": run_command,
"web_search": web_search,
"read_url": read_url,
"write_plan": write_plan,
"read_workspace": read_workspace,
}