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/media.py
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Media processing for AlpieCode — video frame extraction & YouTube download.
|
|
3
|
+
|
|
4
|
+
Supports:
|
|
5
|
+
- Video files: .mp4, .avi, .mov, .mkv, .webm → extract key frames via ffmpeg
|
|
6
|
+
- YouTube URLs: download via yt-dlp → extract frames
|
|
7
|
+
- Images: pass-through (already supported in agent.py)
|
|
8
|
+
|
|
9
|
+
Uses ffmpeg for frame extraction (no heavy Python deps like opencv).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import base64
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import shutil
|
|
16
|
+
import subprocess
|
|
17
|
+
import tempfile
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import List, Optional, Tuple
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
from rich.console import Console
|
|
23
|
+
console = Console()
|
|
24
|
+
HAS_RICH = True
|
|
25
|
+
except ImportError:
|
|
26
|
+
HAS_RICH = False
|
|
27
|
+
class _Fallback:
|
|
28
|
+
def print(self, *a, **kw):
|
|
29
|
+
kw.pop("style", None)
|
|
30
|
+
kw.pop("highlight", None)
|
|
31
|
+
print(*a, **kw)
|
|
32
|
+
console = _Fallback()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ── Constants ─────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
VIDEO_EXTENSIONS = {".mp4", ".avi", ".mov", ".mkv", ".webm", ".flv", ".wmv", ".m4v"}
|
|
38
|
+
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg"}
|
|
39
|
+
MAX_FRAMES = 8 # Max frames to extract from a video (balances context vs quality)
|
|
40
|
+
FRAME_QUALITY = 85 # JPEG quality for extracted frames
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ── YouTube URL detection ─────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
YOUTUBE_PATTERNS = [
|
|
46
|
+
r"(?:https?://)?(?:www\.)?youtube\.com/watch\?v=[\w-]+",
|
|
47
|
+
r"(?:https?://)?youtu\.be/[\w-]+",
|
|
48
|
+
r"(?:https?://)?(?:www\.)?youtube\.com/shorts/[\w-]+",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def is_youtube_url(url: str) -> bool:
|
|
53
|
+
"""Check if a string is a YouTube URL."""
|
|
54
|
+
return any(re.match(pattern, url) for pattern in YOUTUBE_PATTERNS)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def is_video_file(path: str) -> bool:
|
|
58
|
+
"""Check if a file path is a supported video format."""
|
|
59
|
+
return Path(path).suffix.lower() in VIDEO_EXTENSIONS
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def is_image_file(path: str) -> bool:
|
|
63
|
+
"""Check if a file path is a supported image format."""
|
|
64
|
+
return Path(path).suffix.lower() in IMAGE_EXTENSIONS
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ── Frame extraction via ffmpeg ───────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
def _get_video_duration(video_path: str) -> Optional[float]:
|
|
70
|
+
"""Get video duration in seconds using ffprobe."""
|
|
71
|
+
try:
|
|
72
|
+
result = subprocess.run(
|
|
73
|
+
["ffprobe", "-v", "error", "-show_entries", "format=duration",
|
|
74
|
+
"-of", "default=noprint_wrappers=1:nokey=1", video_path],
|
|
75
|
+
capture_output=True, text=True, timeout=15,
|
|
76
|
+
)
|
|
77
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
78
|
+
return float(result.stdout.strip())
|
|
79
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, ValueError):
|
|
80
|
+
pass
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _get_ffmpeg_cmd() -> str:
|
|
85
|
+
"""Get ffmpeg executable path from system PATH or bundled imageio-ffmpeg."""
|
|
86
|
+
sys_ffmpeg = shutil.which("ffmpeg")
|
|
87
|
+
if sys_ffmpeg:
|
|
88
|
+
return sys_ffmpeg
|
|
89
|
+
try:
|
|
90
|
+
import imageio_ffmpeg
|
|
91
|
+
return imageio_ffmpeg.get_ffmpeg_exe()
|
|
92
|
+
except (ImportError, Exception):
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def extract_frames(video_path: str, max_frames: int = MAX_FRAMES) -> List[Tuple[str, bytes]]:
|
|
97
|
+
"""
|
|
98
|
+
Extract key frames from a video using ffmpeg or imageio-ffmpeg.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
List of (mime_type, raw_bytes) tuples for each extracted frame.
|
|
102
|
+
"""
|
|
103
|
+
ffmpeg_cmd = _get_ffmpeg_cmd()
|
|
104
|
+
if not ffmpeg_cmd:
|
|
105
|
+
raise RuntimeError(
|
|
106
|
+
"ffmpeg binary not found. Please ensure imageio-ffmpeg dependency is installed."
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
video_path = str(Path(video_path).resolve())
|
|
110
|
+
duration = _get_video_duration(video_path)
|
|
111
|
+
|
|
112
|
+
with tempfile.TemporaryDirectory(prefix="alpiecode_frames_") as tmpdir:
|
|
113
|
+
if duration and duration > 0:
|
|
114
|
+
# Extract evenly-spaced frames across the video duration
|
|
115
|
+
interval = duration / (max_frames + 1)
|
|
116
|
+
frames = []
|
|
117
|
+
for i in range(1, max_frames + 1):
|
|
118
|
+
timestamp = interval * i
|
|
119
|
+
out_path = os.path.join(tmpdir, f"frame_{i:03d}.jpg")
|
|
120
|
+
subprocess.run(
|
|
121
|
+
[ffmpeg_cmd, "-ss", f"{timestamp:.2f}", "-i", video_path,
|
|
122
|
+
"-vframes", "1", "-q:v", str(FRAME_QUALITY // 10),
|
|
123
|
+
"-y", out_path],
|
|
124
|
+
capture_output=True, timeout=30,
|
|
125
|
+
)
|
|
126
|
+
if os.path.exists(out_path) and os.path.getsize(out_path) > 0:
|
|
127
|
+
frames.append(("image/jpeg", Path(out_path).read_bytes()))
|
|
128
|
+
return frames if frames else _extract_frames_fallback(video_path, tmpdir, max_frames, ffmpeg_cmd)
|
|
129
|
+
else:
|
|
130
|
+
return _extract_frames_fallback(video_path, tmpdir, max_frames, ffmpeg_cmd)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _extract_frames_fallback(video_path: str, tmpdir: str, max_frames: int, ffmpeg_cmd: str = None) -> List[Tuple[str, bytes]]:
|
|
134
|
+
"""Fallback: extract frames at 1 fps and pick evenly-spaced ones."""
|
|
135
|
+
cmd = ffmpeg_cmd or _get_ffmpeg_cmd() or "ffmpeg"
|
|
136
|
+
subprocess.run(
|
|
137
|
+
[cmd, "-i", video_path, "-vf", "fps=1", "-q:v", "2",
|
|
138
|
+
"-y", os.path.join(tmpdir, "frame_%04d.jpg")],
|
|
139
|
+
capture_output=True, timeout=120,
|
|
140
|
+
)
|
|
141
|
+
all_frames = sorted(Path(tmpdir).glob("frame_*.jpg"))
|
|
142
|
+
if not all_frames:
|
|
143
|
+
raise RuntimeError(f"ffmpeg failed to extract any frames from {video_path}")
|
|
144
|
+
|
|
145
|
+
# Pick evenly spaced frames
|
|
146
|
+
step = max(1, len(all_frames) // max_frames)
|
|
147
|
+
selected = all_frames[::step][:max_frames]
|
|
148
|
+
return [("image/jpeg", f.read_bytes()) for f in selected]
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# ── YouTube download via yt-dlp ───────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
def download_youtube(url: str, output_dir: str = None) -> str:
|
|
154
|
+
"""
|
|
155
|
+
Download a YouTube video using yt-dlp.
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
Path to the downloaded video file.
|
|
159
|
+
"""
|
|
160
|
+
if not shutil.which("yt-dlp"):
|
|
161
|
+
raise RuntimeError(
|
|
162
|
+
"yt-dlp is required for YouTube downloads. "
|
|
163
|
+
"Install it with: uv pip install yt-dlp"
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
if output_dir is None:
|
|
167
|
+
output_dir = tempfile.mkdtemp(prefix="alpiecode_yt_")
|
|
168
|
+
|
|
169
|
+
output_template = os.path.join(output_dir, "video.%(ext)s")
|
|
170
|
+
|
|
171
|
+
result = subprocess.run(
|
|
172
|
+
["yt-dlp",
|
|
173
|
+
"-f", "best[height<=720]", # Cap at 720p to keep frames reasonable
|
|
174
|
+
"--no-playlist",
|
|
175
|
+
"-o", output_template,
|
|
176
|
+
url],
|
|
177
|
+
capture_output=True, text=True, timeout=120,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
if result.returncode != 0:
|
|
181
|
+
raise RuntimeError(f"yt-dlp failed: {result.stderr[:500]}")
|
|
182
|
+
|
|
183
|
+
# Find the downloaded file
|
|
184
|
+
for f in Path(output_dir).iterdir():
|
|
185
|
+
if f.suffix.lower() in VIDEO_EXTENSIONS and f.stat().st_size > 0:
|
|
186
|
+
return str(f)
|
|
187
|
+
|
|
188
|
+
raise RuntimeError(f"yt-dlp download completed but no video file found in {output_dir}")
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# ── High-level: build multimodal content ──────────────────────────────
|
|
192
|
+
|
|
193
|
+
def build_media_content(
|
|
194
|
+
task: str,
|
|
195
|
+
image_path: Optional[str] = None,
|
|
196
|
+
video_path: Optional[str] = None,
|
|
197
|
+
url: Optional[str] = None,
|
|
198
|
+
workdir: Path = None,
|
|
199
|
+
) -> list:
|
|
200
|
+
"""
|
|
201
|
+
Build a multimodal message content array from text + optional media.
|
|
202
|
+
|
|
203
|
+
Handles:
|
|
204
|
+
- image_path: single image → 1 image_url entry
|
|
205
|
+
- video_path: video file → N frame image_url entries
|
|
206
|
+
- url: YouTube URL → download + extract frames
|
|
207
|
+
|
|
208
|
+
Returns:
|
|
209
|
+
list suitable for OpenAI messages[].content (text + image_url entries)
|
|
210
|
+
"""
|
|
211
|
+
content = [{"type": "text", "text": task}]
|
|
212
|
+
workdir = workdir or Path(".")
|
|
213
|
+
|
|
214
|
+
# ── Image ──
|
|
215
|
+
if image_path:
|
|
216
|
+
img_file = workdir / image_path if not Path(image_path).is_absolute() else Path(image_path)
|
|
217
|
+
if img_file.exists():
|
|
218
|
+
ext = img_file.suffix.lower().lstrip(".")
|
|
219
|
+
mime = f"image/{'jpeg' if ext in ('jpg', 'jpeg') else ext}"
|
|
220
|
+
b64 = base64.b64encode(img_file.read_bytes()).decode("utf-8")
|
|
221
|
+
content.append({
|
|
222
|
+
"type": "image_url",
|
|
223
|
+
"image_url": {"url": f"data:{mime};base64,{b64}"},
|
|
224
|
+
})
|
|
225
|
+
if HAS_RICH:
|
|
226
|
+
console.print(f"🖼️ Image loaded: {image_path}", style="cyan")
|
|
227
|
+
else:
|
|
228
|
+
if HAS_RICH:
|
|
229
|
+
console.print(f"⚠️ Image not found: {image_path}", style="yellow")
|
|
230
|
+
|
|
231
|
+
# ── Video file ──
|
|
232
|
+
if video_path:
|
|
233
|
+
vid_file = workdir / video_path if not Path(video_path).is_absolute() else Path(video_path)
|
|
234
|
+
if vid_file.exists():
|
|
235
|
+
if HAS_RICH:
|
|
236
|
+
console.print(f"🎬 Extracting frames from: {video_path}...", style="cyan")
|
|
237
|
+
try:
|
|
238
|
+
frames = extract_frames(str(vid_file))
|
|
239
|
+
for i, (mime, raw_bytes) in enumerate(frames):
|
|
240
|
+
b64 = base64.b64encode(raw_bytes).decode("utf-8")
|
|
241
|
+
content.append({
|
|
242
|
+
"type": "image_url",
|
|
243
|
+
"image_url": {"url": f"data:{mime};base64,{b64}"},
|
|
244
|
+
})
|
|
245
|
+
if HAS_RICH:
|
|
246
|
+
console.print(f" ✅ Extracted {len(frames)} frames", style="green")
|
|
247
|
+
except RuntimeError as e:
|
|
248
|
+
if HAS_RICH:
|
|
249
|
+
console.print(f" ❌ {e}", style="red")
|
|
250
|
+
else:
|
|
251
|
+
if HAS_RICH:
|
|
252
|
+
console.print(f"⚠️ Video not found: {video_path}", style="yellow")
|
|
253
|
+
|
|
254
|
+
# ── YouTube URL ──
|
|
255
|
+
if url and is_youtube_url(url):
|
|
256
|
+
if HAS_RICH:
|
|
257
|
+
console.print(f"📺 Downloading YouTube video: {url}...", style="cyan")
|
|
258
|
+
try:
|
|
259
|
+
downloaded = download_youtube(url)
|
|
260
|
+
if HAS_RICH:
|
|
261
|
+
console.print(f" ✅ Downloaded: {Path(downloaded).name}", style="green")
|
|
262
|
+
console.print(f"🎬 Extracting frames...", style="cyan")
|
|
263
|
+
frames = extract_frames(downloaded)
|
|
264
|
+
for mime, raw_bytes in frames:
|
|
265
|
+
b64 = base64.b64encode(raw_bytes).decode("utf-8")
|
|
266
|
+
content.append({
|
|
267
|
+
"type": "image_url",
|
|
268
|
+
"image_url": {"url": f"data:{mime};base64,{b64}"},
|
|
269
|
+
})
|
|
270
|
+
if HAS_RICH:
|
|
271
|
+
console.print(f" ✅ Extracted {len(frames)} frames from YouTube video", style="green")
|
|
272
|
+
# Clean up downloaded video
|
|
273
|
+
try:
|
|
274
|
+
os.remove(downloaded)
|
|
275
|
+
os.rmdir(str(Path(downloaded).parent))
|
|
276
|
+
except OSError:
|
|
277
|
+
pass
|
|
278
|
+
except RuntimeError as e:
|
|
279
|
+
if HAS_RICH:
|
|
280
|
+
console.print(f" ❌ {e}", style="red")
|
|
281
|
+
|
|
282
|
+
# If only text was added, return the plain string (no media)
|
|
283
|
+
if len(content) == 1:
|
|
284
|
+
return task
|
|
285
|
+
|
|
286
|
+
return content
|
codeagent/memory.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Memory — persistent cross-session context for AlpieCode.
|
|
3
|
+
|
|
4
|
+
Saves key learnings from each session to ~/.alpiecode/memories/:
|
|
5
|
+
- Project structure and layout
|
|
6
|
+
- Build/test commands discovered
|
|
7
|
+
- Coding patterns and conventions
|
|
8
|
+
- Known issues and workarounds
|
|
9
|
+
|
|
10
|
+
Memories are loaded at the start of each new session and injected
|
|
11
|
+
into the system prompt as additional context.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import hashlib
|
|
16
|
+
import time
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import List, Optional
|
|
19
|
+
|
|
20
|
+
MEMORY_DIR = Path.home() / ".alpiecode" / "memories"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _project_key(workdir: Path) -> str:
|
|
24
|
+
"""Generate a stable key for a project directory."""
|
|
25
|
+
return hashlib.md5(str(workdir.resolve()).encode()).hexdigest()[:12]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _memory_path(workdir: Path) -> Path:
|
|
29
|
+
"""Get the memory file path for a project."""
|
|
30
|
+
return MEMORY_DIR / f"{_project_key(workdir)}.json"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def load_memories(workdir: Path) -> List[dict]:
|
|
34
|
+
"""
|
|
35
|
+
Load memories for a specific project directory.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
List of memory entries, each with 'content', 'timestamp', 'type'
|
|
39
|
+
"""
|
|
40
|
+
path = _memory_path(workdir)
|
|
41
|
+
if not path.exists():
|
|
42
|
+
return []
|
|
43
|
+
try:
|
|
44
|
+
data = json.loads(path.read_text())
|
|
45
|
+
return data.get("memories", [])
|
|
46
|
+
except (json.JSONDecodeError, KeyError):
|
|
47
|
+
return []
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def save_memory(workdir: Path, content: str, memory_type: str = "learning") -> None:
|
|
51
|
+
"""
|
|
52
|
+
Save a memory entry for a project.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
workdir: Project directory
|
|
56
|
+
content: The memory content to save
|
|
57
|
+
memory_type: Type of memory (learning, structure, command, pattern)
|
|
58
|
+
"""
|
|
59
|
+
MEMORY_DIR.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
path = _memory_path(workdir)
|
|
61
|
+
|
|
62
|
+
existing = load_memories(workdir)
|
|
63
|
+
existing.append({
|
|
64
|
+
"content": content,
|
|
65
|
+
"type": memory_type,
|
|
66
|
+
"timestamp": time.time(),
|
|
67
|
+
"workdir": str(workdir.resolve()),
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
# Keep only the last 20 memories per project (FIFO)
|
|
71
|
+
if len(existing) > 20:
|
|
72
|
+
existing = existing[-20:]
|
|
73
|
+
|
|
74
|
+
path.write_text(json.dumps({
|
|
75
|
+
"project": str(workdir.resolve()),
|
|
76
|
+
"memories": existing,
|
|
77
|
+
}, indent=2))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def format_memories_for_prompt(workdir: Path) -> Optional[str]:
|
|
81
|
+
"""
|
|
82
|
+
Format memories into a string suitable for injection into the system prompt.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
Formatted memories string, or None if no memories exist
|
|
86
|
+
"""
|
|
87
|
+
memories = load_memories(workdir)
|
|
88
|
+
if not memories:
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
lines = ["## Recalled memories from previous sessions on this project:\n"]
|
|
92
|
+
for mem in memories[-10:]: # Only inject last 10 to save context
|
|
93
|
+
lines.append(f"- [{mem.get('type', 'note')}] {mem['content']}")
|
|
94
|
+
|
|
95
|
+
lines.append(
|
|
96
|
+
"\nNote: These memories are from previous sessions. "
|
|
97
|
+
"Verify they are still accurate before relying on them."
|
|
98
|
+
)
|
|
99
|
+
return "\n".join(lines)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def extract_and_save_memories(workdir: Path, messages: list) -> None:
|
|
103
|
+
"""
|
|
104
|
+
After a session ends, extract key learnings from the conversation
|
|
105
|
+
and save them as memories.
|
|
106
|
+
|
|
107
|
+
This scans tool results for commonly useful information like:
|
|
108
|
+
- Project structure (from list_files results)
|
|
109
|
+
- Test commands (from bash results running tests)
|
|
110
|
+
- Build commands (from bash results running builds)
|
|
111
|
+
"""
|
|
112
|
+
for msg in messages:
|
|
113
|
+
if not isinstance(msg, dict):
|
|
114
|
+
continue
|
|
115
|
+
|
|
116
|
+
# Look for tool results
|
|
117
|
+
if msg.get("role") == "tool":
|
|
118
|
+
content = msg.get("content", "")
|
|
119
|
+
|
|
120
|
+
# Detect test commands that succeeded
|
|
121
|
+
if '"exit_code": 0' in content and any(kw in content.lower() for kw in
|
|
122
|
+
["pytest", "test", "passed", "ok", "success"]):
|
|
123
|
+
# Find the corresponding tool call to get the command
|
|
124
|
+
pass # Would need more context to extract command
|
|
125
|
+
|
|
126
|
+
# Look for assistant messages with DONE
|
|
127
|
+
if msg.get("role") == "assistant" and msg.get("content"):
|
|
128
|
+
content = msg["content"]
|
|
129
|
+
if content.strip().startswith("DONE"):
|
|
130
|
+
save_memory(workdir, content.strip()[:200], "completion_summary")
|