alpiecode 9.0.3__tar.gz → 9.0.6__tar.gz

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.
Files changed (51) hide show
  1. {alpiecode-9.0.3 → alpiecode-9.0.6}/PKG-INFO +1 -1
  2. {alpiecode-9.0.3 → alpiecode-9.0.6}/pyproject.toml +1 -1
  3. {alpiecode-9.0.3 → alpiecode-9.0.6}/src/alpiecode.egg-info/PKG-INFO +1 -1
  4. {alpiecode-9.0.3 → alpiecode-9.0.6}/src/alpiecode.egg-info/SOURCES.txt +4 -0
  5. {alpiecode-9.0.3 → alpiecode-9.0.6}/src/codeagent/__init__.py +1 -1
  6. {alpiecode-9.0.3 → alpiecode-9.0.6}/src/codeagent/agent.py +65 -10
  7. {alpiecode-9.0.3 → alpiecode-9.0.6}/src/codeagent/backends/local_backend.py +18 -1
  8. {alpiecode-9.0.3 → alpiecode-9.0.6}/src/codeagent/cli.py +16 -2
  9. {alpiecode-9.0.3 → alpiecode-9.0.6}/src/codeagent/client.py +1 -1
  10. {alpiecode-9.0.3 → alpiecode-9.0.6}/src/codeagent/config.py +18 -0
  11. {alpiecode-9.0.3 → alpiecode-9.0.6}/src/codeagent/discovery.py +69 -1
  12. {alpiecode-9.0.3 → alpiecode-9.0.6}/src/codeagent/executor.py +75 -5
  13. {alpiecode-9.0.3 → alpiecode-9.0.6}/src/codeagent/extension/alpiecode.vsix +0 -0
  14. {alpiecode-9.0.3 → alpiecode-9.0.6}/src/codeagent/local_model.py +44 -3
  15. {alpiecode-9.0.3 → alpiecode-9.0.6}/src/codeagent/orchestrator.py +65 -17
  16. {alpiecode-9.0.3 → alpiecode-9.0.6}/src/codeagent/progress.py +45 -0
  17. {alpiecode-9.0.3 → alpiecode-9.0.6}/src/codeagent/prompt.py +9 -3
  18. {alpiecode-9.0.3 → alpiecode-9.0.6}/src/codeagent/rephraser.py +4 -0
  19. {alpiecode-9.0.3 → alpiecode-9.0.6}/src/codeagent/server.py +7 -1
  20. {alpiecode-9.0.3 → alpiecode-9.0.6}/src/codeagent/tools.py +25 -9
  21. {alpiecode-9.0.3 → alpiecode-9.0.6}/src/codeagent/updater.py +8 -0
  22. alpiecode-9.0.6/test/test_audit_fixes.py +71 -0
  23. alpiecode-9.0.6/test/test_chat_mode.py +85 -0
  24. alpiecode-9.0.6/test/test_claude_codex_capabilities.py +111 -0
  25. alpiecode-9.0.6/test/test_offline_mode.py +78 -0
  26. {alpiecode-9.0.3 → alpiecode-9.0.6}/README.md +0 -0
  27. {alpiecode-9.0.3 → alpiecode-9.0.6}/setup.cfg +0 -0
  28. {alpiecode-9.0.3 → alpiecode-9.0.6}/src/alpiecode/__init__.py +0 -0
  29. {alpiecode-9.0.3 → alpiecode-9.0.6}/src/alpiecode.egg-info/dependency_links.txt +0 -0
  30. {alpiecode-9.0.3 → alpiecode-9.0.6}/src/alpiecode.egg-info/entry_points.txt +0 -0
  31. {alpiecode-9.0.3 → alpiecode-9.0.6}/src/alpiecode.egg-info/requires.txt +0 -0
  32. {alpiecode-9.0.3 → alpiecode-9.0.6}/src/alpiecode.egg-info/top_level.txt +0 -0
  33. {alpiecode-9.0.3 → alpiecode-9.0.6}/src/codeagent/backends/__init__.py +0 -0
  34. {alpiecode-9.0.3 → alpiecode-9.0.6}/src/codeagent/backends/base.py +0 -0
  35. {alpiecode-9.0.3 → alpiecode-9.0.6}/src/codeagent/backends/openai_backend.py +0 -0
  36. {alpiecode-9.0.3 → alpiecode-9.0.6}/src/codeagent/cache.py +0 -0
  37. {alpiecode-9.0.3 → alpiecode-9.0.6}/src/codeagent/compaction.py +0 -0
  38. {alpiecode-9.0.3 → alpiecode-9.0.6}/src/codeagent/context.py +0 -0
  39. {alpiecode-9.0.3 → alpiecode-9.0.6}/src/codeagent/doctor.py +0 -0
  40. {alpiecode-9.0.3 → alpiecode-9.0.6}/src/codeagent/git_ops.py +0 -0
  41. {alpiecode-9.0.3 → alpiecode-9.0.6}/src/codeagent/github.py +0 -0
  42. {alpiecode-9.0.3 → alpiecode-9.0.6}/src/codeagent/guardian.py +0 -0
  43. {alpiecode-9.0.3 → alpiecode-9.0.6}/src/codeagent/guardrails.py +0 -0
  44. {alpiecode-9.0.3 → alpiecode-9.0.6}/src/codeagent/ipython_ext.py +0 -0
  45. {alpiecode-9.0.3 → alpiecode-9.0.6}/src/codeagent/media.py +0 -0
  46. {alpiecode-9.0.3 → alpiecode-9.0.6}/src/codeagent/memory.py +0 -0
  47. {alpiecode-9.0.3 → alpiecode-9.0.6}/src/codeagent/session.py +0 -0
  48. {alpiecode-9.0.3 → alpiecode-9.0.6}/src/codeagent/vscode_installer.py +0 -0
  49. {alpiecode-9.0.3 → alpiecode-9.0.6}/test/test_phase1.py +0 -0
  50. {alpiecode-9.0.3 → alpiecode-9.0.6}/test/test_phase2.py +0 -0
  51. {alpiecode-9.0.3 → alpiecode-9.0.6}/test/test_phase3.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alpiecode
3
- Version: 9.0.3
3
+ Version: 9.0.6
4
4
  Summary: AlpieCode — Autonomous AI Coding Agent CLI powered by local 169Pi GGUF VLM
5
5
  Requires-Python: >=3.9
6
6
  Requires-Dist: huggingface_hub>=0.20.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "alpiecode"
7
- version = "9.0.3"
7
+ version = "9.0.6"
8
8
  description = "AlpieCode — Autonomous AI Coding Agent CLI powered by local 169Pi GGUF VLM"
9
9
  requires-python = ">=3.9"
10
10
  dependencies = [
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alpiecode
3
- Version: 9.0.3
3
+ Version: 9.0.6
4
4
  Summary: AlpieCode — Autonomous AI Coding Agent CLI powered by local 169Pi GGUF VLM
5
5
  Requires-Python: >=3.9
6
6
  Requires-Dist: huggingface_hub>=0.20.0
@@ -40,6 +40,10 @@ src/codeagent/backends/base.py
40
40
  src/codeagent/backends/local_backend.py
41
41
  src/codeagent/backends/openai_backend.py
42
42
  src/codeagent/extension/alpiecode.vsix
43
+ test/test_audit_fixes.py
44
+ test/test_chat_mode.py
45
+ test/test_claude_codex_capabilities.py
46
+ test/test_offline_mode.py
43
47
  test/test_phase1.py
44
48
  test/test_phase2.py
45
49
  test/test_phase3.py
@@ -16,7 +16,7 @@ Supports:
16
16
  from pathlib import Path
17
17
  from typing import Optional, Any, Dict
18
18
 
19
- __version__ = "9.0.3"
19
+ __version__ = "9.0.6"
20
20
 
21
21
  def run(task: str, workdir: str = ".", reasoning_level: str = "thinking", **kwargs):
22
22
  """Run an autonomous coding task programmatically."""
@@ -213,11 +213,15 @@ def run_agent(
213
213
  # CLI-as-Client check: auto-detect if local/remote server is running
214
214
  from .client import AlpieCodeClient
215
215
  target_server = server_url or getattr(cfg, "server_url", None)
216
- if not target_server:
217
- # Check default local server
218
- local_client = AlpieCodeClient("http://127.0.0.1:7169")
219
- if local_client.health().get("status") == "online":
220
- target_server = "http://127.0.0.1:7169"
216
+ if not target_server and cfg.base_url != "offline" and not getattr(cfg, "offline", False):
217
+ # Check default local server (online mode only)
218
+ try:
219
+ local_client = AlpieCodeClient("http://127.0.0.1:7169")
220
+ h = local_client.health()
221
+ if h.get("status") == "online" and h.get("mode") != "offline":
222
+ target_server = "http://127.0.0.1:7169"
223
+ except Exception:
224
+ pass
221
225
 
222
226
  if target_server:
223
227
  client = AlpieCodeClient(target_server)
@@ -234,7 +238,7 @@ def run_agent(
234
238
  backend = resolve_backend(cfg)
235
239
  orchestrator = AgentOrchestrator(backend)
236
240
  session_mgr = SessionManager()
237
- session = session_mgr.create_session(workdir, max_tokens=cfg.n_ctx if not backend.is_available else 262_144)
241
+ session = session_mgr.create_session(workdir, max_tokens=backend.context_window if hasattr(backend, 'context_window') and backend.context_window else (cfg.n_ctx if cfg.base_url == 'offline' else 262_144))
238
242
  event_stream = orchestrator.run_task(
239
243
  session=session,
240
244
  task=task,
@@ -467,7 +471,7 @@ def run_agent(
467
471
  return session.context.messages if "session" in locals() else []
468
472
 
469
473
 
470
- def run_chat(workdir: Path, cfg: Config, verbose: bool = True) -> None:
474
+ def run_chat(workdir: Path, cfg: Config, verbose: bool = True, debug: bool = False) -> None:
471
475
  """Run interactive chat session with Rich presentation."""
472
476
  workdir = workdir.resolve()
473
477
  _ensure_git(workdir)
@@ -475,7 +479,7 @@ def run_chat(workdir: Path, cfg: Config, verbose: bool = True) -> None:
475
479
  backend = resolve_backend(cfg)
476
480
  orchestrator = AgentOrchestrator(backend)
477
481
  session_mgr = SessionManager()
478
- session = session_mgr.create_session(workdir, max_tokens=cfg.n_ctx if not backend.is_available else 262_144)
482
+ session = session_mgr.create_session(workdir, max_tokens=backend.context_window if hasattr(backend, 'context_window') and backend.context_window else (cfg.n_ctx if cfg.base_url == 'offline' else 262_144))
479
483
 
480
484
  is_offline = isinstance(backend, LocalBackend) or not backend.is_available
481
485
  active_tools = PromptBuilder().get_tools(is_offline=is_offline)
@@ -513,7 +517,50 @@ def run_chat(workdir: Path, cfg: Config, verbose: bool = True) -> None:
513
517
  console.print("Goodbye! 👋")
514
518
  break
515
519
 
520
+ # In-chat slash commands
521
+ if user_input.lower() in ("/clear", "/reset"):
522
+ session = session_mgr.create_session(
523
+ workdir,
524
+ max_tokens=backend.context_window if hasattr(backend, 'context_window') and backend.context_window else (cfg.n_ctx if cfg.base_url == 'offline' else 262_144)
525
+ )
526
+ if HAS_RICH:
527
+ console.print("[bold green]✨ Conversation context reset.[/bold green]")
528
+ else:
529
+ print("✨ Conversation context reset.")
530
+ continue
531
+
532
+ if user_input.lower() in ("/info", "/status"):
533
+ mode_str = "OFFLINE (Local GGUF)" if is_offline else "ONLINE (Cloud API)"
534
+ ctx_tokens = backend.context_window if hasattr(backend, 'context_window') and backend.context_window else cfg.n_ctx
535
+ user_turns = len([m for m in session.context.messages if m.get("role") == "user"])
536
+ info_msg = (
537
+ f"[bold]AlpieCode Session Info[/bold]\n"
538
+ f" • Mode: {mode_str}\n"
539
+ f" • Working Dir: {workdir}\n"
540
+ f" • Context Window: {ctx_tokens} tokens\n"
541
+ f" • Session Turns: {user_turns}"
542
+ )
543
+ if HAS_RICH:
544
+ console.print(Panel(info_msg, border_style="cyan", title="ℹ️ Status"))
545
+ else:
546
+ print(info_msg)
547
+ continue
548
+
549
+ if user_input.lower() in ("/help", "/?"):
550
+ help_msg = (
551
+ "Available commands:\n"
552
+ " • /clear, /reset - Reset conversation memory\n"
553
+ " • /info, /status - View current model and session diagnostics\n"
554
+ " • exit, quit, q - Exit interactive chat"
555
+ )
556
+ if HAS_RICH:
557
+ console.print(Panel(help_msg, border_style="dim", title="Help"))
558
+ else:
559
+ print(help_msg)
560
+ continue
561
+
516
562
  has_streamed_chat = False
563
+ has_printed_chat = False
517
564
  for event in orchestrator.run_task(session, user_input, cfg):
518
565
  if event.type == "token":
519
566
  delta = event.data.get("delta", "")
@@ -521,6 +568,7 @@ def run_chat(workdir: Path, cfg: Config, verbose: bool = True) -> None:
521
568
  sys.stdout.write(delta)
522
569
  sys.stdout.flush()
523
570
  has_streamed_chat = True
571
+ has_printed_chat = True
524
572
 
525
573
  elif event.type == "turn_start" and debug:
526
574
  if HAS_RICH:
@@ -532,7 +580,12 @@ def run_chat(workdir: Path, cfg: Config, verbose: bool = True) -> None:
532
580
  _print_reasoning(event.data["content"])
533
581
 
534
582
  elif event.type == "tool_call" and verbose:
583
+ if has_streamed_chat:
584
+ sys.stdout.write("\n")
585
+ sys.stdout.flush()
586
+ has_streamed_chat = False
535
587
  _print_tool_call(event.data["turn"], event.data["name"], event.data["arguments"])
588
+ has_printed_chat = True
536
589
 
537
590
  elif event.type == "tool_result":
538
591
  if verbose:
@@ -543,8 +596,9 @@ def run_chat(workdir: Path, cfg: Config, verbose: bool = True) -> None:
543
596
  if has_streamed_chat:
544
597
  sys.stdout.write("\n")
545
598
  has_streamed_chat = False
546
- else:
599
+ elif not has_printed_chat:
547
600
  _print_assistant_message(event.data["content"])
601
+ has_printed_chat = True
548
602
  _checkpoint(workdir, "checkpoint: done")
549
603
 
550
604
  elif event.type == "error":
@@ -557,6 +611,7 @@ def run_chat(workdir: Path, cfg: Config, verbose: bool = True) -> None:
557
611
  if has_streamed_chat:
558
612
  sys.stdout.write("\n")
559
613
  has_streamed_chat = False
560
- elif event.data.get("summary"):
614
+ elif not has_printed_chat and event.data.get("summary"):
561
615
  _print_assistant_message(event.data["summary"])
616
+ has_printed_chat = True
562
617
  break
@@ -55,8 +55,25 @@ class LocalBackend:
55
55
  sanitized = []
56
56
  for msg in messages:
57
57
  role = msg.get("role", "")
58
- # Skip tool result messages entirely — GGUF models don't understand them
59
58
  if role == "tool":
59
+ # Bridge tool execution results into ChatML user turns so local model sees outputs
60
+ name = msg.get("name") or "tool"
61
+ raw_content = msg.get("content") or ""
62
+ if not isinstance(raw_content, str):
63
+ raw_content = str(raw_content)
64
+ # Smart truncate oversized terminal logs to conserve context
65
+ if len(raw_content) > 1500:
66
+ lines = raw_content.splitlines()
67
+ if len(lines) > 12:
68
+ raw_content = f"[Output: {len(lines)} lines]\n" + "\n".join(lines[:6]) + "\n...\n" + "\n".join(lines[-4:])
69
+ else:
70
+ raw_content = raw_content[:1500] + "\n... [truncated]"
71
+
72
+ tool_text = f"[Tool Result for {name}]:\n{raw_content}"
73
+ if sanitized and sanitized[-1].get("role") == "user":
74
+ sanitized[-1]["content"] += "\n\n" + tool_text
75
+ else:
76
+ sanitized.append({"role": "user", "content": tool_text})
60
77
  continue
61
78
 
62
79
  clean = {"role": role}
@@ -41,6 +41,10 @@ def _normalize_args():
41
41
  if not subcommand and len(sys.argv) > 1:
42
42
  subcommand = "run"
43
43
  sys.argv.insert(1, "run")
44
+ elif subcommand and subcommand not in ("-h", "--help", "-v", "--version") and sys.argv[1] != subcommand:
45
+ # Move subcommand to index 1 so subparsers parse arguments correctly without default override
46
+ sys.argv.remove(subcommand)
47
+ sys.argv.insert(1, subcommand)
44
48
 
45
49
  if subcommand in ("run", "plan", "explain") and len(sys.argv) > 2:
46
50
  cmd_idx = sys.argv.index(subcommand)
@@ -82,6 +86,7 @@ def main():
82
86
  common.add_argument("--no-update", action="store_true", help="Skip automatic update check")
83
87
  common.add_argument("--quiet", action="store_true", help="Suppress per-turn logging")
84
88
  common.add_argument("--debug", action="store_true", help="Show autonomous discovery and debug diagnostics")
89
+ common.add_argument("--offline", "--local", dest="offline", action="store_true", help="Run using local offline GGUF model on GPU without internet")
85
90
 
86
91
  from . import __version__
87
92
  parser = argparse.ArgumentParser(
@@ -145,13 +150,16 @@ def main():
145
150
 
146
151
  if args.command == "serve":
147
152
  _show_banner()
153
+ if getattr(args, "offline", False):
154
+ import os
155
+ os.environ["ALPIECODE_BASE_URL"] = "offline"
148
156
  try:
149
157
  from .vscode_installer import ensure_vscode_extension
150
158
  ensure_vscode_extension()
151
159
  except Exception as e:
152
160
  pass
153
161
  from .server import run_server
154
- run_server(host=args.host, port=args.port)
162
+ run_server(host=args.host, port=args.port, offline=getattr(args, "offline", False))
155
163
  return
156
164
 
157
165
  # Check for auto-updates from GitHub in background
@@ -164,6 +172,12 @@ def main():
164
172
 
165
173
  cfg = load_config()
166
174
 
175
+ if getattr(args, "offline", False):
176
+ cfg.base_url = "offline"
177
+ cfg.n_ctx = 16384
178
+ import os
179
+ os.environ["ALPIECODE_BASE_URL"] = "offline"
180
+
167
181
  if getattr(args, "thinking", False):
168
182
  cfg.enable_thinking = True
169
183
  elif getattr(args, "no_thinking", False):
@@ -188,7 +202,7 @@ def main():
188
202
  cfg.max_turns = args.max_turns
189
203
  _show_banner()
190
204
  from .agent import run_chat
191
- run_chat(Path(args.workdir), cfg, verbose=not args.quiet)
205
+ run_chat(Path(args.workdir), cfg, verbose=not args.quiet, debug=getattr(args, 'debug', False))
192
206
 
193
207
  elif args.command == "plan":
194
208
  plan_task = (
@@ -11,7 +11,7 @@ import urllib.parse
11
11
  import urllib.request
12
12
  from dataclasses import dataclass
13
13
  from pathlib import Path
14
- from typing import Any, Dict, Iterator, Optional
14
+ from typing import Any, Dict, Iterator, List, Optional
15
15
 
16
16
  from .orchestrator import AgentEvent
17
17
 
@@ -133,6 +133,16 @@ def load_config() -> Config:
133
133
  data["api_key"] = os.environ[f"{prefix}API_KEY"]
134
134
  if os.environ.get("HF_TOKEN"):
135
135
  data["hf_token"] = os.environ["HF_TOKEN"]
136
+
137
+ # Context size overrides from environment
138
+ for ctx_var in ("ALPIECODE_N_CTX", "ALPIECODE_CTX_SIZE", "ALPIECODE_CONTEXT_SIZE", "CODEAGENT_N_CTX"):
139
+ if os.environ.get(ctx_var):
140
+ try:
141
+ data["n_ctx"] = int(os.environ[ctx_var])
142
+ break
143
+ except ValueError:
144
+ pass
145
+
136
146
  if os.environ.get("ALPIECODE_CPU") == "1":
137
147
  data["n_gpu_layers"] = 0
138
148
  elif os.environ.get("ALPIECODE_GPU_LAYERS"):
@@ -141,6 +151,14 @@ def load_config() -> Config:
141
151
  except ValueError:
142
152
  pass
143
153
 
154
+ # For offline mode, set high-performance 16k context (16384 tokens) for big multi-file projects
155
+ # Fits completely in 6GB VRAM on RTX 3050 with zero paging
156
+ if data.get("base_url") == "offline" and not any(os.environ.get(v) for v in ("ALPIECODE_N_CTX", "ALPIECODE_CTX_SIZE")):
157
+ if data.get("n_ctx") in (32768, 4096):
158
+ data["n_ctx"] = 16384
159
+ if data.get("max_tokens", 8192) > 4096:
160
+ data["max_tokens"] = 4096
161
+
144
162
  # Filter data to only keys present in Config fields
145
163
  valid_keys = {f.name for f in fields(Config)}
146
164
  filtered_data = {k: v for k, v in data.items() if k in valid_keys}
@@ -19,7 +19,7 @@ import subprocess
19
19
  import sys
20
20
  from dataclasses import dataclass, field
21
21
  from pathlib import Path
22
- from typing import List, Optional, Tuple
22
+ from typing import Dict, List, Optional, Tuple
23
23
 
24
24
 
25
25
  # -- Task Context ----------------------------------------------------------
@@ -47,6 +47,7 @@ class TaskContext:
47
47
  has_tests: bool = False
48
48
  entry_points: List[str] = field(default_factory=list)
49
49
  dependencies: List[str] = field(default_factory=list)
50
+ symbol_index: Dict[str, List[str]] = field(default_factory=dict)
50
51
 
51
52
  # Budget (computed from complexity)
52
53
  max_tokens: int = 8192
@@ -558,6 +559,7 @@ def gather_relevant_context(
558
559
  if getattr(task_context, "intent", "") in ("qa",):
559
560
  return None
560
561
 
562
+ task_lower = task.lower().strip()
561
563
  sections: List[str] = []
562
564
  chars_used = 0
563
565
 
@@ -599,6 +601,22 @@ def gather_relevant_context(
599
601
  for ep in task_context.entry_points[:3]:
600
602
  _add_file(ep, f"{ep} (entry point)")
601
603
 
604
+
605
+ # -- Priority 2.5: Matching Codebase Symbols (quick navigation) --
606
+ if getattr(task_context, "symbol_index", None):
607
+ matched_syms = []
608
+ task_terms = [t for t in task_lower.split() if len(t) > 3]
609
+ for fpath, syms in task_context.symbol_index.items():
610
+ rel_lower = fpath.lower()
611
+ syms_str = " ".join(syms).lower()
612
+ if any(term in rel_lower or term in syms_str for term in task_terms) or len(matched_syms) < 3:
613
+ matched_syms.append(f"- `{fpath}`: {', '.join(syms[:4])}")
614
+ if len(matched_syms) >= 6:
615
+ break
616
+ if matched_syms:
617
+ sym_section = "### Codebase Symbol Map (Quick Navigation)\n" + "\n".join(matched_syms)
618
+ sections.append(sym_section)
619
+
602
620
  # -- Priority 3: Files mentioned in the task (fuzzy match) --
603
621
  task_lower = task.lower()
604
622
  try:
@@ -629,6 +647,48 @@ def gather_relevant_context(
629
647
 
630
648
  # -- Main Entry Point -------------------------------------------------------
631
649
 
650
+
651
+ def build_symbol_index(workdir: Path, max_files: int = 40) -> Dict[str, List[str]]:
652
+ """Fast AST-based code symbol indexer (<50ms).
653
+
654
+ Extracts classes, methods, and functions across Python files.
655
+ """
656
+ symbols: Dict[str, List[str]] = {}
657
+ count = 0
658
+ import ast
659
+
660
+ skip_dirs = {".git", "__pycache__", "node_modules", ".venv", "venv", "dist", "build", ".egg-info"}
661
+
662
+ try:
663
+ for path in workdir.rglob("*.py"):
664
+ if any(part in skip_dirs or part.endswith(".egg-info") for part in path.parts):
665
+ continue
666
+ try:
667
+ rel_str = str(path.relative_to(workdir))
668
+ tree = ast.parse(path.read_text(encoding="utf-8", errors="replace"))
669
+ file_syms = []
670
+ for node in tree.body:
671
+ if isinstance(node, ast.FunctionDef):
672
+ args = [a.arg for a in node.args.args if a.arg != "self"]
673
+ sig = f"def {node.name}({', '.join(args[:3])})"
674
+ file_syms.append(sig)
675
+ elif isinstance(node, ast.ClassDef):
676
+ methods = [n.name for n in node.body if isinstance(n, ast.FunctionDef) and not n.name.startswith("__")]
677
+ method_str = f" [{', '.join(methods[:3])}]" if methods else ""
678
+ file_syms.append(f"class {node.name}{method_str}")
679
+ if file_syms:
680
+ symbols[rel_str] = file_syms[:8]
681
+ count += 1
682
+ if count >= max_files:
683
+ break
684
+ except Exception:
685
+ continue
686
+ except Exception:
687
+ pass
688
+
689
+ return symbols
690
+
691
+
632
692
  def build_task_context(task: str, workdir: Path) -> TaskContext:
633
693
  """Build complete TaskContext by running all discovery phases.
634
694
 
@@ -650,7 +710,15 @@ def build_task_context(task: str, workdir: Path) -> TaskContext:
650
710
  # Phase 5: Budget from complexity
651
711
  budget = COMPLEXITY_CONFIG[complexity]
652
712
 
713
+ symbol_index = {}
714
+ if repo.get("file_count", 0) > 0 and repo.get("project_type") != "empty":
715
+ try:
716
+ symbol_index = build_symbol_index(workdir)
717
+ except Exception:
718
+ pass
719
+
653
720
  return TaskContext(
721
+ symbol_index=symbol_index,
654
722
  # Intent
655
723
  intent=intent,
656
724
  complexity=complexity,
@@ -39,11 +39,15 @@ def parse_text_tool_calls(text: str) -> List[dict]:
39
39
 
40
40
  tool_calls = []
41
41
 
42
- # 1. Try parsing JSON blocks inside <tool_call> or ```json
43
- json_matches = re.findall(r"(?:<tool_call>|```json)\s*(\{.*?\})\s*(?:</tool_call>|```|$)", text, re.DOTALL)
44
- for jm in json_matches:
42
+ # 1. Try parsing blocks inside <tool_call> or ```json using robust JSON repair
43
+ from .backends.openai_backend import _repair_and_parse_json_arguments
44
+ blocks = re.findall(r"(?:<tool_call>|```json)\s*(.*?)\s*(?:</tool_call>|```|$)", text, re.DOTALL)
45
+ for block in blocks:
46
+ raw = block.strip()
47
+ if not raw:
48
+ continue
45
49
  try:
46
- data = json.loads(jm.strip())
50
+ data = _repair_and_parse_json_arguments(raw)
47
51
  if isinstance(data, dict) and "name" in data:
48
52
  tool_calls.append({
49
53
  "name": data["name"],
@@ -146,6 +150,42 @@ def _parse_error_hint(stderr: str, stdout: str) -> str:
146
150
  return "\n".join(hints)
147
151
 
148
152
 
153
+
154
+ def suggest_similar_file(workdir: Path, target_path: str) -> Optional[str]:
155
+ """Find the closest existing file path in the workspace for typos/misspellings."""
156
+ import difflib
157
+ target_name = Path(target_path).name.lower()
158
+ candidates = []
159
+ skip_dirs = {".git", "__pycache__", "node_modules", ".venv", "venv"}
160
+ try:
161
+ for p in workdir.rglob("*"):
162
+ if p.is_file() and not any(part in skip_dirs for part in p.parts):
163
+ candidates.append(str(p.relative_to(workdir)))
164
+ except Exception:
165
+ return None
166
+
167
+ if not candidates:
168
+ return None
169
+
170
+ # 1. Exact filename match in another directory
171
+ for c in candidates:
172
+ if Path(c).name.lower() == target_name:
173
+ return c
174
+
175
+ # 2. Fuzzy match on relative path
176
+ matches = difflib.get_close_matches(target_path, candidates, n=1, cutoff=0.55)
177
+ if matches:
178
+ return matches[0]
179
+
180
+ # 3. Fuzzy match on filename
181
+ file_matches = difflib.get_close_matches(target_name, [Path(c).name.lower() for c in candidates], n=1, cutoff=0.55)
182
+ if file_matches:
183
+ for c in candidates:
184
+ if Path(c).name.lower() == file_matches[0]:
185
+ return c
186
+ return None
187
+
188
+
149
189
  class ToolExecutor:
150
190
  """Executes tool calls for a workspace."""
151
191
 
@@ -154,13 +194,14 @@ class ToolExecutor:
154
194
  self.dispatch = make_dispatch(workdir)
155
195
  self.tool_call_history: List[Tuple[str, str]] = []
156
196
  self.compile_fail_counts: Dict[str, int] = {}
197
+ self.successful_bash_commands: set = set()
157
198
 
158
199
  def extract_tool_calls(self, response: ChatResponse) -> List[ToolCall]:
159
200
  """Extract tool calls from response (structured or text format)."""
160
201
  if response.tool_calls:
161
202
  return response.tool_calls
162
203
 
163
- if response.content and "<tool_call>" in response.content:
204
+ if response.content:
164
205
  parsed = parse_text_tool_calls(response.content)
165
206
  return [
166
207
  ToolCall(
@@ -215,6 +256,12 @@ class ToolExecutor:
215
256
  res_str = f"error: {e}"
216
257
  if on_tool_end:
217
258
  on_tool_end(tc.name, res_str)
259
+ if "file not found" in res_str.lower() and isinstance(tc.arguments, dict):
260
+ req_path = tc.arguments.get("path") or tc.arguments.get("file", "")
261
+ if req_path:
262
+ sugg = suggest_similar_file(self.workdir, req_path)
263
+ if sugg and sugg != req_path:
264
+ res_str += f"\n💡 HINT: Did you mean '{sugg}'? Use that path instead."
218
265
  results_map[tc.id] = ToolResult(
219
266
  tool_call_id=tc.id, name=tc.name, content=res_str, duration_ms=elapsed
220
267
  )
@@ -270,6 +317,12 @@ class ToolExecutor:
270
317
  res_str = f"error: {e}"
271
318
  if on_tool_end:
272
319
  on_tool_end(tc.name, res_str)
320
+ if "file not found" in res_str.lower() and isinstance(tc.arguments, dict):
321
+ req_path = tc.arguments.get("path") or tc.arguments.get("file", "")
322
+ if req_path:
323
+ sugg = suggest_similar_file(self.workdir, req_path)
324
+ if sugg and sugg != req_path:
325
+ res_str += f"\n💡 HINT: Did you mean '{sugg}'? Use that path instead."
273
326
  results_map[tc.id] = ToolResult(
274
327
  tool_call_id=tc.id, name=tc.name, content=res_str, duration_ms=elapsed
275
328
  )
@@ -290,6 +343,10 @@ class ToolExecutor:
290
343
  res_str = f"error: Unknown tool '{tc.name}'"
291
344
  elapsed = (time.monotonic() - t0) * 1000
292
345
 
346
+ # Clear passed verification set whenever code files are written or edited
347
+ if tc.name in ("write_file", "edit_file", "apply_patch"):
348
+ self.successful_bash_commands.clear()
349
+
293
350
  # Tool loop detection guard
294
351
  call_sig = (tc.name, json.dumps(tc.arguments, sort_keys=True))
295
352
  self.tool_call_history.append(call_sig)
@@ -302,6 +359,19 @@ class ToolExecutor:
302
359
  "All checks have passed. Do NOT run this tool again. Output your final summary starting with: DONE: <summary>."
303
360
  )
304
361
 
362
+ # Repetitive verification sequence guard
363
+ if tc.name == "bash":
364
+ cmd_str = safe_args.get("command", "").strip()
365
+ if cmd_str and cmd_str in self.successful_bash_commands:
366
+ res_str += (
367
+ "\n\n⚠️ REPETITIVE VERIFICATION NOTICE: This exact command has already run and passed with exit code 0, "
368
+ "and no files have been modified since. Re-running identical tests is unnecessary. "
369
+ "All checks have already passed! Finalize the task now with: DONE: <summary>."
370
+ )
371
+ elif '"exit_code": 0' in res_str:
372
+ if cmd_str:
373
+ self.successful_bash_commands.add(cmd_str)
374
+
305
375
  # Smart error hint injection for failed bash commands
306
376
  if tc.name == "bash" and '"exit_code"' in res_str:
307
377
  try:
@@ -68,7 +68,8 @@ def detect_gpu() -> int:
68
68
  if res.returncode == 0 and res.stdout.strip():
69
69
  vram_mb = int(res.stdout.strip().split("\n")[0].strip())
70
70
  if vram_mb <= 6500: # 6GB VRAM (e.g. RTX 3050 Laptop)
71
- return 26 # Partial offload — keep 2GB VRAM free
71
+ # Full offload: 4.4GB model + 4096 context fits in 6GB VRAM and runs 5x faster than partial offload
72
+ return 33
72
73
  return -1 # 8GB+ VRAM — offload all layers
73
74
  except Exception:
74
75
  pass
@@ -303,7 +304,19 @@ class LocalModel:
303
304
  model_path = self.ensure_model()
304
305
  Llama = _ensure_llama_cpp()
305
306
 
306
- accel = "GPU" if self.n_gpu_layers != 0 else "CPU"
307
+ has_gpu_offload = False
308
+ try:
309
+ import llama_cpp
310
+ has_gpu_offload = getattr(llama_cpp, "llama_supports_gpu_offload", lambda: False)()
311
+ except Exception:
312
+ pass
313
+
314
+ if self.n_gpu_layers != 0 and not has_gpu_offload:
315
+ print(" ⚠️ Warning: Active Python environment has no CUDA llama-cpp support. Running on CPU.")
316
+ accel = "CPU (No CUDA in Python env)"
317
+ self.n_gpu_layers = 0
318
+ else:
319
+ accel = "GPU" if self.n_gpu_layers != 0 else "CPU"
307
320
 
308
321
  # ── Smart P-Core Topology Auto-Tuning ─────────────────────────
309
322
  # On hybrid CPUs (Intel 12th+), over-subscribing E-cores causes
@@ -401,6 +414,17 @@ class LocalModel:
401
414
  else:
402
415
  msgs = messages
403
416
 
417
+ # Context headroom safety guard: dynamically clamp max_tokens if prompt is large
418
+ n_ctx_limit = getattr(self, "n_ctx", 16384)
419
+ try:
420
+ total_chars = sum(len(str(m.get("content", ""))) for m in msgs)
421
+ est_prompt_tokens = int(total_chars / 3.0) + (len(tools or []) * 80) + 100
422
+ if est_prompt_tokens + max_tokens > n_ctx_limit:
423
+ available_tokens = max(128, n_ctx_limit - est_prompt_tokens - 32)
424
+ max_tokens = min(max_tokens, available_tokens)
425
+ except Exception:
426
+ pass
427
+
404
428
  params = {
405
429
  "messages": msgs,
406
430
  "temperature": 0.1 if not enable_thinking else temperature,
@@ -410,7 +434,24 @@ class LocalModel:
410
434
  params["tools"] = tools
411
435
  params["tool_choice"] = kwargs.get("tool_choice", "auto")
412
436
 
413
- result_dict = llm.create_chat_completion(**params)
437
+ try:
438
+ result_dict = llm.create_chat_completion(**params)
439
+ except ValueError as ve:
440
+ err_msg = str(ve)
441
+ if "exceed context window" in err_msg.lower():
442
+ # Parse: "Requested tokens (X) exceed context window of Y"
443
+ m = re.search(r"Requested tokens \((\d+)\) exceed context window of (\d+)", err_msg)
444
+ if m:
445
+ requested = int(m.group(1))
446
+ win = int(m.group(2))
447
+ excess = requested - win
448
+ new_max = max(128, params["max_tokens"] - excess - 32)
449
+ params["max_tokens"] = new_max
450
+ result_dict = llm.create_chat_completion(**params)
451
+ else:
452
+ raise
453
+ else:
454
+ raise
414
455
 
415
456
  # Fix prefill response content so chat history remains valid across multi-turn sessions
416
457
  if not enable_thinking and result_dict.get("choices"):