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.
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")