alpiecode 0.6.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.
- alpiecode-0.6.0.dist-info/METADATA +14 -0
- alpiecode-0.6.0.dist-info/RECORD +17 -0
- alpiecode-0.6.0.dist-info/WHEEL +5 -0
- alpiecode-0.6.0.dist-info/entry_points.txt +3 -0
- alpiecode-0.6.0.dist-info/top_level.txt +1 -0
- codeagent/__init__.py +1 -0
- codeagent/agent.py +989 -0
- codeagent/cli.py +215 -0
- codeagent/compaction.py +163 -0
- codeagent/config.py +195 -0
- codeagent/github.py +241 -0
- codeagent/guardian.py +160 -0
- codeagent/local_model.py +460 -0
- codeagent/media.py +286 -0
- codeagent/memory.py +130 -0
- codeagent/tools.py +718 -0
- codeagent/updater.py +126 -0
codeagent/tools.py
ADDED
|
@@ -0,0 +1,718 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tool definitions and implementations for AlpieCode.
|
|
3
|
+
|
|
4
|
+
11 tools total:
|
|
5
|
+
Files: read_file, write_file, edit_file, list_files, file_search, apply_patch
|
|
6
|
+
Execute: bash
|
|
7
|
+
Web: web_search, fetch_url
|
|
8
|
+
Agent: request_user_input, update_plan
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import subprocess
|
|
15
|
+
import textwrap
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
from .guardian import gate_command
|
|
20
|
+
|
|
21
|
+
# ── Tool schemas (OpenAI function-calling format) ─────────────────────
|
|
22
|
+
|
|
23
|
+
TOOLS = [
|
|
24
|
+
{
|
|
25
|
+
"type": "function",
|
|
26
|
+
"function": {
|
|
27
|
+
"name": "bash",
|
|
28
|
+
"description": (
|
|
29
|
+
"Run a shell command in the repo working directory. Returns stdout/stderr/exit_code. "
|
|
30
|
+
"Prefer file tools (read_file, edit_file) over shell for reading/writing files. "
|
|
31
|
+
"Use this for running tests, builds, git commands, and other CLI operations. "
|
|
32
|
+
"IMPORTANT: Commands run WITHOUT a TTY (no terminal). Interactive programs, "
|
|
33
|
+
"ncurses/curses apps, TUI apps, and terminal games will NOT work — they produce "
|
|
34
|
+
"garbage output or hang. Do NOT attempt to run interactive programs through this tool. "
|
|
35
|
+
"For interactive apps, verify correctness via: clean compilation + code review instead."
|
|
36
|
+
),
|
|
37
|
+
"parameters": {
|
|
38
|
+
"type": "object",
|
|
39
|
+
"properties": {"command": {"type": "string", "description": "The shell command to execute"}},
|
|
40
|
+
"required": ["command"],
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"type": "function",
|
|
46
|
+
"function": {
|
|
47
|
+
"name": "read_file",
|
|
48
|
+
"description": "Read a file's contents with line numbers, optionally restricted to a line range.",
|
|
49
|
+
"parameters": {
|
|
50
|
+
"type": "object",
|
|
51
|
+
"properties": {
|
|
52
|
+
"path": {"type": "string", "description": "Relative path to the file from repo root"},
|
|
53
|
+
"start_line": {"type": "integer", "description": "First line to read (1-indexed)"},
|
|
54
|
+
"end_line": {"type": "integer", "description": "Last line to read (1-indexed, inclusive)"},
|
|
55
|
+
},
|
|
56
|
+
"required": ["path"],
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"type": "function",
|
|
62
|
+
"function": {
|
|
63
|
+
"name": "write_file",
|
|
64
|
+
"description": "Create or overwrite a file with the given content.",
|
|
65
|
+
"parameters": {
|
|
66
|
+
"type": "object",
|
|
67
|
+
"properties": {
|
|
68
|
+
"path": {"type": "string", "description": "Relative path to the file"},
|
|
69
|
+
"content": {"type": "string", "description": "The full content to write"},
|
|
70
|
+
},
|
|
71
|
+
"required": ["path", "content"],
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
"type": "function",
|
|
77
|
+
"function": {
|
|
78
|
+
"name": "edit_file",
|
|
79
|
+
"description": (
|
|
80
|
+
"Replace one exact occurrence of old_str with new_str in a file. "
|
|
81
|
+
"The old_str must match exactly (including whitespace/indentation). "
|
|
82
|
+
"You must read_file first before editing."
|
|
83
|
+
),
|
|
84
|
+
"parameters": {
|
|
85
|
+
"type": "object",
|
|
86
|
+
"properties": {
|
|
87
|
+
"path": {"type": "string", "description": "Relative path to the file"},
|
|
88
|
+
"old_str": {"type": "string", "description": "Exact string to find (must match exactly once)"},
|
|
89
|
+
"new_str": {"type": "string", "description": "Replacement string"},
|
|
90
|
+
},
|
|
91
|
+
"required": ["path", "old_str", "new_str"],
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
"type": "function",
|
|
97
|
+
"function": {
|
|
98
|
+
"name": "list_files",
|
|
99
|
+
"description": "List files in the repository tree (respects .gitignore). Returns file paths.",
|
|
100
|
+
"parameters": {
|
|
101
|
+
"type": "object",
|
|
102
|
+
"properties": {
|
|
103
|
+
"path": {"type": "string", "description": "Subdirectory to list (default: repo root)"},
|
|
104
|
+
"max_depth": {"type": "integer", "description": "Max directory depth (default: 4)"},
|
|
105
|
+
},
|
|
106
|
+
"required": [],
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
"type": "function",
|
|
112
|
+
"function": {
|
|
113
|
+
"name": "file_search",
|
|
114
|
+
"description": (
|
|
115
|
+
"Search for a pattern across files in the repository using regex or literal matching. "
|
|
116
|
+
"Returns matching lines with file paths and line numbers. Like ripgrep/grep."
|
|
117
|
+
),
|
|
118
|
+
"parameters": {
|
|
119
|
+
"type": "object",
|
|
120
|
+
"properties": {
|
|
121
|
+
"pattern": {"type": "string", "description": "Search pattern (regex or literal string)"},
|
|
122
|
+
"path": {"type": "string", "description": "Subdirectory to search in (default: repo root)"},
|
|
123
|
+
"include": {"type": "string", "description": "File glob pattern to include (e.g., '*.py')"},
|
|
124
|
+
"case_insensitive": {"type": "boolean", "description": "Case-insensitive search (default: false)"},
|
|
125
|
+
},
|
|
126
|
+
"required": ["pattern"],
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
"type": "function",
|
|
132
|
+
"function": {
|
|
133
|
+
"name": "apply_patch",
|
|
134
|
+
"description": (
|
|
135
|
+
"Apply a unified diff patch to a file. The patch should be in standard unified diff format "
|
|
136
|
+
"with --- and +++ headers, @@ hunk markers, and +/- line prefixes."
|
|
137
|
+
),
|
|
138
|
+
"parameters": {
|
|
139
|
+
"type": "object",
|
|
140
|
+
"properties": {
|
|
141
|
+
"path": {"type": "string", "description": "Relative path to the file to patch"},
|
|
142
|
+
"patch": {"type": "string", "description": "Unified diff content to apply"},
|
|
143
|
+
},
|
|
144
|
+
"required": ["path", "patch"],
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
"type": "function",
|
|
150
|
+
"function": {
|
|
151
|
+
"name": "web_search",
|
|
152
|
+
"description": (
|
|
153
|
+
"Search the web for information. Returns relevant results with titles, URLs, and snippets. "
|
|
154
|
+
"Use for looking up documentation, error messages, API references, etc."
|
|
155
|
+
),
|
|
156
|
+
"parameters": {
|
|
157
|
+
"type": "object",
|
|
158
|
+
"properties": {
|
|
159
|
+
"query": {"type": "string", "description": "Search query"},
|
|
160
|
+
"num_results": {"type": "integer", "description": "Number of results (default: 5, max: 10)"},
|
|
161
|
+
},
|
|
162
|
+
"required": ["query"],
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
"type": "function",
|
|
168
|
+
"function": {
|
|
169
|
+
"name": "fetch_url",
|
|
170
|
+
"description": "Fetch and extract text content from a URL. Returns the page content as plain text.",
|
|
171
|
+
"parameters": {
|
|
172
|
+
"type": "object",
|
|
173
|
+
"properties": {
|
|
174
|
+
"url": {"type": "string", "description": "URL to fetch"},
|
|
175
|
+
},
|
|
176
|
+
"required": ["url"],
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
"type": "function",
|
|
182
|
+
"function": {
|
|
183
|
+
"name": "request_user_input",
|
|
184
|
+
"description": (
|
|
185
|
+
"Ask the user a clarifying question when the task is ambiguous or you need "
|
|
186
|
+
"a decision before proceeding. Returns the user's response."
|
|
187
|
+
),
|
|
188
|
+
"parameters": {
|
|
189
|
+
"type": "object",
|
|
190
|
+
"properties": {
|
|
191
|
+
"question": {"type": "string", "description": "The question to ask the user"},
|
|
192
|
+
},
|
|
193
|
+
"required": ["question"],
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
"type": "function",
|
|
199
|
+
"function": {
|
|
200
|
+
"name": "update_plan",
|
|
201
|
+
"description": (
|
|
202
|
+
"Write or update a structured task plan. Call this before making edits to document "
|
|
203
|
+
"what you intend to do, which files will be changed, and how you'll verify success."
|
|
204
|
+
),
|
|
205
|
+
"parameters": {
|
|
206
|
+
"type": "object",
|
|
207
|
+
"properties": {
|
|
208
|
+
"plan": {
|
|
209
|
+
"type": "string",
|
|
210
|
+
"description": "The structured plan: deliverables, files to change, and verification checks",
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
"required": ["plan"],
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
"type": "function",
|
|
219
|
+
"function": {
|
|
220
|
+
"name": "view_image",
|
|
221
|
+
"description": (
|
|
222
|
+
"Inspect an image file (.png, .jpg, .jpeg, .webp, .gif, .svg) in the repository. "
|
|
223
|
+
"Encodes and returns image description / metadata for visual analysis."
|
|
224
|
+
),
|
|
225
|
+
"parameters": {
|
|
226
|
+
"type": "object",
|
|
227
|
+
"properties": {
|
|
228
|
+
"path": {"type": "string", "description": "Relative path to the image file"},
|
|
229
|
+
},
|
|
230
|
+
"required": ["path"],
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
"type": "function",
|
|
236
|
+
"function": {
|
|
237
|
+
"name": "github_issues",
|
|
238
|
+
"description": (
|
|
239
|
+
"Fetch issues and pull requests from a GitHub repository. "
|
|
240
|
+
"Can list issues or get full details of a specific issue including comments. "
|
|
241
|
+
"Use this to understand bugs, feature requests, and discussions in open-source projects."
|
|
242
|
+
),
|
|
243
|
+
"parameters": {
|
|
244
|
+
"type": "object",
|
|
245
|
+
"properties": {
|
|
246
|
+
"owner": {"type": "string", "description": "Repository owner (e.g., 'pytorch')"},
|
|
247
|
+
"repo": {"type": "string", "description": "Repository name (e.g., 'pytorch')"},
|
|
248
|
+
"issue_number": {"type": "integer", "description": "Specific issue number to get full details (optional)"},
|
|
249
|
+
"state": {"type": "string", "description": "Filter by state: 'open', 'closed', or 'all' (default: 'open')"},
|
|
250
|
+
"max_results": {"type": "integer", "description": "Max issues to return (default: 10, max: 30)"},
|
|
251
|
+
},
|
|
252
|
+
"required": ["owner", "repo"],
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
"type": "function",
|
|
258
|
+
"function": {
|
|
259
|
+
"name": "github_browse",
|
|
260
|
+
"description": (
|
|
261
|
+
"Browse a GitHub repository's structure, files, and metadata without cloning. "
|
|
262
|
+
"Can list directory contents or read a specific file directly from GitHub. "
|
|
263
|
+
"Use this for quick exploration before deciding whether to clone."
|
|
264
|
+
),
|
|
265
|
+
"parameters": {
|
|
266
|
+
"type": "object",
|
|
267
|
+
"properties": {
|
|
268
|
+
"owner": {"type": "string", "description": "Repository owner (e.g., 'facebook')"},
|
|
269
|
+
"repo": {"type": "string", "description": "Repository name (e.g., 'react')"},
|
|
270
|
+
"path": {"type": "string", "description": "File or directory path to browse (default: root)"},
|
|
271
|
+
"info_only": {"type": "boolean", "description": "If true, return only repo metadata (stars, description, etc.)"},
|
|
272
|
+
},
|
|
273
|
+
"required": ["owner", "repo"],
|
|
274
|
+
},
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
"type": "function",
|
|
279
|
+
"function": {
|
|
280
|
+
"name": "clone_repo",
|
|
281
|
+
"description": (
|
|
282
|
+
"Clone a GitHub repository into the working directory for deep analysis, editing, and testing. "
|
|
283
|
+
"Use github_browse first for quick exploration; only clone when you need to make edits or run tests. "
|
|
284
|
+
"Clones with --depth 1 (shallow) to save time and disk space."
|
|
285
|
+
),
|
|
286
|
+
"parameters": {
|
|
287
|
+
"type": "object",
|
|
288
|
+
"properties": {
|
|
289
|
+
"repo_url": {
|
|
290
|
+
"type": "string",
|
|
291
|
+
"description": "GitHub repo URL or owner/repo shorthand (e.g., 'pytorch/pytorch' or 'https://github.com/pytorch/pytorch')",
|
|
292
|
+
},
|
|
293
|
+
"branch": {"type": "string", "description": "Branch to clone (default: main/master)"},
|
|
294
|
+
},
|
|
295
|
+
"required": ["repo_url"],
|
|
296
|
+
},
|
|
297
|
+
},
|
|
298
|
+
},
|
|
299
|
+
]
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
# ── Tool implementations ──────────────────────────────────────────────
|
|
303
|
+
|
|
304
|
+
def _build_venv_env(workdir: Path) -> dict:
|
|
305
|
+
"""Build an environment dict with .venv/bin (or .venv/Scripts on Windows) prepended to PATH."""
|
|
306
|
+
env = os.environ.copy()
|
|
307
|
+
venv_bin = workdir / ".venv" / ("Scripts" if os.name == "nt" else "bin")
|
|
308
|
+
if not venv_bin.is_dir():
|
|
309
|
+
venv_bin = workdir / ".venv" / ("bin" if os.name == "nt" else "Scripts")
|
|
310
|
+
if venv_bin.is_dir():
|
|
311
|
+
path_sep = ";" if os.name == "nt" else ":"
|
|
312
|
+
env["PATH"] = str(venv_bin) + path_sep + env.get("PATH", "")
|
|
313
|
+
env["VIRTUAL_ENV"] = str(workdir / ".venv")
|
|
314
|
+
env.pop("PYTHONHOME", None)
|
|
315
|
+
return env
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _smart_truncate(text: str, max_chars: int = 6000) -> str:
|
|
319
|
+
"""Truncate long output keeping both head and tail (where errors usually are).
|
|
320
|
+
|
|
321
|
+
Compiler errors appear at the top (file:line: error: ...) while the
|
|
322
|
+
bottom often has summary counts. Keeping both ends gives the model
|
|
323
|
+
far better error visibility than tail-only truncation.
|
|
324
|
+
"""
|
|
325
|
+
if len(text) <= max_chars:
|
|
326
|
+
return text
|
|
327
|
+
head_size = int(max_chars * 0.6) # 60% head — where real errors live
|
|
328
|
+
tail_size = max_chars - head_size # 40% tail — summaries and counts
|
|
329
|
+
omitted = len(text) - head_size - tail_size
|
|
330
|
+
return (
|
|
331
|
+
text[:head_size]
|
|
332
|
+
+ f"\n\n... [{omitted} chars omitted] ...\n\n"
|
|
333
|
+
+ text[-tail_size:]
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _bash(workdir: Path, command: str) -> str:
|
|
338
|
+
"""Run a shell command with guardian safety gate.
|
|
339
|
+
|
|
340
|
+
Cross-platform support for Linux, macOS, and native Windows.
|
|
341
|
+
"""
|
|
342
|
+
# ── Offline-aware command interception ────────────────────────────
|
|
343
|
+
if getattr(_bash, '_offline_mode', False):
|
|
344
|
+
# Block package install commands when offline (no internet)
|
|
345
|
+
install_patterns = ['pip install', 'uv pip install', 'uv add', 'npm install', 'apt install', 'apt-get install']
|
|
346
|
+
cmd_lower = command.lower().strip()
|
|
347
|
+
for pattern in install_patterns:
|
|
348
|
+
if pattern in cmd_lower:
|
|
349
|
+
return json.dumps({
|
|
350
|
+
"stdout": "",
|
|
351
|
+
"stderr": f"⚠️ OFFLINE MODE: '{pattern}' blocked — no internet available. Use only standard library modules.",
|
|
352
|
+
"exit_code": 1,
|
|
353
|
+
})
|
|
354
|
+
# Auto-replace pytest with unittest
|
|
355
|
+
if 'pytest' in command or '-m pytest' in command:
|
|
356
|
+
command = command.replace('python -m pytest', 'python -m unittest')
|
|
357
|
+
command = command.replace('pytest', 'python -m unittest')
|
|
358
|
+
|
|
359
|
+
if not gate_command(command, auto_approve=True):
|
|
360
|
+
return json.dumps({
|
|
361
|
+
"stdout": "",
|
|
362
|
+
"stderr": "Command blocked by safety gate. Run it manually if needed.",
|
|
363
|
+
"exit_code": -1,
|
|
364
|
+
})
|
|
365
|
+
try:
|
|
366
|
+
import shutil
|
|
367
|
+
env = _build_venv_env(workdir)
|
|
368
|
+
|
|
369
|
+
# Cross-platform shell resolution
|
|
370
|
+
if shutil.which("bash"):
|
|
371
|
+
shell_cmd = ["bash", "-c", command]
|
|
372
|
+
elif os.name == "nt":
|
|
373
|
+
if shutil.which("powershell"):
|
|
374
|
+
shell_cmd = ["powershell", "-NoProfile", "-Command", command]
|
|
375
|
+
else:
|
|
376
|
+
shell_cmd = ["cmd.exe", "/c", command]
|
|
377
|
+
else:
|
|
378
|
+
shell_cmd = ["/bin/sh", "-c", command]
|
|
379
|
+
|
|
380
|
+
result = subprocess.run(
|
|
381
|
+
shell_cmd,
|
|
382
|
+
cwd=workdir, capture_output=True, text=True, timeout=300, env=env,
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
stdout = _smart_truncate(result.stdout, 6000)
|
|
386
|
+
stderr = _smart_truncate(result.stderr, 4000)
|
|
387
|
+
|
|
388
|
+
output = json.dumps({
|
|
389
|
+
"stdout": stdout,
|
|
390
|
+
"stderr": stderr,
|
|
391
|
+
"exit_code": result.returncode,
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
# Inject clear error signal for failed commands
|
|
395
|
+
if result.returncode != 0:
|
|
396
|
+
output = (
|
|
397
|
+
f"⚠️ COMMAND FAILED (exit_code={result.returncode}). "
|
|
398
|
+
f"Read the error output carefully and fix the ROOT CAUSE.\n"
|
|
399
|
+
+ output
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
return output
|
|
403
|
+
except subprocess.TimeoutExpired:
|
|
404
|
+
return json.dumps({"stdout": "", "stderr": "Command timed out after 300s", "exit_code": -1})
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _read_file(workdir: Path, path: str, start_line: int = None, end_line: int = None) -> str:
|
|
408
|
+
target = workdir / path
|
|
409
|
+
if not target.exists():
|
|
410
|
+
return f"error: file not found: {path}"
|
|
411
|
+
if not target.is_file():
|
|
412
|
+
return f"error: not a file: {path}"
|
|
413
|
+
try:
|
|
414
|
+
lines = target.read_text(errors="replace").splitlines()
|
|
415
|
+
except Exception as e:
|
|
416
|
+
return f"error reading file: {e}"
|
|
417
|
+
total = len(lines)
|
|
418
|
+
if start_line is not None:
|
|
419
|
+
lines = lines[start_line - 1 : end_line if end_line else None]
|
|
420
|
+
numbered = [f"{i + (start_line or 1):4d} | {line}" for i, line in enumerate(lines)]
|
|
421
|
+
result = "\n".join(numbered)
|
|
422
|
+
if start_line is not None:
|
|
423
|
+
result += f"\n\n[Showing lines {start_line}-{end_line or total} of {total} total]"
|
|
424
|
+
return result
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _write_file(workdir: Path, path: str, content: str) -> str:
|
|
428
|
+
p = workdir / path
|
|
429
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
430
|
+
p.write_text(content)
|
|
431
|
+
return f"wrote {len(content)} bytes to {path}"
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _edit_file(workdir: Path, path: str, old_str: str, new_str: str) -> str:
|
|
435
|
+
p = workdir / path
|
|
436
|
+
if not p.exists():
|
|
437
|
+
return f"error: file not found: {path}"
|
|
438
|
+
text = p.read_text()
|
|
439
|
+
count = text.count(old_str)
|
|
440
|
+
if count == 0:
|
|
441
|
+
return "error: old_str not found in file. Make sure it matches exactly (including whitespace)."
|
|
442
|
+
if count > 1:
|
|
443
|
+
return f"error: old_str matched {count} times, need exactly 1 match. Use a more specific old_str."
|
|
444
|
+
p.write_text(text.replace(old_str, new_str, 1))
|
|
445
|
+
return "edit applied"
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _list_files(workdir: Path, path: str = ".", max_depth: int = 4) -> str:
|
|
449
|
+
target = workdir / path
|
|
450
|
+
if not target.exists():
|
|
451
|
+
return f"error: path not found: {path}"
|
|
452
|
+
|
|
453
|
+
# Try git ls-files first
|
|
454
|
+
try:
|
|
455
|
+
result = subprocess.run(
|
|
456
|
+
["git", "ls-files", "--cached", "--others", "--exclude-standard"],
|
|
457
|
+
cwd=target, capture_output=True, text=True, timeout=10
|
|
458
|
+
)
|
|
459
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
460
|
+
files = result.stdout.strip().splitlines()
|
|
461
|
+
filtered = [f for f in files if f.count(os.sep) < max_depth]
|
|
462
|
+
if filtered:
|
|
463
|
+
return "\n".join(sorted(filtered[:200]))
|
|
464
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
465
|
+
pass
|
|
466
|
+
|
|
467
|
+
# Fallback: os.walk
|
|
468
|
+
entries = []
|
|
469
|
+
for root, dirs, files in os.walk(target):
|
|
470
|
+
dirs[:] = [d for d in dirs if not d.startswith(".")]
|
|
471
|
+
rel_root = os.path.relpath(root, target)
|
|
472
|
+
depth = 0 if rel_root == "." else rel_root.count(os.sep) + 1
|
|
473
|
+
if depth >= max_depth:
|
|
474
|
+
dirs.clear()
|
|
475
|
+
continue
|
|
476
|
+
for fname in sorted(files):
|
|
477
|
+
if fname.startswith("."):
|
|
478
|
+
continue
|
|
479
|
+
rel_path = os.path.join(rel_root, fname) if rel_root != "." else fname
|
|
480
|
+
entries.append(rel_path)
|
|
481
|
+
return "\n".join(entries[:200]) or "(empty directory)"
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _file_search(workdir: Path, pattern: str, path: str = ".", include: str = None,
|
|
485
|
+
case_insensitive: bool = False) -> str:
|
|
486
|
+
"""Search for a pattern across files using grep."""
|
|
487
|
+
target = workdir / path
|
|
488
|
+
|
|
489
|
+
# Build grep command
|
|
490
|
+
cmd = ["grep", "-rn", "--color=never"]
|
|
491
|
+
if case_insensitive:
|
|
492
|
+
cmd.append("-i")
|
|
493
|
+
if include:
|
|
494
|
+
cmd.extend(["--include", include])
|
|
495
|
+
# Exclude common non-code directories
|
|
496
|
+
for excl in [".git", "node_modules", "__pycache__", ".venv", "venv", ".egg-info"]:
|
|
497
|
+
cmd.extend(["--exclude-dir", excl])
|
|
498
|
+
cmd.extend([pattern, str(target)])
|
|
499
|
+
|
|
500
|
+
try:
|
|
501
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30, cwd=workdir)
|
|
502
|
+
output = result.stdout.strip()
|
|
503
|
+
if not output:
|
|
504
|
+
return f"No matches found for pattern: {pattern}"
|
|
505
|
+
|
|
506
|
+
lines = output.splitlines()
|
|
507
|
+
if len(lines) > 50:
|
|
508
|
+
return "\n".join(lines[:50]) + f"\n\n... ({len(lines) - 50} more matches)"
|
|
509
|
+
return output
|
|
510
|
+
except subprocess.TimeoutExpired:
|
|
511
|
+
return "error: search timed out after 30s"
|
|
512
|
+
except FileNotFoundError:
|
|
513
|
+
return "error: grep not available on this system"
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _apply_patch(workdir: Path, path: str, patch: str) -> str:
|
|
517
|
+
"""Apply a unified diff patch to a file."""
|
|
518
|
+
target = workdir / path
|
|
519
|
+
if not target.exists():
|
|
520
|
+
return f"error: file not found: {path}"
|
|
521
|
+
|
|
522
|
+
try:
|
|
523
|
+
# Write patch to temp file and apply
|
|
524
|
+
patch_file = workdir / ".alpiecode_patch.tmp"
|
|
525
|
+
# Ensure patch has proper file headers
|
|
526
|
+
full_patch = patch
|
|
527
|
+
if not patch.startswith("---"):
|
|
528
|
+
full_patch = f"--- a/{path}\n+++ b/{path}\n{patch}"
|
|
529
|
+
patch_file.write_text(full_patch)
|
|
530
|
+
|
|
531
|
+
result = subprocess.run(
|
|
532
|
+
["patch", "-p1", "--no-backup-if-mismatch", "-i", str(patch_file)],
|
|
533
|
+
cwd=workdir, capture_output=True, text=True, timeout=10
|
|
534
|
+
)
|
|
535
|
+
patch_file.unlink(missing_ok=True)
|
|
536
|
+
|
|
537
|
+
if result.returncode == 0:
|
|
538
|
+
return f"patch applied successfully to {path}"
|
|
539
|
+
else:
|
|
540
|
+
return f"error applying patch: {result.stderr or result.stdout}"
|
|
541
|
+
except FileNotFoundError:
|
|
542
|
+
# Fallback: manual line-by-line patch application
|
|
543
|
+
patch_file = workdir / ".alpiecode_patch.tmp"
|
|
544
|
+
patch_file.unlink(missing_ok=True)
|
|
545
|
+
return "error: 'patch' command not available. Use edit_file instead."
|
|
546
|
+
except Exception as e:
|
|
547
|
+
patch_file = workdir / ".alpiecode_patch.tmp"
|
|
548
|
+
patch_file.unlink(missing_ok=True)
|
|
549
|
+
return f"error applying patch: {e}"
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _web_search(query: str, num_results: int = 5) -> str:
|
|
553
|
+
"""Search the web using DDGS (DuckDuckGo)."""
|
|
554
|
+
import warnings
|
|
555
|
+
warnings.filterwarnings("ignore", category=RuntimeWarning)
|
|
556
|
+
|
|
557
|
+
try:
|
|
558
|
+
try:
|
|
559
|
+
from ddgs import DDGS
|
|
560
|
+
except ImportError:
|
|
561
|
+
from duckduckgo_search import DDGS
|
|
562
|
+
|
|
563
|
+
with DDGS() as ddgs:
|
|
564
|
+
results = list(ddgs.text(query, max_results=min(num_results, 10)))
|
|
565
|
+
if not results:
|
|
566
|
+
return (
|
|
567
|
+
f"No web results found for '{query}'. "
|
|
568
|
+
"Tip: If searching for Python package documentation, use bash: python -c 'import pkg; help(pkg)'"
|
|
569
|
+
)
|
|
570
|
+
formatted = []
|
|
571
|
+
for i, r in enumerate(results, 1):
|
|
572
|
+
formatted.append(f"{i}. **{r.get('title', 'No title')}**\n URL: {r.get('href', '')}\n {r.get('body', '')}")
|
|
573
|
+
return "\n\n".join(formatted)
|
|
574
|
+
except ImportError:
|
|
575
|
+
return "error: web search unavailable — install ddgs or duckduckgo-search package"
|
|
576
|
+
except Exception as e:
|
|
577
|
+
return f"error during web search: {e}. Tip: Try python -c 'help(...)' for installed library docs."
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _fetch_url(url: str) -> str:
|
|
581
|
+
"""Fetch and extract text content from a URL."""
|
|
582
|
+
try:
|
|
583
|
+
import urllib.request
|
|
584
|
+
req = urllib.request.Request(url, headers={"User-Agent": "AlpieCode/0.2.0"})
|
|
585
|
+
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
586
|
+
html = resp.read().decode("utf-8", errors="replace")
|
|
587
|
+
|
|
588
|
+
# Try to extract text with beautifulsoup if available
|
|
589
|
+
try:
|
|
590
|
+
from bs4 import BeautifulSoup
|
|
591
|
+
soup = BeautifulSoup(html, "html.parser")
|
|
592
|
+
# Remove script and style elements
|
|
593
|
+
for tag in soup(["script", "style", "nav", "footer", "header"]):
|
|
594
|
+
tag.decompose()
|
|
595
|
+
text = soup.get_text(separator="\n", strip=True)
|
|
596
|
+
except ImportError:
|
|
597
|
+
# Fallback: basic HTML tag stripping
|
|
598
|
+
text = re.sub(r"<[^>]+>", "", html)
|
|
599
|
+
text = re.sub(r"\s+", " ", text).strip()
|
|
600
|
+
|
|
601
|
+
# Truncate to reasonable length
|
|
602
|
+
if len(text) > 8000:
|
|
603
|
+
text = text[:8000] + f"\n\n... (truncated, {len(text)} chars total)"
|
|
604
|
+
return text
|
|
605
|
+
except Exception as e:
|
|
606
|
+
return f"error fetching URL: {e}"
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def _request_user_input(question: str) -> str:
|
|
610
|
+
"""Ask the user a question and return their response."""
|
|
611
|
+
try:
|
|
612
|
+
from rich.console import Console
|
|
613
|
+
from rich.panel import Panel
|
|
614
|
+
console = Console()
|
|
615
|
+
console.print(Panel(
|
|
616
|
+
f"[bold]{question}[/bold]",
|
|
617
|
+
title="❓ Agent needs your input",
|
|
618
|
+
border_style="yellow",
|
|
619
|
+
))
|
|
620
|
+
response = console.input("[bold green]Your answer ❯[/bold green] ").strip()
|
|
621
|
+
except ImportError:
|
|
622
|
+
print(f"\n❓ Agent needs your input: {question}")
|
|
623
|
+
response = input("Your answer ❯ ").strip()
|
|
624
|
+
|
|
625
|
+
return response or "(no response)"
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def _update_plan(workdir: Path, plan: str) -> str:
|
|
629
|
+
"""Save the agent's task plan to a file."""
|
|
630
|
+
plan_path = workdir / ".alpiecode_plan.md"
|
|
631
|
+
plan_path.write_text(f"# AlpieCode Task Plan\n\n{plan}\n")
|
|
632
|
+
return f"plan saved to .alpiecode_plan.md"
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def _view_image(workdir: Path, path: str) -> str:
|
|
636
|
+
"""Inspect and return metadata + base64 data for an image file."""
|
|
637
|
+
target = workdir / path
|
|
638
|
+
if not target.exists():
|
|
639
|
+
return f"error: image file not found: {path}"
|
|
640
|
+
|
|
641
|
+
import base64
|
|
642
|
+
ext = target.suffix.lower().lstrip(".")
|
|
643
|
+
mime_type = f"image/{'jpeg' if ext in ('jpg', 'jpeg') else ext}"
|
|
644
|
+
|
|
645
|
+
try:
|
|
646
|
+
size = target.stat().st_size
|
|
647
|
+
encoded = base64.b64encode(target.read_bytes()).decode("utf-8")
|
|
648
|
+
# Truncate string for string representation, model will get image context
|
|
649
|
+
return f"[Image {path} | Type: {mime_type} | Size: {size} bytes | Base64 Length: {len(encoded)}]"
|
|
650
|
+
except Exception as e:
|
|
651
|
+
return f"error reading image: {e}"
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
# ── GitHub tool implementations ───────────────────────────────────────
|
|
655
|
+
|
|
656
|
+
def _github_issues(owner: str, repo: str, issue_number: int = None,
|
|
657
|
+
state: str = "open", max_results: int = 10) -> str:
|
|
658
|
+
"""Fetch issues/PRs from a GitHub repo."""
|
|
659
|
+
from .github import fetch_issues, fetch_issue_detail
|
|
660
|
+
if issue_number:
|
|
661
|
+
return fetch_issue_detail(owner, repo, issue_number)
|
|
662
|
+
return fetch_issues(owner, repo, state=state, max_results=max_results)
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def _github_browse(owner: str, repo: str, path: str = "",
|
|
666
|
+
info_only: bool = False) -> str:
|
|
667
|
+
"""Browse a GitHub repo's structure and files."""
|
|
668
|
+
from .github import fetch_repo_info, fetch_repo_tree
|
|
669
|
+
if info_only:
|
|
670
|
+
return fetch_repo_info(owner, repo)
|
|
671
|
+
if path:
|
|
672
|
+
return fetch_repo_tree(owner, repo, path)
|
|
673
|
+
# Return repo info + root tree
|
|
674
|
+
info = fetch_repo_info(owner, repo)
|
|
675
|
+
tree = fetch_repo_tree(owner, repo)
|
|
676
|
+
return f"=== Repository Info ===\n{info}\n\n=== Root Directory ===\n{tree}"
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def _clone_repo(workdir: Path, repo_url: str, branch: str = None) -> str:
|
|
680
|
+
"""Clone a GitHub repo into the workdir."""
|
|
681
|
+
from .github import clone_repo
|
|
682
|
+
return clone_repo(repo_url, workdir, branch=branch)
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
# ── Dispatch factory ──────────────────────────────────────────────────
|
|
686
|
+
|
|
687
|
+
def make_dispatch(workdir: Path):
|
|
688
|
+
"""Bind tool implementations to a specific working directory."""
|
|
689
|
+
return {
|
|
690
|
+
"bash": lambda a: _bash(workdir, a["command"]),
|
|
691
|
+
"read_file": lambda a: _read_file(workdir, a["path"], a.get("start_line"), a.get("end_line")),
|
|
692
|
+
"write_file": lambda a: _write_file(workdir, a["path"], a["content"]),
|
|
693
|
+
"edit_file": lambda a: _edit_file(workdir, a["path"], a["old_str"], a["new_str"]),
|
|
694
|
+
"list_files": lambda a: _list_files(workdir, a.get("path", "."), a.get("max_depth", 4)),
|
|
695
|
+
"file_search": lambda a: _file_search(workdir, a["pattern"], a.get("path", "."),
|
|
696
|
+
a.get("include"), a.get("case_insensitive", False)),
|
|
697
|
+
"apply_patch": lambda a: _apply_patch(workdir, a["path"], a["patch"]),
|
|
698
|
+
"web_search": lambda a: _web_search(a["query"], a.get("num_results", 5)),
|
|
699
|
+
"fetch_url": lambda a: _fetch_url(a["url"]),
|
|
700
|
+
"request_user_input": lambda a: _request_user_input(a["question"]),
|
|
701
|
+
"update_plan": lambda a: _update_plan(workdir, a["plan"]),
|
|
702
|
+
"view_image": lambda a: _view_image(workdir, a["path"]),
|
|
703
|
+
"github_issues": lambda a: _github_issues(
|
|
704
|
+
a["owner"], a["repo"],
|
|
705
|
+
issue_number=a.get("issue_number"),
|
|
706
|
+
state=a.get("state", "open"),
|
|
707
|
+
max_results=a.get("max_results", 10),
|
|
708
|
+
),
|
|
709
|
+
"github_browse": lambda a: _github_browse(
|
|
710
|
+
a["owner"], a["repo"],
|
|
711
|
+
path=a.get("path", ""),
|
|
712
|
+
info_only=a.get("info_only", False),
|
|
713
|
+
),
|
|
714
|
+
"clone_repo": lambda a: _clone_repo(
|
|
715
|
+
workdir, a["repo_url"],
|
|
716
|
+
branch=a.get("branch"),
|
|
717
|
+
),
|
|
718
|
+
}
|