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/cli.py ADDED
@@ -0,0 +1,215 @@
1
+ """
2
+ CLI entry point for AlpieCode.
3
+ """
4
+
5
+ import argparse
6
+ import subprocess
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from .config import CONFIG_PATH, interactive_init, load_config
11
+
12
+
13
+ BANNER = r"""
14
+ _ _ _ ____ _
15
+ / \ | |_ _| | ___ / ___|___ __| | ___
16
+ / _ \ | | '_ \ |/ _ \ | / _ \ / _` |/ _ \
17
+ / ___ \| | |_) | | __/ |__| (_) | (_| | __/
18
+ /_/ \_\_|_.__/|_|\___|\____\___/ \__,_|\___|
19
+ """
20
+
21
+
22
+ def _show_banner():
23
+ try:
24
+ from rich.console import Console
25
+ console = Console()
26
+ console.print(BANNER, style="bold cyan", highlight=False)
27
+ except ImportError:
28
+ print(BANNER)
29
+
30
+
31
+ def _normalize_args():
32
+ known_commands = {"init", "run", "chat", "plan", "diff", "-h", "--help", "--version"}
33
+ subcommand = None
34
+
35
+ # Check if a subcommand is present
36
+ for arg in sys.argv[1:]:
37
+ if arg in known_commands:
38
+ subcommand = arg
39
+ break
40
+
41
+ if not subcommand and len(sys.argv) > 1:
42
+ subcommand = "run"
43
+ sys.argv.insert(1, "run")
44
+
45
+ if subcommand in ("run", "plan") and len(sys.argv) > 2:
46
+ cmd_idx = sys.argv.index(subcommand)
47
+ sub_args = sys.argv[cmd_idx + 1:]
48
+
49
+ flags = []
50
+ positionals = []
51
+
52
+ i = 0
53
+ while i < len(sub_args):
54
+ arg = sub_args[i]
55
+ if arg.startswith("-"):
56
+ flags.append(arg)
57
+ if arg in ("--workdir", "--image", "--video", "--url", "--github", "--max-turns") and i + 1 < len(sub_args):
58
+ flags.append(sub_args[i + 1])
59
+ i += 1
60
+ else:
61
+ positionals.append(arg)
62
+ i += 1
63
+
64
+ sys.argv = sys.argv[:cmd_idx + 1] + flags + positionals
65
+
66
+
67
+ def main():
68
+ _normalize_args()
69
+
70
+ common = argparse.ArgumentParser(add_help=False)
71
+ common.add_argument("--workdir", default=".", help="Repo directory (default: current dir)")
72
+ common.add_argument("--image", default=None, help="Path to an image file for vision analysis")
73
+ common.add_argument("--video", default=None, help="Path to a video file for multimodal analysis")
74
+ common.add_argument("--url", default=None, help="YouTube URL for video analysis")
75
+ common.add_argument("--github", default=None, help="GitHub repository (e.g. owner/repo or URL) for open-source analysis")
76
+ common.add_argument("--max-turns", type=int, default=None, help="Override max turns")
77
+ common.add_argument("--no-thinking", "--non-thinking", dest="no_thinking", action="store_true", help="Disable VLM reasoning/thinking mode")
78
+ common.add_argument("--no-update", action="store_true", help="Skip automatic update check")
79
+ common.add_argument("--quiet", action="store_true", help="Suppress per-turn logging")
80
+
81
+ parser = argparse.ArgumentParser(
82
+ prog="alpiecode",
83
+ description="AlpieCode — Autonomous AI Coding Agent powered by 169Pi Alpie VLM",
84
+ parents=[common],
85
+ )
86
+ sub = parser.add_subparsers(dest="command")
87
+
88
+ # ── init ──
89
+ sub.add_parser("init", help="Configure your VLM/OpenAI-compatible endpoint")
90
+
91
+ # ── run ──
92
+ run_p = sub.add_parser("run", help="Run a coding task against a repository", parents=[common])
93
+ run_p.add_argument("task", help="Natural-language task description")
94
+
95
+ # ── chat ──
96
+ chat_p = sub.add_parser("chat", help="Interactive chat mode with AlpieCode", parents=[common])
97
+
98
+ # ── plan ──
99
+ plan_p = sub.add_parser("plan", help="Generate a plan without making changes (read-only)", parents=[common])
100
+ plan_p.add_argument("task", help="Natural-language task to plan for")
101
+
102
+ # ── diff ──
103
+ diff_p = sub.add_parser("diff", help="Show changes AlpieCode has made since last checkpoint", parents=[common])
104
+
105
+ args = parser.parse_args()
106
+
107
+ if not args.command:
108
+ parser.print_help()
109
+ return
110
+
111
+ if args.command == "init":
112
+ interactive_init()
113
+ return
114
+
115
+ # Check for auto-updates from GitHub in background
116
+ if not getattr(args, "no_update", False):
117
+ try:
118
+ from .updater import auto_update
119
+ auto_update(quiet=getattr(args, "quiet", False))
120
+ except Exception:
121
+ pass
122
+
123
+ cfg = load_config()
124
+
125
+ if getattr(args, "no_thinking", False):
126
+ cfg.enable_thinking = False
127
+
128
+ if args.command == "run":
129
+ if args.max_turns:
130
+ cfg.max_turns = args.max_turns
131
+ _show_banner()
132
+ from .agent import run_agent
133
+ run_agent(
134
+ args.task, Path(args.workdir), cfg,
135
+ verbose=not args.quiet,
136
+ image_path=args.image,
137
+ video_path=getattr(args, "video", None),
138
+ url=getattr(args, "url", None),
139
+ github_repo=getattr(args, "github", None),
140
+ )
141
+
142
+ elif args.command == "chat":
143
+ if args.max_turns:
144
+ cfg.max_turns = args.max_turns
145
+ _show_banner()
146
+ from .agent import run_chat
147
+ run_chat(Path(args.workdir), cfg, verbose=not args.quiet)
148
+
149
+ elif args.command == "plan":
150
+ _show_banner()
151
+ plan_task = (
152
+ f"PLANNING ONLY — Do NOT make any file edits. "
153
+ f"Analyze the codebase and create a detailed implementation plan for the following task. "
154
+ f"Use list_files, read_file, and file_search to understand the project. "
155
+ f"Then use update_plan to write a structured plan with deliverables and checks. "
156
+ f"Finish with DONE: when the plan is complete.\n\n"
157
+ f"Task: {args.task}"
158
+ )
159
+ from .agent import run_agent
160
+ run_agent(
161
+ plan_task, Path(args.workdir), cfg, verbose=True,
162
+ image_path=args.image,
163
+ video_path=getattr(args, "video", None),
164
+ url=getattr(args, "url", None),
165
+ github_repo=getattr(args, "github", None),
166
+ )
167
+
168
+ elif args.command == "diff":
169
+ workdir = Path(args.workdir).resolve()
170
+ # Show git diff since the first checkpoint
171
+ result = subprocess.run(
172
+ ["git", "log", "--oneline", "--all"],
173
+ cwd=workdir, capture_output=True, text=True
174
+ )
175
+ if result.returncode != 0:
176
+ print("Not a git repository or no commits found.")
177
+ return
178
+
179
+ # Find the start checkpoint
180
+ log_lines = result.stdout.strip().splitlines()
181
+ start_sha = None
182
+ for line in reversed(log_lines):
183
+ if "checkpoint: start" in line:
184
+ start_sha = line.split()[0]
185
+ break
186
+
187
+ if not start_sha:
188
+ print("No AlpieCode checkpoint found. Run a task first.")
189
+ return
190
+
191
+ diff_result = subprocess.run(
192
+ ["git", "diff", start_sha, "HEAD", "--stat"],
193
+ cwd=workdir, capture_output=True, text=True
194
+ )
195
+ print(f"Changes since AlpieCode started (from {start_sha}):\n")
196
+ print(diff_result.stdout)
197
+
198
+ # Also show the full diff
199
+ full_diff = subprocess.run(
200
+ ["git", "diff", start_sha, "HEAD"],
201
+ cwd=workdir, capture_output=True, text=True
202
+ )
203
+ if full_diff.stdout:
204
+ try:
205
+ from rich.console import Console
206
+ from rich.syntax import Syntax
207
+ console = Console()
208
+ syntax = Syntax(full_diff.stdout, "diff", theme="monokai")
209
+ console.print(syntax)
210
+ except ImportError:
211
+ print(full_diff.stdout)
212
+
213
+
214
+ if __name__ == "__main__":
215
+ main()
@@ -0,0 +1,163 @@
1
+ """
2
+ Context compaction for AlpieCode.
3
+
4
+ When the conversation history approaches the model's context window limit,
5
+ this module summarizes older turns to free up space while preserving
6
+ the essential information needed for the agent to continue working.
7
+
8
+ Strategy:
9
+ - Keep system prompt and last N turns intact
10
+ - Summarize older tool calls and results into compact descriptions
11
+ - Preserve all user messages verbatim
12
+ - Track approximate token count using a simple heuristic (4 chars ≈ 1 token)
13
+ """
14
+
15
+ import json
16
+ from typing import List
17
+
18
+ # Our model's context window
19
+ MAX_CONTEXT_TOKENS = 262_144
20
+ # Start compacting when we hit this percentage of the context window
21
+ COMPACT_THRESHOLD = 0.70
22
+ # Number of recent turns to always keep intact
23
+ KEEP_RECENT_TURNS = 10
24
+ # Approximate chars per token (rough heuristic)
25
+ CHARS_PER_TOKEN = 4
26
+
27
+
28
+ def estimate_tokens(messages: List[dict]) -> int:
29
+ """Estimate token count from a list of messages."""
30
+ total_chars = 0
31
+ for msg in messages:
32
+ if isinstance(msg, dict):
33
+ content = msg.get("content") or ""
34
+ if isinstance(content, str):
35
+ total_chars += len(content)
36
+ # Account for tool call arguments
37
+ tool_calls = msg.get("tool_calls", [])
38
+ if tool_calls:
39
+ for tc in tool_calls:
40
+ if isinstance(tc, dict):
41
+ fn = tc.get("function", {})
42
+ total_chars += len(fn.get("arguments", ""))
43
+ total_chars += len(fn.get("name", ""))
44
+ return total_chars // CHARS_PER_TOKEN
45
+
46
+
47
+ def needs_compaction(messages: List[dict], max_tokens: int = MAX_CONTEXT_TOKENS) -> bool:
48
+ """Check if the conversation needs compaction."""
49
+ tokens = estimate_tokens(messages)
50
+ return tokens > (max_tokens * COMPACT_THRESHOLD)
51
+
52
+
53
+ def _summarize_tool_result(tool_name: str, content: str) -> str:
54
+ """Create a compact summary of a tool result."""
55
+ if len(content) <= 300:
56
+ return content
57
+
58
+ if tool_name == "bash":
59
+ try:
60
+ data = json.loads(content)
61
+ stdout = data.get("stdout", "")
62
+ stderr = data.get("stderr", "")
63
+ exit_code = data.get("exit_code", -1)
64
+ summary = f"exit_code={exit_code}"
65
+ if stdout:
66
+ summary += f", stdout({len(stdout)} chars): {stdout[:150]}..."
67
+ if stderr:
68
+ summary += f", stderr: {stderr[:100]}..."
69
+ return summary
70
+ except json.JSONDecodeError:
71
+ pass
72
+
73
+ if tool_name in ("read_file", "list_files"):
74
+ lines = content.splitlines()
75
+ if len(lines) > 20:
76
+ return "\n".join(lines[:10]) + f"\n... ({len(lines) - 20} lines omitted) ...\n" + "\n".join(lines[-10:])
77
+
78
+ # Generic truncation
79
+ return content[:250] + f"... ({len(content)} chars total)"
80
+
81
+
82
+ def compact_messages(messages: List[dict]) -> List[dict]:
83
+ """
84
+ Compact a message list by summarizing older turns.
85
+
86
+ Preserves:
87
+ - System prompt (index 0)
88
+ - All user messages (verbatim)
89
+ - Last KEEP_RECENT_TURNS messages (verbatim)
90
+
91
+ Summarizes:
92
+ - Older tool results (truncated)
93
+ - Older assistant reasoning (removed)
94
+ """
95
+ if len(messages) <= KEEP_RECENT_TURNS + 2:
96
+ return messages
97
+
98
+ # Always keep system prompt
99
+ system = messages[0] if messages and messages[0].get("role") == "system" else None
100
+
101
+ # Split into old and recent
102
+ cutoff = len(messages) - KEEP_RECENT_TURNS
103
+ old_messages = messages[1:cutoff] if system else messages[:cutoff]
104
+ recent_messages = messages[cutoff:]
105
+
106
+ # Build a compacted summary of old messages
107
+ compacted_old = []
108
+ summary_parts = []
109
+
110
+ for msg in old_messages:
111
+ role = msg.get("role", "")
112
+
113
+ if role == "user":
114
+ # Keep user messages verbatim
115
+ compacted_old.append(msg)
116
+
117
+ elif role == "assistant":
118
+ # Compact assistant messages: keep tool calls but remove reasoning
119
+ compact_msg = {"role": "assistant"}
120
+ if msg.get("content"):
121
+ # Truncate long assistant content
122
+ content = msg["content"]
123
+ if len(content) > 200:
124
+ compact_msg["content"] = content[:200] + "..."
125
+ else:
126
+ compact_msg["content"] = content
127
+ else:
128
+ compact_msg["content"] = None
129
+
130
+ if msg.get("tool_calls"):
131
+ compact_msg["tool_calls"] = msg["tool_calls"]
132
+ compacted_old.append(compact_msg)
133
+
134
+ elif role == "tool":
135
+ # Summarize tool results
136
+ tool_call_id = msg.get("tool_call_id", "")
137
+ content = msg.get("content", "")
138
+
139
+ # Try to find the tool name from the preceding assistant message
140
+ tool_name = "unknown"
141
+ for prev in reversed(compacted_old):
142
+ if prev.get("tool_calls"):
143
+ for tc in prev["tool_calls"]:
144
+ tc_dict = tc if isinstance(tc, dict) else {}
145
+ if tc_dict.get("id") == tool_call_id:
146
+ tool_name = tc_dict.get("function", {}).get("name", "unknown")
147
+ break
148
+ break
149
+
150
+ compacted_old.append({
151
+ "role": "tool",
152
+ "tool_call_id": tool_call_id,
153
+ "content": _summarize_tool_result(tool_name, content),
154
+ })
155
+
156
+ # Rebuild message list
157
+ result = []
158
+ if system:
159
+ result.append(system)
160
+ result.extend(compacted_old)
161
+ result.extend(recent_messages)
162
+
163
+ return result
codeagent/config.py ADDED
@@ -0,0 +1,195 @@
1
+ """
2
+ Per-user config for AlpieCode.
3
+
4
+ Resolution order (highest priority first):
5
+ 1. Environment variables: HF_TOKEN, ALPIECODE_MODEL_REPO, etc.
6
+ 2. ~/.alpiecode/config.json (written by `alpiecode init`)
7
+ 3. Built-in defaults (Local GGUF model: 169Pi/Alpie_learn_prototype_GGUF_NEW)
8
+ """
9
+
10
+ import json
11
+ import os
12
+ import socket
13
+ from dataclasses import dataclass, asdict, fields
14
+ from pathlib import Path
15
+ from typing import Optional
16
+
17
+ CONFIG_DIR = Path.home() / ".alpiecode"
18
+ CONFIG_PATH = CONFIG_DIR / "config.json"
19
+
20
+ # Config version — bump this when defaults change to trigger auto-migration
21
+ CONFIG_VERSION = 2 # v2: n_ctx upgraded 16384 → 32768
22
+
23
+ DEFAULTS = {
24
+ "base_url": "http://20.245.200.125:8000/v1", # Remote VLM server endpoint
25
+ "model": "169Pi/grpo_phase_2_merged",
26
+ "model_repo": "169Pi/Alpie_learn_prototype_GGUF_NEW",
27
+ "api_key": "not-needed",
28
+ "hf_token": None,
29
+ "max_turns": 30,
30
+ "temperature": 0.2,
31
+ "max_tokens": 16384,
32
+ "enable_thinking": True,
33
+ "n_ctx": 32768, # 32k context window (model supports up to 131k)
34
+ "n_gpu_layers": None, # None = auto-detect GPU
35
+ "config_version": CONFIG_VERSION,
36
+ }
37
+
38
+
39
+ def is_server_reachable(base_url: Optional[str], timeout: float = 0.4) -> bool:
40
+ """Fast network ping check (0.4s max) to see if server endpoint is online."""
41
+ if not base_url:
42
+ return False
43
+ try:
44
+ from urllib.parse import urlparse
45
+ parsed = urlparse(base_url)
46
+ host = parsed.hostname
47
+ port = parsed.port or (443 if parsed.scheme == "https" else 80)
48
+ if not host:
49
+ return False
50
+ with socket.create_connection((host, port), timeout=timeout):
51
+ return True
52
+ except Exception:
53
+ return False
54
+
55
+
56
+ def is_internet_available(timeout: float = 1.0) -> bool:
57
+ """Quick check if general internet is available (ping Google DNS)."""
58
+ try:
59
+ with socket.create_connection(("8.8.8.8", 53), timeout=timeout):
60
+ return True
61
+ except Exception:
62
+ return False
63
+
64
+
65
+ @dataclass
66
+ class Config:
67
+ base_url: Optional[str] = None
68
+ model: str = "169Pi/grpo_phase_2_merged" # Server API model name (vLLM)
69
+ model_repo: str = "169Pi/Alpie_learn_prototype_GGUF_NEW" # HuggingFace repo for offline GGUF
70
+ api_key: str = "not-needed"
71
+ hf_token: Optional[str] = None
72
+ max_turns: int = 30
73
+ temperature: float = 0.2
74
+ max_tokens: int = 16384
75
+ enable_thinking: bool = True
76
+ n_ctx: int = 32768
77
+ n_gpu_layers: Optional[int] = None
78
+
79
+
80
+ def load_config() -> Config:
81
+ data = dict(DEFAULTS)
82
+ needs_save = False
83
+ if CONFIG_PATH.exists():
84
+ try:
85
+ saved_data = json.loads(CONFIG_PATH.read_text())
86
+ # Skip null values so they don't override smart defaults (e.g. base_url)
87
+ data.update({k: v for k, v in saved_data.items() if v is not None})
88
+
89
+ # ── Auto-migrate stale configs ────────────────────────────
90
+ saved_version = saved_data.get("config_version", 1)
91
+ if saved_version < CONFIG_VERSION:
92
+ # v1 → v2: n_ctx was 16384, upgrade to 32768
93
+ if saved_data.get("n_ctx") == 16384:
94
+ data["n_ctx"] = 32768
95
+ data["config_version"] = CONFIG_VERSION
96
+ needs_save = True
97
+ except Exception:
98
+ pass
99
+
100
+ # env vars override saved file
101
+ for prefix in ("ALPIECODE_", "CODEAGENT_"):
102
+ if os.environ.get(f"{prefix}BASE_URL"):
103
+ data["base_url"] = os.environ[f"{prefix}BASE_URL"]
104
+ if os.environ.get(f"{prefix}MODEL"):
105
+ data["model"] = os.environ[f"{prefix}MODEL"]
106
+ if os.environ.get(f"{prefix}MODEL_REPO"):
107
+ data["model_repo"] = os.environ[f"{prefix}MODEL_REPO"]
108
+ if os.environ.get(f"{prefix}API_KEY"):
109
+ data["api_key"] = os.environ[f"{prefix}API_KEY"]
110
+ if os.environ.get("HF_TOKEN"):
111
+ data["hf_token"] = os.environ["HF_TOKEN"]
112
+ if os.environ.get("ALPIECODE_CPU") == "1":
113
+ data["n_gpu_layers"] = 0
114
+ elif os.environ.get("ALPIECODE_GPU_LAYERS"):
115
+ try:
116
+ data["n_gpu_layers"] = int(os.environ["ALPIECODE_GPU_LAYERS"])
117
+ except ValueError:
118
+ pass
119
+
120
+ # Filter data to only keys present in Config fields
121
+ valid_keys = {f.name for f in fields(Config)}
122
+ filtered_data = {k: v for k, v in data.items() if k in valid_keys}
123
+ cfg = Config(**filtered_data)
124
+
125
+ # Auto-save migrated config
126
+ if needs_save:
127
+ try:
128
+ save_config(cfg)
129
+ except Exception:
130
+ pass
131
+
132
+ return cfg
133
+
134
+
135
+ def save_config(cfg: Config) -> None:
136
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
137
+ CONFIG_PATH.write_text(json.dumps(asdict(cfg), indent=2))
138
+
139
+
140
+ def interactive_init() -> Config:
141
+ current = load_config()
142
+ print("AlpieCode Setup — Local GGUF Model Configuration\n")
143
+
144
+ print("AlpieCode uses the 169Pi/Alpie_learn_prototype_GGUF_NEW model from HuggingFace.")
145
+ print("Please enter your HuggingFace user access token (required to download model).\n")
146
+
147
+ # Mask token for display (show first 3 + last 3 chars only)
148
+ if current.hf_token:
149
+ t = current.hf_token
150
+ masked = f"{t[:3]}***{t[-3:]}" if len(t) > 6 else "***"
151
+ else:
152
+ masked = "none"
153
+ token_prompt = f"HuggingFace Token [{masked}]: "
154
+ hf_token = input(token_prompt).strip() or current.hf_token
155
+
156
+ repo_prompt = f"Model Repo [{current.model_repo}]: "
157
+ model_repo = input(repo_prompt).strip() or current.model_repo
158
+
159
+ cfg = Config(
160
+ base_url=current.base_url,
161
+ model=current.model,
162
+ model_repo=model_repo,
163
+ api_key=current.api_key,
164
+ hf_token=hf_token,
165
+ max_turns=current.max_turns,
166
+ temperature=current.temperature,
167
+ max_tokens=current.max_tokens,
168
+ enable_thinking=current.enable_thinking,
169
+ n_ctx=current.n_ctx,
170
+ n_gpu_layers=current.n_gpu_layers,
171
+ )
172
+ save_config(cfg)
173
+ print(f"\n✅ Config saved to {CONFIG_PATH}")
174
+
175
+ # Trigger model download test if token provided
176
+ try:
177
+ from .local_model import download_model
178
+ print("\n📥 Testing HuggingFace model download...")
179
+ local_path = download_model(repo_id=cfg.model_repo, token=cfg.hf_token)
180
+ print(f"✅ Model downloaded & cached at: {local_path}")
181
+ except Exception as e:
182
+ print(f"⚠️ Could not download model right now: {e}")
183
+ print(" Model will be downloaded automatically on first task execution.")
184
+
185
+ # Pre-install llama-cpp-python binary wheel (so offline mode works immediately)
186
+ try:
187
+ from .local_model import _ensure_llama_cpp
188
+ print("\n⚙️ Setting up offline GGUF engine...")
189
+ _ensure_llama_cpp()
190
+ print("✅ Offline mode ready — works without internet from now on!")
191
+ except Exception as e:
192
+ print(f"⚠️ Could not setup offline engine: {e}")
193
+ print(" It will be installed automatically when needed.")
194
+
195
+ return cfg