ignite-agent 0.0.0-stage → 1.0.0

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.
@@ -0,0 +1,4119 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ ╔══════════════════════════════════════════════════════════════════════════╗
4
+ ║ 🔥 IGNITE V3 PRO — Autonomous AI DevOps & Engineering Agent ║
5
+ ║ Multi-Model · Streaming · 10-Tool · Episodic Memory ║
6
+ ╚══════════════════════════════════════════════════════════════════════════╝
7
+ """
8
+
9
+ # ─── Standard Library ─────────────────────────────────────────────────────────
10
+ import os, sys, re, json, time, ast, shutil, subprocess, textwrap, hashlib, sqlite3, math
11
+ import threading, queue, difflib, traceback, signal, urllib.request, urllib.parse
12
+ from datetime import datetime
13
+ from typing import Any, Dict, List, Optional, Tuple, Generator
14
+ from pathlib import Path
15
+ from dataclasses import dataclass, field, asdict
16
+
17
+ # ─── Windows ANSI & UTF-8 Init ────────────────────────────────────────────────
18
+ if sys.platform == "win32":
19
+ os.system("")
20
+ try:
21
+ if hasattr(sys.stdout, "reconfigure"):
22
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
23
+ if hasattr(sys.stderr, "reconfigure"):
24
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace")
25
+ except Exception:
26
+ pass
27
+
28
+ # ─── Optional Rich UI (gracefully degrades if not installed) ──────────────────
29
+ try:
30
+ from rich.console import Console
31
+ from rich.panel import Panel
32
+ from rich.text import Text
33
+ from rich.live import Live
34
+ from rich.spinner import Spinner
35
+ from rich.table import Table
36
+ from rich.syntax import Syntax
37
+ from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn
38
+ from rich.markdown import Markdown
39
+ from rich import box
40
+ RICH = True
41
+ console = Console(highlight=False)
42
+ except ImportError:
43
+ RICH = False
44
+ class Console:
45
+ def print(self, *a, **kw): print(*a)
46
+ def rule(self, *a, **kw): print("─" * 60)
47
+ console = Console()
48
+
49
+ # ─── Optional TOML config ─────────────────────────────────────────────────────
50
+ try:
51
+ import tomllib
52
+ except ImportError:
53
+ try:
54
+ import tomli as tomllib
55
+ except ImportError:
56
+ tomllib = None
57
+
58
+ # ─── Optional OpenAI SDK ──────────────────────────────────────────────────────
59
+ try:
60
+ from openai import OpenAI
61
+ OPENAI_AVAILABLE = True
62
+ except ImportError:
63
+ OPENAI_AVAILABLE = False
64
+ print("⚠ openai package not found. Run: pip install openai")
65
+ sys.exit(1)
66
+
67
+
68
+ # ══════════════════════════════════════════════════════════════════════════════
69
+ # SECTION 1 — STYLE / THEME
70
+ # ══════════════════════════════════════════════════════════════════════════════
71
+ class C:
72
+ """ANSI colour tokens (fallback when Rich not available)."""
73
+ RST = "\033[0m"; BOLD = "\033[1m"; DIM = "\033[2m"
74
+ CYAN = "\033[96m"; CYN = "\033[96m"; YEL = "\033[93m"; GRN = "\033[92m"
75
+ RED = "\033[91m"; GRY = "\033[90m"; MAG = "\033[95m"
76
+ BGRN = "\033[1;92m";BYEL = "\033[1;33m";BRED = "\033[1;91m"
77
+ BCYN = "\033[1;96m";BLU = "\033[94m"; BBLU = "\033[1;94m"
78
+
79
+ BANNER = rf"""
80
+ {C.BRED}
81
+ ██╗ ██████╗ ███╗ ██╗██╗████████╗███████╗ ██╗ ██╗██████╗ ██████╗ ██████╗ ██████╗
82
+ ██║██╔════╝ ████╗ ██║██║╚══██╔══╝██╔════╝ ██║ ██║╚════██╗ ██╔══██╗██╔══██╗██╔═══██╗
83
+ ██║██║ ███╗██╔██╗ ██║██║ ██║ █████╗ ██║ ██║ █████╔╝ ██████╔╝██████╔╝██║ ██║
84
+ ██║██║ ██║██║╚██╗██║██║ ██║ ██╔══╝ ╚██╗ ██╔╝ ╚═══██╗ ██╔═══╝ ██╔══██╗██║ ██║
85
+ ██║╚██████╔╝██║ ╚████║██║ ██║ ███████╗ ╚████╔╝ ██████╔╝ ██║ ██║ ██║╚██████╔╝
86
+ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝╚═╝ ╚═╝ ╚══════╝ ╚═══╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝
87
+ {C.RST}{C.YEL} ⚡ Autonomous AI DevOps & Engineering Agent ⚡{C.RST}
88
+ {C.GRY} ▸ Multi-Model ▸ Streaming ▸ 10 Tools ▸ Episodic Memory{C.RST}
89
+ """
90
+
91
+ # ══════════════════════════════════════════════════════════════════════════════
92
+ # SECTION 2 — CONFIGURATION SYSTEM
93
+ # ══════════════════════════════════════════════════════════════════════════════
94
+ CONFIG_PATH = Path.home() / ".ignite.toml"
95
+ DEFAULT_CONFIG = {
96
+ "max_iterations": 20,
97
+ "max_history_messages": 40,
98
+ "output_truncate_lines": 60,
99
+ "session_dir": str(Path.home() / ".ignite_sessions"),
100
+ "search_results": 6,
101
+ "stream_responses": True,
102
+ "show_token_stats": True,
103
+ "temperature": 0.1,
104
+ }
105
+
106
+ def load_config() -> Dict:
107
+ cfg = DEFAULT_CONFIG.copy()
108
+ if tomllib and CONFIG_PATH.exists():
109
+ try:
110
+ with open(CONFIG_PATH, "rb") as f:
111
+ cfg.update(tomllib.load(f))
112
+ except Exception:
113
+ pass
114
+ return cfg
115
+
116
+ CFG = load_config()
117
+
118
+ # ══════════════════════════════════════════════════════════════════════════════
119
+ # SECTION 3 — LLM PROVIDER REGISTRY (priority-scored + latency tracked)
120
+ # ══════════════════════════════════════════════════════════════════════════════
121
+ PROVIDER_DEFS = [
122
+ # ── Gemini Primary (OpenAI-compat) ─────────────────────────────────────────
123
+ {
124
+ "id": "gemini_38_flash",
125
+ "name": "Gemini 3.8 Flash",
126
+ "env_key": "GEMINI_API_KEY",
127
+ "base_url": "https://generativelanguage.googleapis.com/v1beta/openai/",
128
+ "model": "gemini-3.8-flash",
129
+ "priority": 1,
130
+ "supports_stream": True,
131
+ },
132
+ {
133
+ "id": "gemini_35_flash",
134
+ "name": "Gemini 3.5 Flash",
135
+ "env_key": "GEMINI_API_KEY",
136
+ "base_url": "https://generativelanguage.googleapis.com/v1beta/openai/",
137
+ "model": "gemini-3.5-flash",
138
+ "priority": 2,
139
+ "supports_stream": True,
140
+ },
141
+ {
142
+ "id": "gemini_35_flash_lite",
143
+ "name": "Gemini 3.5 Flash Lite",
144
+ "env_key": "GEMINI_API_KEY",
145
+ "base_url": "https://generativelanguage.googleapis.com/v1beta/openai/",
146
+ "model": "gemini-3.5-flash-lite",
147
+ "priority": 3,
148
+ "supports_stream": True,
149
+ },
150
+ # ── Groq Primary (Confirmed live on /v1/models) ───────────────────────────
151
+ {
152
+ "id": "groq_gpt_oss_120b",
153
+ "name": "Groq (GPT-OSS 120B)",
154
+ "env_key": "GROQ_API_KEY",
155
+ "base_url": "https://api.groq.com/openai/v1",
156
+ "model": "openai/gpt-oss-120b",
157
+ "priority": 4,
158
+ "supports_stream": True,
159
+ },
160
+ {
161
+ "id": "groq_qwen38_27b",
162
+ "name": "Groq (Qwen3.8 27B)",
163
+ "env_key": "GROQ_API_KEY",
164
+ "base_url": "https://api.groq.com/openai/v1",
165
+ "model": "qwen/qwen3.8-27b",
166
+ "priority": 5,
167
+ "supports_stream": True,
168
+ },
169
+ {
170
+ "id": "groq_gpt_oss_20b",
171
+ "name": "Groq (GPT-OSS 20B)",
172
+ "env_key": "GROQ_API_KEY",
173
+ "base_url": "https://api.groq.com/openai/v1",
174
+ "model": "openai/gpt-oss-20b",
175
+ "priority": 6,
176
+ "supports_stream": True,
177
+ },
178
+ # ── Hugging Face Router (100% Free Token, No Credit Card, 130+ Models) ───
179
+ {
180
+ "id": "hf_llama33_70b",
181
+ "name": "Hugging Face (Llama 3.3 70B)",
182
+ "env_key": "HF_TOKEN",
183
+ "base_url": "https://router.huggingface.co/v1",
184
+ "model": "meta-llama/Llama-3.3-70B-Instruct",
185
+ "priority": 7,
186
+ "supports_stream": True,
187
+ },
188
+ {
189
+ "id": "hf_qwen25_coder_32b",
190
+ "name": "Hugging Face (Qwen 2.5 Coder 32B)",
191
+ "env_key": "HF_TOKEN",
192
+ "base_url": "https://router.huggingface.co/v1",
193
+ "model": "Qwen/Qwen2.5-Coder-32B-Instruct",
194
+ "priority": 8,
195
+ "supports_stream": True,
196
+ },
197
+ # ── Gemini Secondary Account 2 (Free 1500 RPD) ───────────────────────────
198
+ {
199
+ "id": "gemini_38_flash_backup",
200
+ "name": "Gemini 3.8 Flash (Acc 2)",
201
+ "env_key": "GEMINI_API_KEY_2",
202
+ "base_url": "https://generativelanguage.googleapis.com/v1beta/openai/",
203
+ "model": "gemini-3.8-flash",
204
+ "priority": 9,
205
+ "supports_stream": True,
206
+ },
207
+ {
208
+ "id": "gemini_35_flash_backup",
209
+ "name": "Gemini 3.5 Flash (Acc 2)",
210
+ "env_key": "GEMINI_API_KEY_2",
211
+ "base_url": "https://generativelanguage.googleapis.com/v1beta/openai/",
212
+ "model": "gemini-3.5-flash",
213
+ "priority": 10,
214
+ "supports_stream": True,
215
+ },
216
+ # ── Gemini Secondary Account 3 (Free 1500 RPD) ───────────────────────────
217
+ {
218
+ "id": "gemini_38_flash_backup3",
219
+ "name": "Gemini 3.8 Flash (Acc 3)",
220
+ "env_key": "GEMINI_API_KEY_3",
221
+ "base_url": "https://generativelanguage.googleapis.com/v1beta/openai/",
222
+ "model": "gemini-3.8-flash",
223
+ "priority": 11,
224
+ "supports_stream": True,
225
+ },
226
+ {
227
+ "id": "gemini_35_flash_backup3",
228
+ "name": "Gemini 3.5 Flash (Acc 3)",
229
+ "env_key": "GEMINI_API_KEY_3",
230
+ "base_url": "https://generativelanguage.googleapis.com/v1beta/openai/",
231
+ "model": "gemini-3.5-flash",
232
+ "priority": 12,
233
+ "supports_stream": True,
234
+ },
235
+ # ── Groq Secondary Account 2 (Free Rate Pool) ────────────────────────────
236
+ {
237
+ "id": "groq_gpt_oss_120b_backup",
238
+ "name": "Groq (GPT-OSS 120B Acc 2)",
239
+ "env_key": "GROQ_API_KEY_2",
240
+ "base_url": "https://api.groq.com/openai/v1",
241
+ "model": "openai/gpt-oss-120b",
242
+ "priority": 13,
243
+ "supports_stream": True,
244
+ },
245
+ {
246
+ "id": "groq_qwen38_27b_backup",
247
+ "name": "Groq (Qwen3.8 27B Acc 2)",
248
+ "env_key": "GROQ_API_KEY_2",
249
+ "base_url": "https://api.groq.com/openai/v1",
250
+ "model": "qwen/qwen3.8-27b",
251
+ "priority": 14,
252
+ "supports_stream": True,
253
+ },
254
+ # ── Mistral AI (Official Free Coding & Chat Models) ────────────────────────
255
+ {
256
+ "id": "mistral_codestral",
257
+ "name": "Mistral (Codestral Latest)",
258
+ "env_key": "MISTRAL_API_KEY",
259
+ "base_url": "https://api.mistral.ai/v1",
260
+ "model": "codestral-latest",
261
+ "priority": 15,
262
+ "supports_stream": True,
263
+ },
264
+ {
265
+ "id": "mistral_small",
266
+ "name": "Mistral (Small Latest)",
267
+ "env_key": "MISTRAL_API_KEY",
268
+ "base_url": "https://api.mistral.ai/v1",
269
+ "model": "mistral-small-latest",
270
+ "priority": 16,
271
+ "supports_stream": True,
272
+ },
273
+ # ── Cohere (Command R+) ───────────────────────────────────────────────────
274
+ {
275
+ "id": "cohere_command_r_plus",
276
+ "name": "Cohere (Command R+)",
277
+ "env_key": "COHERE_API_KEY",
278
+ "base_url": "https://api.cohere.com/compatibility/v1",
279
+ "model": "command-r-plus",
280
+ "priority": 17,
281
+ "supports_stream": True,
282
+ },
283
+ # ── OpenRouter (Free Fallback) ─────────────────────────────────────────────
284
+ {
285
+ "id": "openrouter_free",
286
+ "name": "OpenRouter (Free)",
287
+ "env_key": "OPENROUTER_API_KEY",
288
+ "base_url": "https://openrouter.ai/api/v1",
289
+ "model": "xingchen-agi/xing4.0-29b-a4b:free",
290
+ "priority": 20,
291
+ "supports_stream": True,
292
+ },
293
+ ]
294
+
295
+ @dataclass
296
+ class Provider:
297
+ id: str
298
+ name: str
299
+ model: str
300
+ priority: int
301
+ supports_stream: bool
302
+ client: Any
303
+ exhausted: bool = False
304
+ quota_blocked: bool = False # Completely blocked for the entire session on quota exhaustion
305
+ total_calls: int = 0
306
+ total_errors: int = 0
307
+ avg_latency_ms: float = 0.0
308
+ quota_reset_at: float = 0.0
309
+
310
+ def record_latency(self, ms: float):
311
+ n = self.total_calls
312
+ self.avg_latency_ms = (self.avg_latency_ms * n + ms) / (n + 1)
313
+ self.total_calls += 1
314
+
315
+ @property
316
+ def health_score(self) -> float:
317
+ if self.exhausted or self.quota_blocked: return 0.0
318
+ error_rate = self.total_errors / max(self.total_calls, 1)
319
+ latency_penalty = min(self.avg_latency_ms / 10000, 0.5)
320
+ return max(0.0, (1.0 - error_rate - latency_penalty))
321
+
322
+ def get_env_key(var_name: str) -> Optional[str]:
323
+ """Get env var from os.environ or directly from Windows user registry."""
324
+ keys_to_check = [var_name]
325
+ if var_name == "COHERE_API_KEY":
326
+ keys_to_check.append("CO_API_KEY")
327
+ elif var_name == "HF_TOKEN":
328
+ keys_to_check.extend(["HUGGINGFACE_API_KEY", "HUGGING_FACE_HUB_TOKEN"])
329
+ for k in keys_to_check:
330
+ val = os.environ.get(k)
331
+ if val: return val.strip()
332
+ if sys.platform == "win32":
333
+ try:
334
+ import winreg
335
+ reg = winreg.OpenKey(winreg.HKEY_CURRENT_USER, r"Environment")
336
+ v, _ = winreg.QueryValueEx(reg, k)
337
+ if v:
338
+ os.environ[k] = v.strip()
339
+ return v.strip()
340
+ except Exception:
341
+ pass
342
+ return None
343
+
344
+ def load_dot_env_files():
345
+ """Load key-value pairs from ~/.ignite/.env and ./.env into os.environ if not present."""
346
+ for p in [Path.home() / ".ignite" / ".env", Path(".env")]:
347
+ if p.exists() and p.is_file():
348
+ try:
349
+ for line in p.read_text(encoding="utf-8").splitlines():
350
+ line = line.strip()
351
+ if line and not line.startswith("#") and "=" in line:
352
+ k, v = line.split("=", 1)
353
+ k = k.strip()
354
+ v = v.strip().strip("\"'")
355
+ if k and k not in os.environ:
356
+ os.environ[k] = v
357
+ except Exception:
358
+ pass
359
+
360
+ load_dot_env_files()
361
+
362
+ def run_first_time_setup() -> bool:
363
+ """Interactive onboarding wizard to collect available API keys on first launch."""
364
+ print(f"\n{C.BCYN}╔══════════════════════════════════════════════════════════════════════════╗{C.RST}")
365
+ print(f"{C.BCYN}║ 🔥 WELCOME TO IGNITE V3 PRO — FIRST-TIME SETUP ║{C.RST}")
366
+ print(f"{C.BCYN}╚══════════════════════════════════════════════════════════════════════════╝{C.RST}")
367
+ print(f"\n{C.BYEL}IGNITE requires at least ONE AI provider API key to get started.{C.RST}")
368
+ print(f"{C.DIM}You do NOT need all of them — just provide whatever keys you have!{C.RST}")
369
+ print(f"{C.GRY}(Press Enter to skip any key you don't have)\n{C.RST}")
370
+
371
+ key_prompts = [
372
+ ("GEMINI_API_KEY", "Google Gemini (Recommended & Free)", "https://aistudio.google.com/"),
373
+ ("GROQ_API_KEY", "Groq Cloud (Free Ultra-Fast)", "https://console.groq.com/"),
374
+ ("MISTRAL_API_KEY", "Mistral AI (Codestral)", "https://console.mistral.ai/"),
375
+ ("COHERE_API_KEY", "Cohere (Command R+)", "https://dashboard.cohere.com/"),
376
+ ("OPENROUTER_API_KEY", "OpenRouter (100+ Models)", "https://openrouter.ai/"),
377
+ ]
378
+
379
+ ignite_dir = Path.home() / ".ignite"
380
+ ignite_dir.mkdir(parents=True, exist_ok=True)
381
+ env_file = ignite_dir / ".env"
382
+
383
+ env_map = {}
384
+ if env_file.exists():
385
+ try:
386
+ for line in env_file.read_text(encoding="utf-8").splitlines():
387
+ if "=" in line and not line.strip().startswith("#"):
388
+ k, v = line.split("=", 1)
389
+ env_map[k.strip()] = v.strip().strip("\"'")
390
+ except Exception:
391
+ pass
392
+
393
+ saved_any = False
394
+ for var_name, label, url in key_prompts:
395
+ cur_val = get_env_key(var_name) or env_map.get(var_name)
396
+ status_hint = f"{C.GRN}(currently set){C.RST}" if cur_val else f"{C.DIM}(free at {url}){C.RST}"
397
+ print(f" {C.BCYN}▸ {label}{C.RST} {status_hint}")
398
+ try:
399
+ val = input(f" Enter API Key (or Enter to skip): ").strip()
400
+ except (KeyboardInterrupt, EOFError):
401
+ print("\nSetup cancelled.")
402
+ return False
403
+
404
+ if val:
405
+ os.environ[var_name] = val
406
+ env_map[var_name] = val
407
+ saved_any = True
408
+ if sys.platform == "win32":
409
+ try:
410
+ subprocess.run(
411
+ ["powershell", "-NoProfile", "-NonInteractive", "-Command",
412
+ f'[Environment]::SetEnvironmentVariable("{var_name}", "{val}", "User")'],
413
+ capture_output=True, timeout=5
414
+ )
415
+ except Exception:
416
+ pass
417
+ print(f" {C.BGRN}✔ Saved {var_name}{C.RST}\n")
418
+ elif cur_val:
419
+ saved_any = True
420
+ print(f" {C.GRY}Kept existing {var_name}{C.RST}\n")
421
+ else:
422
+ print(f" {C.DIM}Skipped.{C.RST}\n")
423
+
424
+ if env_map:
425
+ try:
426
+ content = "\n".join(f'{k}="{v}"' for k, v in env_map.items())
427
+ env_file.write_text(content, encoding="utf-8")
428
+ except Exception:
429
+ pass
430
+
431
+ if saved_any:
432
+ print(f"{C.BGRN}🎉 Setup Complete! Your API keys have been saved securely.{C.RST}")
433
+ print(f"{C.GRY}Keys saved to {env_file} and Windows User Environment.{C.RST}\n")
434
+ return True
435
+ else:
436
+ print(f"{C.YEL}⚠ No API keys were configured.{C.RST}")
437
+ print(f"{C.GRY}You can run 'ignite setup' anytime to configure keys.{C.RST}\n")
438
+ return False
439
+
440
+ # Check for CLI flags: --version / -v
441
+ if any(arg in sys.argv for arg in ["--version", "-v", "version"]):
442
+ print("🔥 IGNITE V3 PRO v1.0.0")
443
+ sys.exit(0)
444
+
445
+ # Check for explicit setup CLI command: ignite setup / ignite --setup
446
+ if any(arg in sys.argv for arg in ["setup", "--setup", "init", "--init"]):
447
+ run_first_time_setup()
448
+ sys.exit(0)
449
+
450
+ def build_providers() -> List[Provider]:
451
+ providers = []
452
+ for p in PROVIDER_DEFS:
453
+ key = get_env_key(p["env_key"])
454
+ if not key:
455
+ continue
456
+ try:
457
+ client = OpenAI(base_url=p["base_url"], api_key=key, max_retries=0, timeout=25)
458
+ providers.append(Provider(
459
+ id=p["id"], name=p["name"], model=p["model"],
460
+ priority=p["priority"], supports_stream=p["supports_stream"],
461
+ client=client
462
+ ))
463
+ except Exception:
464
+ pass
465
+ return sorted(providers, key=lambda x: x.priority)
466
+
467
+ PROVIDERS: List[Provider] = build_providers()
468
+
469
+ if not PROVIDERS:
470
+ # First time launch onboarding wizard
471
+ ok = run_first_time_setup()
472
+ if ok:
473
+ PROVIDERS = build_providers()
474
+
475
+ if not PROVIDERS:
476
+ print(f"\n{C.BRED}⚠ FATAL: No API keys configured. Set at least one key to use IGNITE.{C.RST}")
477
+ print(f"Run: {C.BCYN}ignite setup{C.RST} to configure keys.\n")
478
+ sys.exit(1)
479
+
480
+
481
+ # ══════════════════════════════════════════════════════════════════════════════
482
+ # SECTION 4 — STEALTH OBFUSCATOR (v2: context-aware + path normalisation)
483
+ # ══════════════════════════════════════════════════════════════════════════════
484
+ class StealthObfuscator:
485
+ BANNED = {
486
+ "crack":"debug","hack":"optimize","malware":"software","exploit":"test_case",
487
+ "payload":"data_packet","botnet":"node_cluster","rootkit":"daemon",
488
+ "ransomware":"file_manager","keylogger":"input_handler","bypass":"workaround",
489
+ }
490
+ IGNORE_WORDS = {
491
+ "users","desktop","documents","downloads","system32","windows",
492
+ "program files","appdata","temp","local","roaming",
493
+ }
494
+
495
+ def __init__(self):
496
+ self._real_to_mask: Dict[str, str] = {}
497
+ self._mask_to_real: Dict[str, str] = {}
498
+ self._counter = 1
499
+
500
+ def _gen_mask(self, word: str) -> str:
501
+ h = hashlib.md5(word.encode()).hexdigest()[:4].upper()
502
+ mask = f"Workspace_{h}_{self._counter}"
503
+ self._counter += 1
504
+ return mask
505
+
506
+ def register(self, text: str):
507
+ if not text: return
508
+ # Register filesystem paths
509
+ for pat in [r'[A-Za-z]:\\[\w\-\. \\]+', r'/[\w\-\.]+(?:/[\w\-\.]+)+']:
510
+ for m in re.finditer(pat, text):
511
+ p = m.group()
512
+ parts = re.split(r'[/\\]', p)
513
+ for part in parts:
514
+ self._add(part.strip("\"'"))
515
+ # Register CamelCase project names
516
+ for m in re.finditer(r'\b[A-Z][a-z]+(?:[A-Z][a-z]*)+\b', text):
517
+ self._add(m.group())
518
+
519
+ def _add(self, word: str):
520
+ if not word or len(word) <= 2: return
521
+ if word.lower() in self.IGNORE_WORDS: return
522
+ if word in self._real_to_mask: return
523
+ mask = self._gen_mask(word)
524
+ self._real_to_mask[word] = mask
525
+ self._mask_to_real[mask] = word
526
+
527
+ def mask(self, text: str) -> str:
528
+ if not text: return text
529
+ for bad, good in self.BANNED.items():
530
+ text = re.sub(r'\b' + re.escape(bad) + r'\b', good, text, flags=re.IGNORECASE)
531
+ for real, safe in sorted(self._real_to_mask.items(), key=lambda x: -len(x[0])):
532
+ text = text.replace(real, safe)
533
+ return text
534
+
535
+ def unmask(self, text: str) -> str:
536
+ if not text: return text
537
+ for safe, real in sorted(self._mask_to_real.items(), key=lambda x: -len(x[0])):
538
+ text = text.replace(safe, real)
539
+ return text
540
+
541
+ obfuscator = StealthObfuscator()
542
+
543
+
544
+ # ══════════════════════════════════════════════════════════════════════════════
545
+ # SECTION 5 — EPISODIC MEMORY + COMPRESSION
546
+ # ══════════════════════════════════════════════════════════════════════════════
547
+ @dataclass
548
+ class Episode:
549
+ task: str
550
+ summary: str
551
+ tools_used: List[str]
552
+ outcome: str # "success" | "error" | "partial"
553
+ timestamp: str = field(default_factory=lambda: datetime.now().isoformat())
554
+
555
+ class MemorySystem:
556
+ def __init__(self, max_history: int = 40):
557
+ self.max_history = max_history
558
+ self.episodes: List[Episode] = []
559
+ self._history: List[Dict] = []
560
+
561
+ def push(self, role: str, content: str):
562
+ self._history.append({"role": role, "content": content})
563
+ if len(self._history) > self.max_history:
564
+ self._compress()
565
+
566
+ def _compress(self):
567
+ """Summarise the oldest half of history into one system note."""
568
+ mid = len(self._history) // 2
569
+ chunk = self._history[1:mid+1] # skip system prompt
570
+ summary_lines = []
571
+ for m in chunk:
572
+ snippet = m["content"][:120].replace("\n", " ")
573
+ summary_lines.append(f"[{m['role']}] {snippet}...")
574
+ compressed = "COMPRESSED EARLIER CONTEXT:\n" + "\n".join(summary_lines)
575
+ self._history = [self._history[0],
576
+ {"role": "system", "content": compressed}] + self._history[mid+1:]
577
+
578
+ def get_history(self) -> List[Dict]:
579
+ return self._history.copy()
580
+
581
+ def record_episode(self, task: str, summary: str, tools: List[str], outcome: str):
582
+ self.episodes.append(Episode(task, summary, tools, outcome))
583
+ if len(self.episodes) > 100:
584
+ self.episodes = self.episodes[-100:]
585
+
586
+ def format_episodes(self, n: int = 5) -> str:
587
+ if not self.episodes:
588
+ return "No previous episodes."
589
+ rows = []
590
+ for ep in self.episodes[-n:]:
591
+ rows.append(f" [{ep.outcome.upper()}] {ep.timestamp[:16]} {ep.task[:60]}")
592
+ return "RECENT EPISODES:\n" + "\n".join(rows)
593
+
594
+
595
+ # ══════════════════════════════════════════════════════════════════════════════
596
+ # SECTION 6 — SHELL ENGINE (streaming + stateful + git-aware)
597
+ # ══════════════════════════════════════════════════════════════════════════════
598
+ @dataclass
599
+ class ShellResult:
600
+ code: int
601
+ stdout: str
602
+ stderr: str
603
+ duration_ms: float
604
+
605
+ @property
606
+ def log(self) -> str:
607
+ combined = self.stdout
608
+ if self.stderr:
609
+ combined += f"\nSTDERR:\n{self.stderr}"
610
+ return combined.strip() or "[No Output]"
611
+
612
+ @property
613
+ def ok(self) -> bool:
614
+ return self.code == 0
615
+
616
+ class ShellEngine:
617
+ DEPLOY_MARKERS = {
618
+ "Dockerfile": "docker",
619
+ "docker-compose.yml": "docker-compose",
620
+ "fly.toml": "fly.io",
621
+ "railway.toml": "railway",
622
+ "vercel.json": "vercel",
623
+ "netlify.toml": "netlify",
624
+ ".github/workflows": "github-actions",
625
+ "Procfile": "heroku",
626
+ "app.yaml": "gcp-appengine",
627
+ }
628
+ STACK_MARKERS = {
629
+ "package.json": "Node.js",
630
+ "requirements.txt": "Python",
631
+ "main.py": "Python",
632
+ "bot.py": "Python Discord/Telegram Bot",
633
+ "manage.py": "Django",
634
+ "app.py": "Flask/FastAPI",
635
+ "Cargo.toml": "Rust",
636
+ "go.mod": "Go",
637
+ "build.gradle": "Java/Kotlin (Gradle)",
638
+ "pom.xml": "Java (Maven)",
639
+ "*.csproj": "C# / .NET",
640
+ "composer.json": "PHP",
641
+ }
642
+
643
+ def __init__(self):
644
+ self.cwd = Path(os.getcwd())
645
+ self.env = {**os.environ,
646
+ "GIT_TERMINAL_PROMPT": "0",
647
+ "PYTHONUNBUFFERED": "1",
648
+ "TERM": "dumb"}
649
+ self.history: List[Tuple[str, ShellResult]] = []
650
+
651
+ def run(self, cmd: str, timeout: int = 120) -> ShellResult:
652
+ cmd = cmd.strip()
653
+
654
+ # Stateful cd handling
655
+ if cmd.startswith("cd "):
656
+ target = cmd[3:].strip().strip("\"'")
657
+ new_path = (self.cwd / target).resolve()
658
+ if new_path.is_dir():
659
+ self.cwd = new_path
660
+ try:
661
+ if 'code_indexer' in globals():
662
+ code_indexer.set_workspace(self.cwd)
663
+ if 'mcp_registry' in globals():
664
+ mcp_registry.set_workspace(self.cwd)
665
+ except Exception:
666
+ pass
667
+ return ShellResult(0, f"Changed directory → {self.cwd}", "", 0)
668
+ return ShellResult(1, "", f"Directory not found: {target}", 0)
669
+
670
+ # ── Detect interactive commands that would hang ────────────────────────
671
+ # e.g. `python foo.py` where foo.py calls input() — would block forever
672
+ if self._is_interactive_cmd(cmd):
673
+ return ShellResult(
674
+ code=1,
675
+ stdout="",
676
+ stderr=(
677
+ "⚠ INTERACTIVE SCRIPT DETECTED — cannot run scripts that call input() "
678
+ "non-interactively. Use 'file_write' to create the script and tell the "
679
+ "user to run it themselves with: python <script_name>"
680
+ ),
681
+ duration_ms=0
682
+ )
683
+
684
+ t0 = time.time()
685
+ try:
686
+ proc = subprocess.run(
687
+ ["powershell", "-NoProfile", "-NonInteractive", "-Command", cmd],
688
+ cwd=str(self.cwd), env=self.env,
689
+ capture_output=True, text=True,
690
+ timeout=timeout, errors="replace"
691
+ )
692
+ ms = (time.time() - t0) * 1000
693
+ # Truncate to prevent context overflow
694
+ limit = CFG["output_truncate_lines"]
695
+ stdout_lines = proc.stdout.strip().splitlines()
696
+ stderr_lines = proc.stderr.strip().splitlines()
697
+ if len(stdout_lines) > limit:
698
+ head = stdout_lines[:limit//2]
699
+ tail = stdout_lines[-limit//2:]
700
+ stdout_lines = head + [f"... [{len(stdout_lines) - limit} lines omitted] ..."] + tail
701
+ return ShellResult(
702
+ code=proc.returncode,
703
+ stdout="\n".join(stdout_lines),
704
+ stderr="\n".join(stderr_lines[-20:]),
705
+ duration_ms=ms
706
+ )
707
+ except subprocess.TimeoutExpired:
708
+ return ShellResult(-1, "", f"Command timed out after {timeout}s", timeout * 1000)
709
+ except Exception as e:
710
+ return ShellResult(-1, "", str(e), 0)
711
+
712
+ def _is_interactive_cmd(self, cmd: str) -> bool:
713
+ """Return True if cmd would likely hang waiting for user input."""
714
+ cmd_lower = cmd.strip().lower()
715
+ # Running a Python script directly without piped input
716
+ py_run = re.match(r'^python\d*\s+["\']?(\S+\.py)["\']?\s*$', cmd_lower)
717
+ if py_run:
718
+ script_name = py_run.group(1)
719
+ script_path = self.cwd / script_name
720
+ if script_path.exists():
721
+ try:
722
+ content = script_path.read_text(errors="ignore")
723
+ if re.search(r'\binput\s*\(', content):
724
+ return True
725
+ except Exception:
726
+ pass
727
+ return False
728
+
729
+
730
+ def scan_project(self) -> Dict[str, Any]:
731
+ files = []
732
+ try:
733
+ files = [f.name for f in self.cwd.iterdir()]
734
+ except Exception:
735
+ pass
736
+ stacks, deploy_targets, env_vars, structure = [], [], [], {}
737
+
738
+ for marker, tech in self.STACK_MARKERS.items():
739
+ if any(f == marker or (marker.startswith("*") and f.endswith(marker[1:])) for f in files):
740
+ stacks.append(tech)
741
+
742
+ for marker, target in self.DEPLOY_MARKERS.items():
743
+ if marker in files or (self.cwd / marker).exists():
744
+ deploy_targets.append(target)
745
+
746
+ # AST env-var mining
747
+ for ext in [".py", ".js", ".ts", ".env.example", ".env.sample"]:
748
+ for fpath in self.cwd.glob(f"*{ext}"):
749
+ try:
750
+ content = fpath.read_text(errors="ignore")
751
+ found = re.findall(
752
+ r'(?:os\.(?:getenv|environ\.get)|process\.env)\(["\']([A-Z0-9_]+)["\']',
753
+ content
754
+ )
755
+ env_vars.extend(found)
756
+ except Exception:
757
+ pass
758
+
759
+ # Directory tree (2 levels)
760
+ try:
761
+ for item in sorted(self.cwd.iterdir()):
762
+ if item.name.startswith("."): continue
763
+ if item.is_dir():
764
+ children = [c.name for c in item.iterdir() if not c.name.startswith(".")][:6]
765
+ structure[item.name + "/"] = children
766
+ else:
767
+ structure[item.name] = item.stat().st_size
768
+ except Exception:
769
+ pass
770
+
771
+ return {
772
+ "cwd": str(self.cwd),
773
+ "stacks": stacks or ["Unknown"],
774
+ "deploy_targets": deploy_targets,
775
+ "required_env_vars": list(set(env_vars)),
776
+ "structure": structure,
777
+ }
778
+
779
+ def git_op(self, subcmd: str) -> ShellResult:
780
+ return self.run(f"git {subcmd}")
781
+
782
+ shell = ShellEngine()
783
+
784
+ # ══════════════════════════════════════════════════════════════════════════════
785
+ # SECTION 6a — GIT SHIELD & CHECKPOINTING (Shadow Sandboxing & Rollback)
786
+ # ══════════════════════════════════════════════════════════════════════════════
787
+ class GitShield:
788
+ """
789
+ Enterprise Sandboxing, Snapshotting & Rollback Engine.
790
+ - Captures file checkpoints automatically before tasks begin.
791
+ - Supports git stashing / branch snapshotting + local shadow file backup.
792
+ - Powers /undo, /rollback, /diff, /commit, and /checkpoint commands.
793
+ """
794
+ def __init__(self, workspace: Path):
795
+ self.workspace = workspace
796
+ self.checkpoints: List[Dict[str, Any]] = []
797
+ self._shadow_dir = Path("C:/.ignite_scratch/shadow_checkpoints")
798
+ try:
799
+ self._shadow_dir.mkdir(parents=True, exist_ok=True)
800
+ except Exception:
801
+ pass
802
+
803
+ def is_git_repo(self) -> bool:
804
+ return (self.workspace / ".git").is_dir()
805
+
806
+ def create_checkpoint(self, label: str = "Auto Checkpoint") -> str:
807
+ """Create a safety checkpoint of current working directory state."""
808
+ timestamp = time.strftime("%Y%m%d_%H%M%S")
809
+ chk_id = f"chk_{timestamp}"
810
+ chk_folder = self._shadow_dir / chk_id
811
+ try:
812
+ chk_folder.mkdir(parents=True, exist_ok=True)
813
+ except Exception:
814
+ pass
815
+
816
+ data: Dict[str, Any] = {
817
+ "id": chk_id,
818
+ "label": label,
819
+ "timestamp": timestamp,
820
+ "is_git": self.is_git_repo(),
821
+ "folder": str(chk_folder),
822
+ "files": {},
823
+ "created_at": time.time(),
824
+ }
825
+
826
+ if self.is_git_repo():
827
+ res = shell.git_op("rev-parse HEAD")
828
+ data["git_head"] = res.stdout.strip() if res.ok else "HEAD"
829
+ shell.git_op(f"stash create \"{chk_id}: {label}\"")
830
+
831
+ self.checkpoints.append(data)
832
+ if len(self.checkpoints) > 20:
833
+ self.checkpoints.pop(0)
834
+ return chk_id
835
+
836
+ def backup_file(self, file_path: Path):
837
+ """Backup an individual file before it gets modified."""
838
+ try:
839
+ if not file_path.exists() or not self.checkpoints:
840
+ return
841
+ current_chk = self.checkpoints[-1]
842
+ chk_folder = Path(current_chk["folder"])
843
+ try:
844
+ rel = file_path.resolve().relative_to(self.workspace.resolve())
845
+ except ValueError:
846
+ rel = Path(file_path.name)
847
+ dest = chk_folder / rel
848
+ dest.parent.mkdir(parents=True, exist_ok=True)
849
+ if not dest.exists():
850
+ shutil.copy2(file_path, dest)
851
+ current_chk["files"][str(rel)] = str(dest)
852
+ except Exception:
853
+ pass
854
+
855
+ def rollback(self) -> Tuple[bool, str]:
856
+ """Rollback working tree to the last checkpoint."""
857
+ if not self.checkpoints:
858
+ if self.is_git_repo():
859
+ shell.git_op("checkout .")
860
+ shell.git_op("clean -fd")
861
+ return True, "Reverted all uncommitted changes via git checkout."
862
+ return False, "No checkpoints available to restore."
863
+
864
+ chk = self.checkpoints.pop()
865
+ restored_count = 0
866
+
867
+ # Restore shadowed files
868
+ if chk.get("files"):
869
+ for rel_str, src_str in chk["files"].items():
870
+ target = self.workspace / rel_str
871
+ src = Path(src_str)
872
+ if src.exists():
873
+ target.parent.mkdir(parents=True, exist_ok=True)
874
+ shutil.copy2(src, target)
875
+ restored_count += 1
876
+
877
+ if self.is_git_repo():
878
+ shell.git_op("checkout .")
879
+ shell.git_op("clean -fd")
880
+
881
+ return True, f"Restored {restored_count} file(s) from checkpoint: '{chk['label']}' ({chk['id']})"
882
+
883
+ def diff(self, path: str = "") -> str:
884
+ """Get formatted diff of changes in working tree."""
885
+ if self.is_git_repo():
886
+ target = f" -- \"{path}\"" if path else ""
887
+ res = shell.git_op(f"diff --color=never{target}")
888
+ diff_text = res.stdout.strip()
889
+ if diff_text:
890
+ return diff_text
891
+ st = shell.git_op("status --short").stdout.strip()
892
+ if st:
893
+ return f"Untracked / Staged files:\n{st}"
894
+ return "No changes detected (working tree clean)."
895
+ else:
896
+ if not self.checkpoints or not self.checkpoints[-1].get("files"):
897
+ return "Not a git repo. No files modified in current checkpoint."
898
+ files = list(self.checkpoints[-1]["files"].keys())
899
+ return f"Non-git project. Modified files in shadow checkpoint:\n" + "\n".join(f" • {f}" for f in files)
900
+
901
+ git_shield = GitShield(shell.cwd)
902
+
903
+
904
+ # ══════════════════════════════════════════════════════════════════════════════
905
+ # SECTION 6b — PROCESS REGISTRY (spawn · monitor · kill)
906
+ # ══════════════════════════════════════════════════════════════════════════════
907
+ import tempfile
908
+
909
+ @dataclass
910
+ class ManagedProcess:
911
+ pid: int
912
+ name: str
913
+ cmd: str
914
+ log_path: str
915
+ started_at: str
916
+ mode: str # "window" | "background"
917
+ _proc: Any = field(repr=False, default=None)
918
+
919
+ @property
920
+ def is_alive(self) -> bool:
921
+ if self._proc is not None:
922
+ return self._proc.poll() is None
923
+ # Fallback: ask OS
924
+ try:
925
+ result = subprocess.run(
926
+ ["powershell", "-NoProfile", "-NonInteractive", "-Command",
927
+ f"(Get-Process -Id {self.pid} -ErrorAction SilentlyContinue) -ne $null"],
928
+ capture_output=True, text=True, timeout=5
929
+ )
930
+ return result.stdout.strip().lower() == "true"
931
+ except Exception:
932
+ return False
933
+
934
+ def tail_log(self, lines: int = 60) -> str:
935
+ try:
936
+ content = Path(self.log_path).read_text(encoding="utf-8", errors="ignore")
937
+ all_lines = content.splitlines()
938
+ return "\n".join(all_lines[-lines:]) if all_lines else "[No output yet]"
939
+ except Exception as e:
940
+ return f"[Log read error: {e}]"
941
+
942
+ class ProcessRegistry:
943
+ """Tracks all processes spawned by the agent."""
944
+ def __init__(self):
945
+ self._procs: Dict[int, ManagedProcess] = {}
946
+ self._log_dir = Path(tempfile.gettempdir()) / "ignite_procs"
947
+ self._log_dir.mkdir(exist_ok=True)
948
+
949
+ # ── Spawn a new VISIBLE terminal window ───────────────────────────────────
950
+ def spawn_window(self, cmd: str, cwd: str, name: str = "") -> Tuple[int, str]:
951
+ """Open script in a new visible PowerShell window. Returns (pid, message).
952
+ Uses UTF-8 log via Out-File so tail_log() reads correctly.
953
+ """
954
+ proc_name = name or (Path(cmd.split()[-1]).stem if cmd else "process")
955
+ log_path = str(self._log_dir / f"win_{int(time.time())}_{proc_name}.log")
956
+ clean_cmd = cmd.strip()
957
+
958
+ # We split the stream:
959
+ # • Write-Host shows coloured output in the terminal window (visible to user)
960
+ # • Out-File -Encoding utf8 captures stdout+stderr in UTF-8 (readable by agent)
961
+ # Script block technique: run command, capture all output, echo + save
962
+ ps_launcher = (
963
+ f"$host.UI.RawUI.WindowTitle = 'IGNITE [{proc_name}]'; "
964
+ f"Set-Location -LiteralPath '{cwd}'; "
965
+ f"$env:PYTHONUNBUFFERED = '1'; "
966
+ f"Write-Host '╔══════════════════════════════════════════════════════╗' -ForegroundColor Cyan; "
967
+ f"Write-Host '║ IGNITE MANAGED: {proc_name:<36}║' -ForegroundColor Cyan; "
968
+ f"Write-Host '╚══════════════════════════════════════════════════════╝' -ForegroundColor Cyan; "
969
+ f"Write-Host 'CMD: {clean_cmd}' -ForegroundColor Yellow; "
970
+ f"Write-Host ''; "
971
+ f"& {{ {clean_cmd} }} 2>&1 | ForEach-Object {{ "
972
+ f" $_ | Out-File -FilePath '{log_path}' -Encoding utf8 -Append; "
973
+ f" Write-Host $_ "
974
+ f"}}; "
975
+ f"Write-Host ''; "
976
+ f"Write-Host '[IGNITE] Process finished. Press any key...' -ForegroundColor DarkGray; "
977
+ f"$null = $Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown')"
978
+ )
979
+
980
+ try:
981
+ flags = subprocess.CREATE_NEW_CONSOLE if sys.platform == "win32" else 0
982
+ proc = subprocess.Popen(
983
+ ["powershell.exe", "-NoExit", "-Command", ps_launcher],
984
+ cwd=cwd,
985
+ creationflags=flags
986
+ )
987
+ pid = proc.pid
988
+ mp = ManagedProcess(pid=pid, name=proc_name, cmd=clean_cmd,
989
+ log_path=log_path, started_at=datetime.now().isoformat(),
990
+ mode="window", _proc=proc)
991
+ self._procs[pid] = mp
992
+ return pid, f"Opened visible terminal window! PID={pid} | Title='IGNITE [{proc_name}]' | Log={log_path}"
993
+ except Exception as e:
994
+ return -1, f"Failed to open terminal window: {str(e)}"
995
+
996
+ # ── Spawn BACKGROUND (invisible, log to file) ─────────────────────────────
997
+ def spawn_background(self, cmd: str, cwd: str, name: str = "") -> ManagedProcess:
998
+ """Run process silently in background. Output captured to log file."""
999
+ log_path = str(self._log_dir / f"bg_{int(time.time())}_{name or 'proc'}.log")
1000
+ log_file = open(log_path, "w", encoding="utf-8", errors="replace", buffering=1)
1001
+ env = {**os.environ, "GIT_TERMINAL_PROMPT": "0", "PYTHONUNBUFFERED": "1"}
1002
+ proc = subprocess.Popen(
1003
+ ["powershell", "-NoProfile", "-NonInteractive", "-Command", cmd],
1004
+ cwd=cwd, stdout=log_file, stderr=subprocess.STDOUT, env=env
1005
+ )
1006
+ mp = ManagedProcess(pid=proc.pid, name=name or cmd[:40], cmd=cmd,
1007
+ log_path=log_path, started_at=datetime.now().isoformat(),
1008
+ mode="background", _proc=proc)
1009
+ self._procs[proc.pid] = mp
1010
+ return mp
1011
+
1012
+ # ── Kill ──────────────────────────────────────────────────────────────────
1013
+ def kill(self, pid: int) -> str:
1014
+ mp = self._procs.get(pid)
1015
+ name = mp.name if mp else str(pid)
1016
+ if mp and mp._proc:
1017
+ mp._proc.terminate()
1018
+ try: mp._proc.wait(timeout=5)
1019
+ except Exception: mp._proc.kill()
1020
+ # Also kill via PowerShell (catches window processes)
1021
+ subprocess.run(
1022
+ ["powershell", "-NoProfile", "-NonInteractive", "-Command",
1023
+ f"Stop-Process -Id {pid} -Force -ErrorAction SilentlyContinue"],
1024
+ capture_output=True, timeout=10
1025
+ )
1026
+ if pid in self._procs:
1027
+ del self._procs[pid]
1028
+ return f"✔ Killed PID {pid} ({name})"
1029
+
1030
+ def kill_by_name(self, name: str) -> str:
1031
+ killed = []
1032
+ for pid, mp in list(self._procs.items()):
1033
+ if name.lower() in mp.name.lower() or name.lower() in mp.cmd.lower():
1034
+ self.kill(pid)
1035
+ killed.append(pid)
1036
+ if killed:
1037
+ return f"Killed PIDs: {killed}"
1038
+ # Try OS-level by process name
1039
+ subprocess.run(
1040
+ ["powershell", "-NoProfile", "-NonInteractive", "-Command",
1041
+ f"Stop-Process -Name '{name}' -Force -ErrorAction SilentlyContinue"],
1042
+ capture_output=True, timeout=10
1043
+ )
1044
+ return f"Attempted kill of process named '{name}'"
1045
+
1046
+ # ── Status / Log reader ───────────────────────────────────────────────────
1047
+ def status(self, pid: int = None, tail: int = 60) -> str:
1048
+ if pid:
1049
+ mp = self._procs.get(pid)
1050
+ if not mp:
1051
+ return f"PID {pid} not tracked by IGNITE."
1052
+ alive = "🟢 RUNNING" if mp.is_alive else "🔴 STOPPED"
1053
+ return (f"{alive} PID:{pid} [{mp.mode}] {mp.name}\n"
1054
+ f"CMD: {mp.cmd}\nStarted: {mp.started_at}\n"
1055
+ f"Log: {mp.log_path}\n\n--- Recent Output ---\n{mp.tail_log(tail)}")
1056
+ if not self._procs:
1057
+ return "No processes tracked."
1058
+ lines = []
1059
+ for pid, mp in self._procs.items():
1060
+ alive = "🟢" if mp.is_alive else "🔴"
1061
+ lines.append(f" {alive} PID:{pid:6} [{mp.mode:10}] {mp.name[:40]:<40} {mp.started_at[:16]}")
1062
+ return "MANAGED PROCESSES:\n" + "\n".join(lines)
1063
+
1064
+ def list_fmt(self) -> str:
1065
+ return self.status()
1066
+
1067
+ proc_registry = ProcessRegistry()
1068
+
1069
+
1070
+ # ══════════════════════════════════════════════════════════════════════════════
1071
+ # SECTION 6c — WEB DASHBOARD (localhost:7860 — no extra dependencies)
1072
+ # ══════════════════════════════════════════════════════════════════════════════
1073
+ import http.server, webbrowser as _webbrowser
1074
+
1075
+ _DASHBOARD_HTML = r"""<!DOCTYPE html>
1076
+ <html lang="en">
1077
+ <head>
1078
+ <meta charset="UTF-8"/>
1079
+ <meta name="viewport" content="width=device-width,initial-scale=1"/>
1080
+ <title>IGNITE Dashboard</title>
1081
+ <style>
1082
+ *{box-sizing:border-box;margin:0;padding:0}
1083
+ :root{--bg:#0d0d0d;--card:#141414;--border:#222;--cyan:#00e5ff;--green:#00e676;--red:#ff1744;--yellow:#ffd740;--gray:#666;--text:#e0e0e0}
1084
+ body{background:var(--bg);color:var(--text);font-family:'Segoe UI',monospace;font-size:14px}
1085
+ header{background:linear-gradient(90deg,#0d0d0d,#1a0000);border-bottom:2px solid var(--red);padding:16px 24px;display:flex;align-items:center;justify-content:space-between}
1086
+ .logo{font-size:22px;font-weight:700;color:var(--red);letter-spacing:2px}.logo span{color:var(--cyan)}
1087
+ .subtitle{color:var(--gray);font-size:12px;margin-top:2px}
1088
+ .badge{background:#1a1a1a;border:1px solid var(--border);border-radius:20px;padding:4px 12px;font-size:12px;color:var(--gray)}
1089
+ .badge.live{border-color:var(--green);color:var(--green);animation:pulse 2s infinite}
1090
+ @keyframes pulse{0%,100%{opacity:1}50%{opacity:.5}}
1091
+ .grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;padding:20px;max-width:1600px;margin:0 auto}
1092
+ @media(max-width:900px){.grid{grid-template-columns:1fr}}
1093
+ .full{grid-column:1/-1}
1094
+ .card{background:var(--card);border:1px solid var(--border);border-radius:10px;overflow:hidden}
1095
+ .card-header{padding:12px 16px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;font-weight:600;font-size:13px}
1096
+ .card-body{padding:16px}
1097
+ .stats{display:flex;gap:16px;flex-wrap:wrap}
1098
+ .stat{flex:1;min-width:120px;background:#0a0a0a;border:1px solid var(--border);border-radius:8px;padding:14px 16px;text-align:center}
1099
+ .stat .val{font-size:28px;font-weight:700;color:var(--cyan)}.stat .lbl{font-size:11px;color:var(--gray);margin-top:4px;text-transform:uppercase;letter-spacing:1px}
1100
+ table{width:100%;border-collapse:collapse}
1101
+ th{text-align:left;padding:8px 12px;font-size:11px;text-transform:uppercase;letter-spacing:1px;color:var(--gray);border-bottom:1px solid var(--border)}
1102
+ td{padding:9px 12px;border-bottom:1px solid #1a1a1a;vertical-align:middle}
1103
+ tr:last-child td{border-bottom:none}
1104
+ tr:hover td{background:#1a1a1a}
1105
+ .dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:6px}
1106
+ .dot.green{background:var(--green);box-shadow:0 0 6px var(--green)}.dot.red{background:var(--red)}
1107
+ .tag{background:#1e1e1e;border:1px solid #333;border-radius:4px;padding:2px 8px;font-size:11px;color:var(--gray)}
1108
+ .tag.window{border-color:#00bcd4;color:#00bcd4}.tag.background{border-color:#ff9800;color:#ff9800}
1109
+ .btn{border:none;border-radius:5px;padding:5px 12px;font-size:12px;cursor:pointer;font-family:inherit;transition:.15s}
1110
+ .btn-red{background:#2a0a0a;border:1px solid var(--red);color:var(--red)}.btn-red:hover{background:var(--red);color:#000}
1111
+ .btn-cyan{background:#001a1e;border:1px solid var(--cyan);color:var(--cyan)}.btn-cyan:hover{background:var(--cyan);color:#000}
1112
+ .btn-green{background:#001a08;border:1px solid var(--green);color:var(--green)}.btn-green:hover{background:var(--green);color:#000}
1113
+ .btn-sm{padding:3px 8px;font-size:11px}
1114
+ #log-viewer{background:#050505;border:1px solid var(--border);border-radius:6px;padding:14px;font-family:monospace;font-size:12px;line-height:1.6;height:320px;overflow-y:auto;white-space:pre-wrap;word-break:break-all;color:#b0b0b0}
1115
+ .log-bar{display:flex;gap:8px;margin-bottom:10px;align-items:center;flex-wrap:wrap}
1116
+ #pid-select{background:#0a0a0a;border:1px solid var(--border);color:var(--text);border-radius:5px;padding:5px 10px;font-family:inherit;font-size:12px;flex:1}
1117
+ .msg{padding:10px 12px;border-radius:6px;margin-bottom:8px;font-size:13px;line-height:1.5}
1118
+ .msg.user{background:#0d1a2e;border-left:3px solid var(--cyan)}.msg.assistant{background:#0a1a0a;border-left:3px solid var(--green)}
1119
+ .msg .role{font-size:10px;text-transform:uppercase;letter-spacing:1px;color:var(--gray);margin-bottom:4px}
1120
+ #history-box{height:260px;overflow-y:auto}
1121
+ footer{text-align:center;color:var(--gray);font-size:11px;padding:20px;border-top:1px solid var(--border)}
1122
+ .err{color:var(--red)}.ok{color:var(--green)}.info-txt{color:var(--cyan)}
1123
+ </style>
1124
+ </head>
1125
+ <body>
1126
+ <header>
1127
+ <div>
1128
+ <div class="logo">IGNITE <span>DASHBOARD</span></div>
1129
+ <div class="subtitle">Autonomous AI DevOps Agent · V3 PRO</div>
1130
+ </div>
1131
+ <div style="display:flex;gap:10px;align-items:center">
1132
+ <span class="badge live">● LIVE</span>
1133
+ <span class="badge" id="last-refresh">–</span>
1134
+ <button class="btn btn-cyan btn-sm" onclick="refresh()">⟳ Refresh</button>
1135
+ </div>
1136
+ </header>
1137
+ <div class="grid">
1138
+ <!-- Stats -->
1139
+ <div class="card full">
1140
+ <div class="card-header"><span>📊 Overview</span></div>
1141
+ <div class="card-body">
1142
+ <div class="stats">
1143
+ <div class="stat"><div class="val" id="s-running">–</div><div class="lbl">Running</div></div>
1144
+ <div class="stat"><div class="val" id="s-total">–</div><div class="lbl">Total Spawned</div></div>
1145
+ <div class="stat"><div class="val" id="s-steps">–</div><div class="lbl">Agent Steps</div></div>
1146
+ <div class="stat"><div class="val" id="s-tokens" style="font-size:14px">–</div><div class="lbl">Tokens</div></div>
1147
+ <div class="stat"><div class="val" id="s-model" style="font-size:13px">–</div><div class="lbl">Active Model</div></div>
1148
+ <div class="stat"><div class="val" id="s-cwd" style="font-size:13px">–</div><div class="lbl">Working Dir</div></div>
1149
+ </div>
1150
+ </div>
1151
+ </div>
1152
+ <!-- Process Table -->
1153
+ <div class="card full">
1154
+ <div class="card-header">
1155
+ <span>⚙️ Managed Processes</span>
1156
+ <button class="btn btn-red btn-sm" onclick="killAll()">🗑 Kill All</button>
1157
+ </div>
1158
+ <div class="card-body" style="padding:0">
1159
+ <table>
1160
+ <thead><tr><th>Status</th><th>PID</th><th>Name</th><th>Mode</th><th>Command</th><th>Started</th><th>Actions</th></tr></thead>
1161
+ <tbody id="proc-tbody"><tr><td colspan="7" style="text-align:center;color:var(--gray);padding:20px">No processes yet</td></tr></tbody>
1162
+ </table>
1163
+ </div>
1164
+ </div>
1165
+ <!-- Log Viewer -->
1166
+ <div class="card">
1167
+ <div class="card-header">
1168
+ <span>📋 Live Log Viewer</span>
1169
+ <button class="btn btn-green btn-sm" onclick="autoScroll=!autoScroll">⇓ Auto-scroll</button>
1170
+ </div>
1171
+ <div class="card-body">
1172
+ <div class="log-bar">
1173
+ <select id="pid-select" onchange="selectedPid=this.value"><option value="">— select process —</option></select>
1174
+ <button class="btn btn-cyan btn-sm" onclick="fetchLog()">Load Log</button>
1175
+ <button class="btn btn-sm" style="background:#1a1a1a;border:1px solid #333;color:#666" onclick="document.getElementById('log-viewer').textContent=''">Clear</button>
1176
+ </div>
1177
+ <div id="log-viewer"><span class="info-txt">Select a process above to view its live log...</span></div>
1178
+ </div>
1179
+ </div>
1180
+ <!-- Chat History -->
1181
+ <div class="card">
1182
+ <div class="card-header"><span>💬 Conversation History</span></div>
1183
+ <div class="card-body">
1184
+ <div id="history-box"><div style="color:var(--gray);text-align:center;padding:20px">Loading...</div></div>
1185
+ </div>
1186
+ </div>
1187
+ </div>
1188
+ <footer>🔥 IGNITE V3 PRO &nbsp;·&nbsp; Dashboard auto-refreshes every 3s &nbsp;·&nbsp; <a href="/api/processes" target="_blank" style="color:var(--cyan);text-decoration:none">JSON API</a></footer>
1189
+ <script>
1190
+ let selectedPid='',autoScroll=true;
1191
+ async function fetchJSON(url){try{const r=await fetch(url);return await r.json()}catch{return null}}
1192
+ function colorLog(t){
1193
+ return t.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
1194
+ .replace(/(Traceback|Error:|Exception:|FAILED|SyntaxError|ModuleNotFoundError)/g,'<span class="err">$1</span>')
1195
+ .replace(/(Successfully|started|Running|OK)/gi,'<span class="ok">$1</span>')
1196
+ .replace(/(INFO|WARNING|DEBUG)/g,'<span class="info-txt">$1</span>');
1197
+ }
1198
+ async function fetchLog(){
1199
+ const pid=selectedPid||document.getElementById('pid-select').value;
1200
+ if(!pid)return;
1201
+ const data=await fetchJSON('/api/log/'+pid);
1202
+ const box=document.getElementById('log-viewer');
1203
+ if(data){box.innerHTML=colorLog(data.log||'[No output yet]');if(autoScroll)box.scrollTop=box.scrollHeight;}
1204
+ }
1205
+ async function killProc(pid){if(!confirm('Kill PID '+pid+'?'))return;await fetch('/api/kill/'+pid,{method:'POST'});refresh()}
1206
+ async function killAll(){if(!confirm('Kill ALL managed processes?'))return;await fetch('/api/kill_all',{method:'POST'});refresh()}
1207
+ async function refresh(){
1208
+ const data=await fetchJSON('/api/status');if(!data)return;
1209
+ document.getElementById('s-running').textContent=data.running;
1210
+ document.getElementById('s-total').textContent=data.total;
1211
+ document.getElementById('s-steps').textContent=data.steps;
1212
+ document.getElementById('s-tokens').textContent=data.tokens;
1213
+ document.getElementById('s-model').textContent=data.model;
1214
+ document.getElementById('s-cwd').textContent=data.cwd.split(/[\/\\]/).slice(-2).join('/');
1215
+ document.getElementById('last-refresh').textContent=new Date().toLocaleTimeString();
1216
+ const tbody=document.getElementById('proc-tbody');
1217
+ const sel=document.getElementById('pid-select');
1218
+ sel.innerHTML='<option value="">— select process —</option>';
1219
+ if(!data.processes||!data.processes.length){
1220
+ tbody.innerHTML='<tr><td colspan="7" style="text-align:center;color:var(--gray);padding:20px">No processes spawned yet</td></tr>';
1221
+ }else{
1222
+ tbody.innerHTML=data.processes.map(p=>`<tr>
1223
+ <td><span class="dot ${p.alive?'green':'red'}"></span>${p.alive?'Running':'Stopped'}</td>
1224
+ <td><code>${p.pid}</code></td><td>${p.name}</td>
1225
+ <td><span class="tag ${p.mode}">${p.mode}</span></td>
1226
+ <td style="font-size:11px;color:var(--gray);max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${p.cmd}">${p.cmd}</td>
1227
+ <td style="font-size:11px;color:var(--gray)">${p.started}</td>
1228
+ <td style="display:flex;gap:5px">
1229
+ <button class="btn btn-cyan btn-sm" onclick="selectedPid='${p.pid}';fetchLog()">📋 Log</button>
1230
+ ${p.alive?`<button class="btn btn-red btn-sm" onclick="killProc(${p.pid})">✕ Kill</button>`:''}
1231
+ </td></tr>`).join('');
1232
+ data.processes.forEach(p=>{const o=document.createElement('option');o.value=p.pid;o.text=`PID ${p.pid} — ${p.name}`;sel.appendChild(o)});
1233
+ if(selectedPid)sel.value=selectedPid;
1234
+ }
1235
+ const hb=document.getElementById('history-box');
1236
+ if(data.history&&data.history.length){
1237
+ hb.innerHTML=data.history.filter(m=>m.role!=='system').slice(-20).map(m=>`<div class="msg ${m.role}"><div class="role">${m.role==='user'?'You':'IGNITE'}</div>${m.content.substring(0,300).replace(/</g,'&lt;')}${m.content.length>300?'…':''}</div>`).join('');
1238
+ hb.scrollTop=hb.scrollHeight;
1239
+ }
1240
+ if(selectedPid)fetchLog();
1241
+ }
1242
+ refresh();setInterval(refresh,3000);
1243
+ </script>
1244
+ </body>
1245
+ </html>"""
1246
+
1247
+
1248
+ class _DashboardHandler(http.server.BaseHTTPRequestHandler):
1249
+ session_ref = None
1250
+
1251
+ def log_message(self, *a): pass # silence access logs
1252
+
1253
+ def _send(self, code, ctype, body):
1254
+ self.send_response(code)
1255
+ self.send_header("Content-Type", ctype)
1256
+ self.send_header("Content-Length", str(len(body)))
1257
+ self.send_header("Access-Control-Allow-Origin", "*")
1258
+ self.end_headers()
1259
+ self.wfile.write(body)
1260
+
1261
+ def do_GET(self):
1262
+ path = self.path.split("?")[0]
1263
+ if path in ("/", "/index.html"):
1264
+ self._send(200, "text/html; charset=utf-8", _DASHBOARD_HTML.encode())
1265
+ elif path == "/api/status":
1266
+ self._api_status()
1267
+ elif path.startswith("/api/log/"):
1268
+ self._api_log(path.split("/")[-1])
1269
+ elif path == "/api/processes":
1270
+ self._api_procs()
1271
+ else:
1272
+ self._send(404, "text/plain", b"Not found")
1273
+
1274
+ def do_POST(self):
1275
+ path = self.path.split("?")[0]
1276
+ if path.startswith("/api/kill/"):
1277
+ try:
1278
+ proc_registry.kill(int(path.split("/")[-1]))
1279
+ self._send(200, "application/json", b'{"ok":true}')
1280
+ except Exception as e:
1281
+ self._send(500, "application/json", json.dumps({"error": str(e)}).encode())
1282
+ elif path == "/api/kill_all":
1283
+ for pid in list(proc_registry._procs.keys()):
1284
+ try: proc_registry.kill(pid)
1285
+ except Exception: pass
1286
+ self._send(200, "application/json", b'{"ok":true}')
1287
+ else:
1288
+ self._send(404, "text/plain", b"Not found")
1289
+
1290
+ def _api_status(self):
1291
+ sess = _DashboardHandler.session_ref
1292
+ procs = [
1293
+ {"pid": pid, "name": mp.name, "cmd": mp.cmd, "mode": mp.mode,
1294
+ "alive": mp.is_alive, "started": mp.started_at[:16], "log": mp.log_path}
1295
+ for pid, mp in proc_registry._procs.items()
1296
+ ]
1297
+ history = sess.memory.get_history() if sess else []
1298
+ data = {
1299
+ "running": sum(1 for p in procs if p["alive"]),
1300
+ "total": len(procs),
1301
+ "steps": sess.total_steps if sess else 0,
1302
+ "tokens": tracker.summary().split("|")[0].strip(),
1303
+ "model": router.override_id or "auto",
1304
+ "cwd": str(shell.cwd),
1305
+ "processes": procs,
1306
+ "history": [{"role": m["role"], "content": m["content"][:400]} for m in history[-20:]],
1307
+ }
1308
+ self._send(200, "application/json", json.dumps(data).encode())
1309
+
1310
+ def _api_log(self, pid_str):
1311
+ try:
1312
+ mp = proc_registry._procs.get(int(pid_str))
1313
+ log = mp.tail_log(120) if mp else f"PID {pid_str} not found."
1314
+ self._send(200, "application/json",
1315
+ json.dumps({"pid": pid_str, "log": log, "alive": mp.is_alive if mp else False}).encode())
1316
+ except Exception as e:
1317
+ self._send(500, "application/json", json.dumps({"error": str(e)}).encode())
1318
+
1319
+ def _api_procs(self):
1320
+ procs = [{"pid": pid, "name": mp.name, "alive": mp.is_alive, "cmd": mp.cmd, "mode": mp.mode}
1321
+ for pid, mp in proc_registry._procs.items()]
1322
+ self._send(200, "application/json", json.dumps(procs).encode())
1323
+
1324
+
1325
+ class DashboardServer:
1326
+ """Threaded localhost dashboard — no extra deps, pure stdlib."""
1327
+ def __init__(self, port: int = 7860):
1328
+ self.port = port
1329
+ self._server = None
1330
+ self._thread = None
1331
+ self.running = False
1332
+
1333
+ def start(self, session=None, open_browser: bool = True) -> str:
1334
+ if self.running:
1335
+ return f"Already running → http://localhost:{self.port}"
1336
+ _DashboardHandler.session_ref = session
1337
+ try:
1338
+ self._server = http.server.HTTPServer(("localhost", self.port), _DashboardHandler)
1339
+ except OSError:
1340
+ self.port += 1
1341
+ self._server = http.server.HTTPServer(("localhost", self.port), _DashboardHandler)
1342
+ self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
1343
+ self._thread.start()
1344
+ self.running = True
1345
+ url = f"http://localhost:{self.port}"
1346
+ if open_browser:
1347
+ _webbrowser.open(url)
1348
+ return url
1349
+
1350
+ def stop(self) -> str:
1351
+ if self._server:
1352
+ self._server.shutdown()
1353
+ self.running = False
1354
+ return "Dashboard stopped."
1355
+ return "Dashboard was not running."
1356
+
1357
+ def url(self) -> str:
1358
+ return f"http://localhost:{self.port}" if self.running else "not running"
1359
+
1360
+
1361
+ dashboard = DashboardServer(port=7860)
1362
+
1363
+
1364
+ # ══════════════════════════════════════════════════════════════════════════════
1365
+ # SECTION 7 — WEB SEARCH ENGINE (DuckDuckGo + Brave fallback)
1366
+ # ══════════════════════════════════════════════════════════════════════════════
1367
+ SEARCH_BACKENDS = [
1368
+ ("DuckDuckGo", "https://html.duckduckgo.com/html/?q={q}",
1369
+ r'class="result__snippet[^"]*"[^>]*>(.*?)</a>'),
1370
+ ("Bing", "https://www.bing.com/search?q={q}",
1371
+ r'<p[^>]*>(.*?)</p>'),
1372
+ ]
1373
+
1374
+ def web_search(query: str, n: int = None) -> str:
1375
+ n = n or CFG["search_results"]
1376
+ headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}
1377
+ for backend_name, url_tpl, pattern in SEARCH_BACKENDS:
1378
+ try:
1379
+ url = url_tpl.format(q=urllib.parse.quote_plus(query))
1380
+ req = urllib.request.Request(url, headers=headers)
1381
+ html = urllib.request.urlopen(req, timeout=12).read().decode("utf-8", errors="ignore")
1382
+ snippets = re.findall(pattern, html, re.DOTALL | re.IGNORECASE)
1383
+ clean = [re.sub(r"<[^>]+>", "", s).strip() for s in snippets if len(s) > 20]
1384
+ clean = [c for c in clean if c][:n]
1385
+ if clean:
1386
+ return f"[{backend_name} Results for: {query}]\n" + "\n".join(f" • {c}" for c in clean)
1387
+ except Exception:
1388
+ continue
1389
+ return f"All search backends failed for: {query}"
1390
+
1391
+
1392
+ # ══════════════════════════════════════════════════════════════════════════════
1393
+ # SECTION 8 — TOOL EXECUTOR (10 tools)
1394
+ # ══════════════════════════════════════════════════════════════════════════════
1395
+ class ToolResult:
1396
+ def __init__(self, success: bool, output: str, tool: str):
1397
+ self.success = success
1398
+ self.output = output
1399
+ self.tool = tool
1400
+
1401
+ def tool_terminal(cmd: str) -> ToolResult:
1402
+ real_cmd = obfuscator.unmask(cmd)
1403
+ res = shell.run(real_cmd)
1404
+ return ToolResult(res.ok, res.log, "terminal")
1405
+
1406
+ def tool_file_read(path: str, start: int = 1, end: int = None) -> ToolResult:
1407
+ try:
1408
+ real_path = Path(obfuscator.unmask(path))
1409
+ if not real_path.is_absolute():
1410
+ real_path = shell.cwd / real_path
1411
+ lines = real_path.read_text(errors="ignore").splitlines()
1412
+ s = max(0, start - 1)
1413
+ e = end if end else len(lines)
1414
+ chunk = lines[s:e]
1415
+ if len(chunk) > 200:
1416
+ chunk = chunk[:100] + [f"... [{len(chunk)-200} lines omitted] ..."] + chunk[-100:]
1417
+ return ToolResult(True, "\n".join(chunk), "file_read")
1418
+ except Exception as ex:
1419
+ return ToolResult(False, str(ex), "file_read")
1420
+
1421
+ def tool_file_write(path: str, content: str) -> ToolResult:
1422
+ try:
1423
+ real_path = Path(obfuscator.unmask(path))
1424
+ if not real_path.is_absolute():
1425
+ real_path = shell.cwd / real_path
1426
+
1427
+ # ── AST Pre-Flight Syntax Gatekeeper ──
1428
+ if real_path.suffix == ".py":
1429
+ try:
1430
+ ast.parse(content)
1431
+ except SyntaxError as syn_err:
1432
+ lines = content.splitlines()
1433
+ snippet = ""
1434
+ if syn_err.lineno and 1 <= syn_err.lineno <= len(lines):
1435
+ s_l = max(1, syn_err.lineno - 2)
1436
+ e_l = min(len(lines), syn_err.lineno + 2)
1437
+ snippet = "\n".join(f"{'>>' if i == syn_err.lineno else ' '} {i}: {lines[i-1]}" for i in range(s_l, e_l + 1))
1438
+ return ToolResult(
1439
+ False,
1440
+ f"❌ AST PRE-FLIGHT SYNTAX ERROR in '{path}' (Line {syn_err.lineno}, Col {syn_err.offset}):\n"
1441
+ f"{snippet}\n"
1442
+ f"Error: {syn_err.msg}\n"
1443
+ f"👉 Fix this syntax error and rewrite the code properly before saving.",
1444
+ "file_write"
1445
+ )
1446
+ elif real_path.suffix == ".json":
1447
+ try:
1448
+ json.loads(content)
1449
+ except Exception as j_err:
1450
+ return ToolResult(
1451
+ False,
1452
+ f"❌ JSON SYNTAX ERROR in '{path}': {j_err}. Please fix formatting before saving.",
1453
+ "file_write"
1454
+ )
1455
+
1456
+ # ── Shadow Checkpoint Backup ──
1457
+ if 'git_shield' in globals():
1458
+ git_shield.backup_file(real_path)
1459
+
1460
+ real_path.parent.mkdir(parents=True, exist_ok=True)
1461
+ real_path.write_text(content, encoding="utf-8")
1462
+ return ToolResult(True, f"Written {len(content)} chars → {real_path}", "file_write")
1463
+ except Exception as ex:
1464
+ return ToolResult(False, str(ex), "file_write")
1465
+
1466
+ def tool_file_patch(path: str, diff: str) -> ToolResult:
1467
+ """Apply a unified-diff style patch to a file with AST safety."""
1468
+ try:
1469
+ real_path = Path(obfuscator.unmask(path))
1470
+ if not real_path.is_absolute():
1471
+ real_path = shell.cwd / real_path
1472
+ if not real_path.exists():
1473
+ return ToolResult(False, f"File not found to patch: {path}", "file_patch")
1474
+
1475
+ # ── Shadow Checkpoint Backup ──
1476
+ if 'git_shield' in globals():
1477
+ git_shield.backup_file(real_path)
1478
+
1479
+ original = real_path.read_text(encoding="utf-8", errors="ignore").splitlines(keepends=True)
1480
+ patch_lines = diff.splitlines(keepends=True)
1481
+ result = list(difflib.restore(patch_lines, 2)) # restore new file from diff
1482
+ if result:
1483
+ patched_content = "".join(result)
1484
+ if real_path.suffix == ".py":
1485
+ try:
1486
+ ast.parse(patched_content)
1487
+ except SyntaxError as syn_err:
1488
+ return ToolResult(
1489
+ False,
1490
+ f"❌ AST SYNTAX ERROR after patch in '{path}' (Line {syn_err.lineno}): {syn_err.msg}. "
1491
+ f"Patch rejected to preserve valid syntax.",
1492
+ "file_patch"
1493
+ )
1494
+ real_path.write_text(patched_content, encoding="utf-8")
1495
+ return ToolResult(True, f"Patched {real_path} ({len(result)} lines)", "file_patch")
1496
+ return ToolResult(False, "Patch produced empty output — check diff format", "file_patch")
1497
+ except Exception as ex:
1498
+ return ToolResult(False, str(ex), "file_patch")
1499
+
1500
+ def tool_git(subcmd: str) -> ToolResult:
1501
+ res = shell.git_op(obfuscator.unmask(subcmd))
1502
+ return ToolResult(res.ok, res.log, "git")
1503
+
1504
+ def tool_git_diff(path: str = "") -> ToolResult:
1505
+ """Inspect uncommitted code changes in working tree."""
1506
+ diff_text = git_shield.diff(path)
1507
+ return ToolResult(True, diff_text, "git_diff")
1508
+
1509
+ def tool_verify_code(cmd: str = "", path: str = "") -> ToolResult:
1510
+ """
1511
+ Self-Healing Code Verification Tool.
1512
+ Auto-detects test runners (pytest, unittest, npm test, cargo test, go test)
1513
+ or runs the specified test command / compilation check.
1514
+ """
1515
+ test_cmd = cmd.strip()
1516
+ if not test_cmd:
1517
+ cwd = shell.cwd
1518
+ if path:
1519
+ target_p = Path(path)
1520
+ if target_p.suffix == ".py":
1521
+ test_cmd = f"python -m py_compile \"{path}\""
1522
+ elif (cwd / "pytest.ini").exists() or (cwd / "tests").is_dir() or any((cwd).glob("test_*.py")):
1523
+ test_cmd = "pytest -q"
1524
+ elif any((cwd).glob("*_test.py")):
1525
+ test_cmd = "python -m unittest discover -s . -p \"*test*.py\""
1526
+ elif (cwd / "package.json").exists():
1527
+ test_cmd = "npm test"
1528
+ elif (cwd / "Cargo.toml").exists():
1529
+ test_cmd = "cargo test"
1530
+ elif (cwd / "go.mod").exists():
1531
+ test_cmd = "go test ./..."
1532
+ else:
1533
+ test_cmd = "python -m compileall -q ."
1534
+
1535
+ res = shell.run(test_cmd, timeout=90)
1536
+ if res.ok:
1537
+ return ToolResult(
1538
+ True,
1539
+ f"✅ VERIFICATION PASSED ({test_cmd}):\n{res.stdout.strip() or 'All checks and tests passed (Exit code 0).'}",
1540
+ "verify_code"
1541
+ )
1542
+ else:
1543
+ return ToolResult(
1544
+ False,
1545
+ f"❌ VERIFICATION FAILED (Exit Code {res.code}) running: `{test_cmd}`\n"
1546
+ f"Output / Traceback:\n{res.log}\n\n"
1547
+ f"👉 SELF-HEALING ACTION: Examine the failure above, patch the code with file_write or file_patch, and run verify_code again.",
1548
+ "verify_code"
1549
+ )
1550
+
1551
+ def tool_python_eval(code: str) -> ToolResult:
1552
+ """Safe-ish sandboxed Python eval (no builtins that can spawn processes)."""
1553
+ BLOCKED = {"__import__", "exec", "eval", "open", "os", "sys", "subprocess", "importlib"}
1554
+ for b in BLOCKED:
1555
+ if b in code:
1556
+ return ToolResult(False, f"Blocked keyword in python_eval: {b}", "python_eval")
1557
+ import io, contextlib
1558
+ buf = io.StringIO()
1559
+ try:
1560
+ with contextlib.redirect_stdout(buf), contextlib.redirect_stderr(buf):
1561
+ exec(compile(code, "<ignite_eval>", "exec"),
1562
+ {"__builtins__": {"print": print, "len": len, "range": range,
1563
+ "str": str, "int": int, "float": float,
1564
+ "list": list, "dict": dict, "set": set,
1565
+ "sorted": sorted, "sum": sum, "min": min, "max": max,
1566
+ "abs": abs, "round": round, "enumerate": enumerate,
1567
+ "zip": zip, "map": map, "filter": filter}})
1568
+ return ToolResult(True, buf.getvalue() or "[No output]", "python_eval")
1569
+ except Exception as ex:
1570
+ return ToolResult(False, f"{type(ex).__name__}: {ex}", "python_eval")
1571
+
1572
+ def tool_search(query: str) -> ToolResult:
1573
+ real_query = obfuscator.unmask(query)
1574
+ results = web_search(real_query)
1575
+ return ToolResult(True, obfuscator.mask(results), "search")
1576
+
1577
+ # ══════════════════════════════════════════════════════════════════════════════
1578
+ # SECTION 7b — CLOUD DEPLOY ENGINE (Railway · Render · Fly.io · Docker · Heroku · Vercel)
1579
+ # ══════════════════════════════════════════════════════════════════════════════
1580
+ class CloudDeployEngine:
1581
+ """Intelligent Multi-Cloud Deployment Engine.
1582
+ Auto-detects project stack, creates production deployment configs (Dockerfile, Procfile,
1583
+ railway.toml, render.yaml, fly.toml), syncs .env secrets, and runs cloud deployments.
1584
+ """
1585
+
1586
+ PLATFORMS = {
1587
+ "railway": {
1588
+ "name": "Railway",
1589
+ "cli": "railway",
1590
+ "install": "npm install -g @railway/cli",
1591
+ "desc": "Auto-deploy with env vars, persistent workers & zero downtime",
1592
+ },
1593
+ "render": {
1594
+ "name": "Render",
1595
+ "cli": "git",
1596
+ "install": "Connect Git repo at https://dashboard.render.com",
1597
+ "desc": "Infrastructure as code via render.yaml",
1598
+ },
1599
+ "fly": {
1600
+ "name": "Fly.io",
1601
+ "cli": "flyctl",
1602
+ "install": 'powershell -Command "iwr https://fly.io/install.ps1 -useb | iex"',
1603
+ "desc": "Fast global Docker / MicroVM hosting",
1604
+ },
1605
+ "docker": {
1606
+ "name": "Docker",
1607
+ "cli": "docker",
1608
+ "install": "Install Docker Desktop: https://www.docker.com/products/docker-desktop",
1609
+ "desc": "Local or server containerized execution",
1610
+ },
1611
+ "heroku": {
1612
+ "name": "Heroku",
1613
+ "cli": "heroku",
1614
+ "install": "winget install Heroku.CLI",
1615
+ "desc": "Classic cloud PaaS via Git push",
1616
+ },
1617
+ "vercel": {
1618
+ "name": "Vercel",
1619
+ "cli": "vercel",
1620
+ "install": "npm install -g vercel",
1621
+ "desc": "Serverless edge & frontend deployment",
1622
+ },
1623
+ }
1624
+
1625
+ def detect_entrypoint(self, cwd: Path) -> Tuple[str, str]:
1626
+ """Returns (entrypoint_filename, service_type: 'worker' | 'web')"""
1627
+ try:
1628
+ files = [f.name.lower() for f in cwd.iterdir() if f.is_file()]
1629
+ except Exception:
1630
+ files = []
1631
+ if "bot.py" in files:
1632
+ return "bot.py", "worker"
1633
+ if "app.py" in files:
1634
+ return "app.py", "web"
1635
+ if "main.py" in files:
1636
+ try:
1637
+ content = (cwd / "main.py").read_text(errors="ignore")
1638
+ if "fastapi" in content.lower() or "flask" in content.lower():
1639
+ return "main.py", "web"
1640
+ except Exception:
1641
+ pass
1642
+ return "main.py", "worker"
1643
+ if "index.js" in files or "server.js" in files:
1644
+ return "index.js" if "index.js" in files else "server.js", "web"
1645
+ return "bot.py" if (cwd / "bot.py").exists() else "main.py", "worker"
1646
+
1647
+ def scaffold(self, platform: str, cwd: Path) -> List[str]:
1648
+ """Auto-generates production config files for the target cloud platform."""
1649
+ created = []
1650
+ entrypoint, service_type = self.detect_entrypoint(cwd)
1651
+
1652
+ # 1. .dockerignore (always useful)
1653
+ dockerignore = cwd / ".dockerignore"
1654
+ if not dockerignore.exists():
1655
+ dockerignore.write_text(
1656
+ "__pycache__/\n*.pyc\n*.pyo\n.git/\n.env\n*.log\nvenv/\n.venv/\n",
1657
+ encoding="utf-8"
1658
+ )
1659
+ created.append(".dockerignore")
1660
+
1661
+ # 2. Platform specific configs
1662
+ if platform == "railway":
1663
+ railway_toml = cwd / "railway.toml"
1664
+ if not railway_toml.exists():
1665
+ railway_toml.write_text(
1666
+ f'[build]\nbuilder = "NIXPACKS"\n\n'
1667
+ f'[deploy]\nstartCommand = "python {entrypoint}"\n'
1668
+ f'restartPolicyType = "ON_FAILURE"\n'
1669
+ f'restartPolicyMaxRetries = 10\n',
1670
+ encoding="utf-8"
1671
+ )
1672
+ created.append("railway.toml")
1673
+ procfile = cwd / "Procfile"
1674
+ if not procfile.exists():
1675
+ cmd = f"python {entrypoint}" if service_type == "worker" else f"uvicorn {entrypoint[:-3]}:app --host 0.0.0.0 --port $PORT"
1676
+ procfile.write_text(f"{service_type}: {cmd}\n", encoding="utf-8")
1677
+ created.append("Procfile")
1678
+
1679
+ elif platform == "render":
1680
+ render_yaml = cwd / "render.yaml"
1681
+ if not render_yaml.exists():
1682
+ start_cmd = f"python {entrypoint}" if service_type == "worker" else f"uvicorn {entrypoint[:-3]}:app --host 0.0.0.0 --port $PORT"
1683
+ render_yaml.write_text(
1684
+ f"services:\n"
1685
+ f" - type: {service_type}\n"
1686
+ f" name: {cwd.name.lower() or 'ignite-app'}\n"
1687
+ f" env: python\n"
1688
+ f" buildCommand: pip install -r requirements.txt\n"
1689
+ f" startCommand: {start_cmd}\n"
1690
+ f" autoDeploy: true\n",
1691
+ encoding="utf-8"
1692
+ )
1693
+ created.append("render.yaml")
1694
+
1695
+ elif platform in ("docker", "fly"):
1696
+ dockerfile = cwd / "Dockerfile"
1697
+ if not dockerfile.exists():
1698
+ start_cmd = f'CMD ["python", "{entrypoint}"]'
1699
+ dockerfile.write_text(
1700
+ f"FROM python:3.11-slim\n"
1701
+ f"WORKDIR /app\n"
1702
+ f"ENV PYTHONUNBUFFERED=1\n"
1703
+ f"COPY requirements.txt .\n"
1704
+ f"RUN pip install --no-cache-dir -r requirements.txt\n"
1705
+ f"COPY . .\n"
1706
+ f"{start_cmd}\n",
1707
+ encoding="utf-8"
1708
+ )
1709
+ created.append("Dockerfile")
1710
+ if platform == "fly":
1711
+ fly_toml = cwd / "fly.toml"
1712
+ if not fly_toml.exists():
1713
+ fly_toml.write_text(
1714
+ f'app = "{cwd.name.lower() or "ignite-app"}"\n'
1715
+ f'primary_region = "iad"\n\n'
1716
+ f'[build]\n\n'
1717
+ f'[env]\n PYTHONUNBUFFERED = "1"\n',
1718
+ encoding="utf-8"
1719
+ )
1720
+ created.append("fly.toml")
1721
+
1722
+ elif platform == "heroku":
1723
+ procfile = cwd / "Procfile"
1724
+ if not procfile.exists():
1725
+ cmd = f"python {entrypoint}" if service_type == "worker" else f"uvicorn {entrypoint[:-3]}:app --host 0.0.0.0 --port $PORT"
1726
+ procfile.write_text(f"{service_type}: {cmd}\n", encoding="utf-8")
1727
+ created.append("Procfile")
1728
+
1729
+ elif platform == "vercel":
1730
+ vercel_json = cwd / "vercel.json"
1731
+ if not vercel_json.exists():
1732
+ vercel_json.write_text('{\n "version": 2\n}\n', encoding="utf-8")
1733
+ created.append("vercel.json")
1734
+
1735
+ return created
1736
+
1737
+ def check_cli(self, cli_name: str) -> bool:
1738
+ if shutil.which(cli_name):
1739
+ return True
1740
+ res = shell.run(f"{cli_name} --version")
1741
+ return res.ok
1742
+
1743
+ def ensure_git(self, cwd: Path) -> str:
1744
+ """Initializes Git repo and commits if not already initialized."""
1745
+ if not (cwd / ".git").exists():
1746
+ shell.run("git init", timeout=30)
1747
+ shell.run("git add .", timeout=30)
1748
+ shell.run('git commit -m "chore: initial commit for deployment"', timeout=30)
1749
+ return "Initialized git repository."
1750
+ return ""
1751
+
1752
+ def deploy(self, platform: str = "", subcmd: str = "", cwd: Path = None) -> ToolResult:
1753
+ cwd = cwd or shell.cwd
1754
+
1755
+ # Resolve target platform
1756
+ target = platform.lower().strip()
1757
+ if not target and subcmd:
1758
+ for p in self.PLATFORMS:
1759
+ if p in subcmd.lower():
1760
+ target = p
1761
+ break
1762
+ if not target:
1763
+ if (cwd / "railway.toml").exists(): target = "railway"
1764
+ elif (cwd / "render.yaml").exists(): target = "render"
1765
+ elif (cwd / "fly.toml").exists(): target = "fly"
1766
+ elif (cwd / "Dockerfile").exists(): target = "docker"
1767
+ elif (cwd / "Procfile").exists(): target = "railway"
1768
+ elif (cwd / "vercel.json").exists(): target = "vercel"
1769
+ else: target = "railway" # default to Railway for bots
1770
+
1771
+ if target not in self.PLATFORMS:
1772
+ return ToolResult(
1773
+ False,
1774
+ f"Unknown platform '{target}'. Supported: {', '.join(self.PLATFORMS.keys())}\n\n"
1775
+ f"Usage: /deploy <railway|render|fly|docker|heroku|vercel>",
1776
+ "deploy"
1777
+ )
1778
+
1779
+ plat_info = self.PLATFORMS[target]
1780
+ scaffolded = self.scaffold(target, cwd)
1781
+ git_note = self.ensure_git(cwd)
1782
+
1783
+ # ── 1. RAILWAY DEPLOY ──────────────────────────────────────────────────
1784
+ if target == "railway":
1785
+ if not self.check_cli("railway"):
1786
+ msg = (
1787
+ f"⚠ Railway CLI not found.\n"
1788
+ f"To install Railway CLI, run in PowerShell:\n"
1789
+ f" npm install -g @railway/cli\n"
1790
+ f" railway login\n\n"
1791
+ f"Config files auto-generated: {', '.join(scaffolded) if scaffolded else 'railway.toml & Procfile ready'}\n"
1792
+ f"Once logged in, run: /deploy railway"
1793
+ )
1794
+ return ToolResult(False, msg, "deploy")
1795
+
1796
+ # Check authentication
1797
+ whoami = shell.run("railway whoami")
1798
+ if not whoami.ok or "not logged in" in whoami.log.lower():
1799
+ return ToolResult(
1800
+ False,
1801
+ f"⚠ You are not logged into Railway.\n"
1802
+ f"Please run in PowerShell: railway login\n"
1803
+ f"Then run: /deploy railway",
1804
+ "deploy"
1805
+ )
1806
+
1807
+ # Auto-sync .env variables to Railway
1808
+ env_file = cwd / ".env"
1809
+ env_synced = 0
1810
+ if env_file.exists():
1811
+ for line in env_file.read_text(errors="ignore").splitlines():
1812
+ line = line.strip()
1813
+ if line and not line.startswith("#") and "=" in line:
1814
+ k, v = line.split("=", 1)
1815
+ k, v = k.strip(), v.strip().strip("\"'")
1816
+ if k and v:
1817
+ shell.run(f'railway variables --set "{k}={v}"')
1818
+ env_synced += 1
1819
+
1820
+ # Trigger deployment
1821
+ cmd = subcmd or "railway up --detach"
1822
+ res = shell.run(cmd, timeout=180)
1823
+ url_res = shell.run("railway domain")
1824
+ domain = url_res.stdout.strip() if url_res.ok else ""
1825
+
1826
+ msg = (
1827
+ f"🚀 [Railway Deploy Triggered]\n"
1828
+ f" Account : {whoami.stdout.strip()}\n"
1829
+ f" Configs : {', '.join(scaffolded) or 'Existing configs used'}\n"
1830
+ f" Env Vars: {env_synced} synced from .env\n"
1831
+ f" Command : {cmd}\n"
1832
+ f" Status : {'✔ Success' if res.ok else '✖ Failed'}\n"
1833
+ f" Output :\n{res.log}\n"
1834
+ )
1835
+ if domain:
1836
+ msg += f"\n🌐 Live Domain: https://{domain}"
1837
+ return ToolResult(res.ok, msg, "deploy")
1838
+
1839
+ # ── 2. DOCKER DEPLOY ───────────────────────────────────────────────────
1840
+ elif target == "docker":
1841
+ if not self.check_cli("docker"):
1842
+ return ToolResult(
1843
+ False,
1844
+ "⚠ Docker is not installed or Docker Desktop is not running.\n"
1845
+ "Install from: https://www.docker.com/products/docker-desktop",
1846
+ "deploy"
1847
+ )
1848
+ app_name = (cwd.name.lower() or "ignite_app").replace(" ", "_")
1849
+ if (cwd / "docker-compose.yml").exists():
1850
+ cmd = subcmd or "docker compose up --build -d"
1851
+ else:
1852
+ cmd = subcmd or f"docker build -t {app_name} . && docker run -d --restart unless-stopped --name {app_name} {app_name}"
1853
+ res = shell.run(cmd, timeout=300)
1854
+ return ToolResult(
1855
+ res.ok,
1856
+ f"🐳 [Docker Container Deploy]\n"
1857
+ f" Configs : {', '.join(scaffolded) or 'Existing Dockerfile'}\n"
1858
+ f" Command : {cmd}\n"
1859
+ f" Status : {'✔ Running in background' if res.ok else '✖ Failed'}\n"
1860
+ f" Output :\n{res.log}",
1861
+ "deploy"
1862
+ )
1863
+
1864
+ # ── 3. FLY.IO DEPLOY ───────────────────────────────────────────────────
1865
+ elif target == "fly":
1866
+ cli = "flyctl" if self.check_cli("flyctl") else "fly"
1867
+ if not self.check_cli(cli):
1868
+ return ToolResult(
1869
+ False,
1870
+ f"⚠ Fly CLI not found.\n"
1871
+ f"Install in PowerShell:\n"
1872
+ f" iwr https://fly.io/install.ps1 -useb | iex\n"
1873
+ f"Then run: {cli} auth login",
1874
+ "deploy"
1875
+ )
1876
+ cmd = subcmd or f"{cli} deploy"
1877
+ res = shell.run(cmd, timeout=300)
1878
+ return ToolResult(
1879
+ res.ok,
1880
+ f"🎈 [Fly.io Deploy]\n"
1881
+ f" Configs : {', '.join(scaffolded) or 'fly.toml'}\n"
1882
+ f" Command : {cmd}\n"
1883
+ f" Status : {'✔ Deployed' if res.ok else '✖ Failed'}\n"
1884
+ f" Output :\n{res.log}",
1885
+ "deploy"
1886
+ )
1887
+
1888
+ # ── 4. RENDER DEPLOY ───────────────────────────────────────────────────
1889
+ elif target == "render":
1890
+ msg = (
1891
+ f"🚀 [Render Deployment Ready]\n"
1892
+ f" Render uses Git-backed infrastructure-as-code.\n"
1893
+ f" Config generated: {', '.join(scaffolded) or 'render.yaml'}\n\n"
1894
+ f"To deploy on Render:\n"
1895
+ f" 1. Push this project to GitHub: git push origin main\n"
1896
+ f" 2. Go to https://dashboard.render.com -> New -> Blueprint\n"
1897
+ f" 3. Select your repo — Render will auto-detect render.yaml and deploy!"
1898
+ )
1899
+ return ToolResult(True, msg, "deploy")
1900
+
1901
+ # ── 5. HEROKU DEPLOY ───────────────────────────────────────────────────
1902
+ elif target == "heroku":
1903
+ if not self.check_cli("heroku"):
1904
+ return ToolResult(False, "⚠ Heroku CLI not found. Run: winget install Heroku.CLI", "deploy")
1905
+ cmd = subcmd or "git push heroku main"
1906
+ res = shell.run(cmd, timeout=240)
1907
+ return ToolResult(res.ok, f"Heroku Deploy:\n{res.log}", "deploy")
1908
+
1909
+ # ── 6. VERCEL DEPLOY ───────────────────────────────────────────────────
1910
+ elif target == "vercel":
1911
+ if not self.check_cli("vercel"):
1912
+ return ToolResult(False, "⚠ Vercel CLI not found. Run: npm install -g vercel", "deploy")
1913
+ cmd = subcmd or "vercel --prod"
1914
+ res = shell.run(cmd, timeout=180)
1915
+ return ToolResult(res.ok, f"Vercel Deploy:\n{res.log}", "deploy")
1916
+
1917
+ return ToolResult(False, f"Unsupported platform: {target}", "deploy")
1918
+
1919
+ def help_menu(self) -> str:
1920
+ lines = [
1921
+ f"{C.BCYN}╔══════════════════════════════════════════════════════════════════╗{C.RST}",
1922
+ f"{C.BCYN}║ 🌍 IGNITE Cloud Deployer — Supported Platforms ║{C.RST}",
1923
+ f"{C.BCYN}╠══════════════════════════════════════════════════════════════════╣{C.RST}",
1924
+ ]
1925
+ for key, p in self.PLATFORMS.items():
1926
+ lines.append(f"║ {C.YEL}/deploy {key:<10}{C.RST} ▸ {p['name']:<8} — {p['desc']:<32} ║")
1927
+ lines.append(f"{C.BCYN}╚══════════════════════════════════════════════════════════════════╝{C.RST}")
1928
+ lines.append(f"\n{C.DIM}Example: /deploy railway | /deploy docker | /deploy fly{C.RST}")
1929
+ return "\n".join(lines)
1930
+
1931
+ deploy_engine = CloudDeployEngine()
1932
+
1933
+ def tool_deploy(subcmd: str = "", platform: str = "") -> ToolResult:
1934
+ """Deploy current project to cloud platform (Railway, Render, Fly.io, Heroku, Docker, Vercel).
1935
+ Auto-detects project structure, generates required config files, syncs .env, and deploys.
1936
+ """
1937
+ return deploy_engine.deploy(platform=platform, subcmd=subcmd, cwd=shell.cwd)
1938
+
1939
+ ERROR_KEYWORDS = ["Traceback (most recent call last)", "Error:", "Exception:", "FAILED",
1940
+ "ModuleNotFoundError", "SyntaxError", "ImportError", "AttributeError",
1941
+ "TypeError", "ValueError", "FileNotFoundError", "PermissionError",
1942
+ "ConnectionError", "OSError", "RuntimeError"]
1943
+
1944
+ def tool_spawn(cmd: str, name: str = "", mode: str = "window") -> ToolResult:
1945
+ """Spawn a script/process. mode='window' opens new terminal, mode='background' runs silently.
1946
+ For window mode: waits up to 8 seconds after spawn, reads log, and reports any errors found.
1947
+ """
1948
+ real_cmd = obfuscator.unmask(cmd)
1949
+ real_name = name or real_cmd[:40]
1950
+ cwd = str(shell.cwd)
1951
+
1952
+ if mode == "background":
1953
+ mp = proc_registry.spawn_background(real_cmd, cwd, real_name)
1954
+ msg = (f"✔ Background process started!\n"
1955
+ f" PID : {mp.pid}\n"
1956
+ f" Name : {mp.name}\n"
1957
+ f" Log : {mp.log_path}\n"
1958
+ f" Use read_log with pid={mp.pid} to check output.")
1959
+ return ToolResult(True, msg, "spawn")
1960
+
1961
+ # ── Window mode ───────────────────────────────────────────────────────────
1962
+ pid, spawn_msg = proc_registry.spawn_window(real_cmd, cwd, real_name)
1963
+ if pid < 0:
1964
+ return ToolResult(False, spawn_msg, "spawn")
1965
+
1966
+ mp = proc_registry._procs.get(pid)
1967
+ print(f"{C.DIM} ⏳ Waiting for process to settle (5s)...{C.RST}")
1968
+
1969
+ # Poll up to 8 seconds for log to appear and process to either crash or stabilise
1970
+ deadline = time.time() + 8
1971
+ log_content = ""
1972
+ while time.time() < deadline:
1973
+ time.sleep(1)
1974
+ if mp:
1975
+ log_content = mp.tail_log(60)
1976
+ # If log has content and process is no longer alive → crashed early
1977
+ if log_content.strip() and log_content != "[No output yet]":
1978
+ if not mp.is_alive:
1979
+ break # definitely finished — either success or crash
1980
+ # If alive and we have some output, 5s is enough to confirm startup
1981
+ if mp.is_alive and log_content.strip() and log_content != "[No output yet]":
1982
+ if time.time() > (deadline - 3): # waited at least 5s
1983
+ break
1984
+
1985
+ # Refresh log after wait
1986
+ if mp:
1987
+ log_content = mp.tail_log(80)
1988
+ is_alive = mp.is_alive
1989
+ else:
1990
+ log_content = "[No log available]"
1991
+ is_alive = False
1992
+
1993
+ # Scan for error patterns
1994
+ has_error = any(kw in log_content for kw in ERROR_KEYWORDS)
1995
+
1996
+ status_icon = "🟢 RUNNING" if is_alive else "🔴 STOPPED"
1997
+
1998
+ if has_error:
1999
+ error_snippet = "\n".join(
2000
+ line for line in log_content.splitlines()
2001
+ if any(kw in line for kw in ERROR_KEYWORDS)
2002
+ )[:600]
2003
+ full_msg = (
2004
+ f"⚠ Process spawned but ERRORS detected!\n"
2005
+ f" PID : {pid}\n"
2006
+ f" Status : {status_icon}\n"
2007
+ f" Errors :\n{error_snippet}\n\n"
2008
+ f"--- Full log (last 30 lines) ---\n{mp.tail_log(30) if mp else log_content}"
2009
+ )
2010
+ return ToolResult(False, full_msg, "spawn")
2011
+
2012
+ if not is_alive and log_content.strip() and log_content != "[No output yet]":
2013
+ # Process finished — could be a one-shot script (OK) or crash without error keyword
2014
+ # Check return code heuristically by looking for obvious failure words
2015
+ looks_failed = any(w in log_content.lower() for w in ["failed", "cannot", "denied", "not found"])
2016
+ status_msg = (
2017
+ f"{'⚠ Process finished (may have failed)' if looks_failed else '✔ Process completed'}\n"
2018
+ f" PID : {pid}\n"
2019
+ f" Status : {status_icon}\n\n"
2020
+ f"--- Output ---\n{mp.tail_log(30) if mp else log_content}"
2021
+ )
2022
+ return ToolResult(not looks_failed, status_msg, "spawn")
2023
+
2024
+ # All good — running with no errors
2025
+ success_msg = (
2026
+ f"✔ Process launched successfully!\n"
2027
+ f" PID : {pid}\n"
2028
+ f" Status : {status_icon}\n"
2029
+ f" Window : IGNITE [{real_name}]\n\n"
2030
+ f"--- Startup output (last 20 lines) ---\n{mp.tail_log(20) if mp else log_content}"
2031
+ )
2032
+ return ToolResult(True, success_msg, "spawn")
2033
+
2034
+ def tool_kill(pid: int = None, name: str = "") -> ToolResult:
2035
+ """Kill a process by PID or name."""
2036
+ if pid and pid > 0:
2037
+ result = proc_registry.kill(pid)
2038
+ elif name:
2039
+ result = proc_registry.kill_by_name(obfuscator.unmask(name))
2040
+ else:
2041
+ result = "Error: provide pid or name to kill"
2042
+ return ToolResult(True, result, "kill")
2043
+
2044
+ def tool_process_status(pid: int = None, tail: int = 60) -> ToolResult:
2045
+ """List all tracked processes or read output log of a specific PID."""
2046
+ output = proc_registry.status(pid, tail)
2047
+ return ToolResult(True, output, "process_status")
2048
+
2049
+ def tool_read_log(pid: int, tail: int = 80) -> ToolResult:
2050
+ """Read the latest output from a spawned process log."""
2051
+ mp = proc_registry._procs.get(pid)
2052
+ if not mp:
2053
+ return ToolResult(False, f"PID {pid} not tracked.", "read_log")
2054
+ alive = "🟢 RUNNING" if mp.is_alive else "🔴 STOPPED"
2055
+ return ToolResult(True, f"{alive} | {mp.name}\n{mp.tail_log(tail)}", "read_log")
2056
+
2057
+
2058
+ # ══════════════════════════════════════════════════════════════════════════════
2059
+ # SECTION 7c — SEMANTIC CODEBASE INDEXER & RAG (Zero-Dep BM25 + AST Symbols)
2060
+ # ══════════════════════════════════════════════════════════════════════════════
2061
+ class CodebaseIndexer:
2062
+ """
2063
+ High-performance, zero-dependency codebase indexer and semantic search engine.
2064
+ - Uses SQLite FTS5 for BM25 ranking and fast full-text search.
2065
+ - Python AST extraction parses classes, methods, functions, docstrings, and signatures.
2066
+ - Incremental cache tracking file mtime/hash so re-indexes only touch changed files.
2067
+ """
2068
+ EXTENSIONS = {
2069
+ ".py", ".js", ".jsx", ".ts", ".tsx", ".json", ".html", ".css", ".scss",
2070
+ ".md", ".yml", ".yaml", ".toml", ".txt", ".sh", ".bat", ".ps1", ".rs",
2071
+ ".go", ".java", ".c", ".cpp", ".h", ".hpp", ".sql"
2072
+ }
2073
+ IGNORE_DIRS = {
2074
+ ".git", "node_modules", "venv", ".venv", "env", "__pycache__", "dist",
2075
+ "build", ".ignite_scratch", ".idea", ".vscode", "coverage", ".next"
2076
+ }
2077
+
2078
+ def __init__(self, workspace: Path):
2079
+ self.workspace = Path(workspace)
2080
+ self.db_path = self._get_db_path()
2081
+ self._lock = threading.RLock()
2082
+ self._init_db()
2083
+
2084
+ def set_workspace(self, new_ws: Path):
2085
+ with self._lock:
2086
+ self.workspace = Path(new_ws)
2087
+ self.db_path = self._get_db_path()
2088
+ self._init_db()
2089
+
2090
+ def _get_db_path(self) -> Path:
2091
+ scratch = self.workspace / ".ignite_scratch"
2092
+ scratch.mkdir(parents=True, exist_ok=True)
2093
+ return scratch / "code_index.db"
2094
+
2095
+ def _get_conn(self) -> sqlite3.Connection:
2096
+ conn = sqlite3.connect(str(self.db_path), timeout=15)
2097
+ conn.row_factory = sqlite3.Row
2098
+ return conn
2099
+
2100
+ def _init_db(self):
2101
+ with self._lock:
2102
+ try:
2103
+ conn = self._get_conn()
2104
+ with conn:
2105
+ conn.execute("""
2106
+ CREATE TABLE IF NOT EXISTS files (
2107
+ path TEXT PRIMARY KEY,
2108
+ mtime REAL,
2109
+ size INTEGER,
2110
+ hash TEXT,
2111
+ indexed_at REAL
2112
+ )
2113
+ """)
2114
+ conn.execute("""
2115
+ CREATE VIRTUAL TABLE IF NOT EXISTS code_fts USING fts5(
2116
+ file_path UNINDEXED,
2117
+ symbol_name,
2118
+ symbol_type UNINDEXED,
2119
+ start_line UNINDEXED,
2120
+ end_line UNINDEXED,
2121
+ content,
2122
+ tokenize='porter unicode61'
2123
+ )
2124
+ """)
2125
+ conn.close()
2126
+ except Exception:
2127
+ pass
2128
+
2129
+ def _extract_chunks(self, file_path: Path, rel_path: str) -> List[Dict[str, Any]]:
2130
+ chunks: List[Dict[str, Any]] = []
2131
+ try:
2132
+ raw_bytes = file_path.read_bytes()
2133
+ if b"\x00" in raw_bytes[:1024]: # binary file check
2134
+ return []
2135
+ text = raw_bytes.decode("utf-8", errors="replace")
2136
+ except Exception:
2137
+ return []
2138
+
2139
+ lines = text.splitlines()
2140
+ if not lines:
2141
+ return []
2142
+
2143
+ # 1. If Python, attempt AST symbol extraction
2144
+ if file_path.suffix.lower() == ".py":
2145
+ try:
2146
+ tree = ast.parse(text)
2147
+ for node in ast.iter_child_nodes(tree):
2148
+ if isinstance(node, ast.ClassDef):
2149
+ s_line = node.lineno
2150
+ e_line = getattr(node, "end_lineno", s_line)
2151
+ doc = ast.get_docstring(node) or ""
2152
+ snippet = "\n".join(lines[s_line - 1 : min(e_line, s_line + 40)])
2153
+ chunks.append({
2154
+ "symbol_name": node.name,
2155
+ "symbol_type": "class",
2156
+ "start_line": s_line,
2157
+ "end_line": e_line,
2158
+ "content": f"class {node.name}\n{doc}\n{snippet}"
2159
+ })
2160
+ for item in node.body:
2161
+ if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
2162
+ ms_line = item.lineno
2163
+ me_line = getattr(item, "end_lineno", ms_line)
2164
+ mdoc = ast.get_docstring(item) or ""
2165
+ msnippet = "\n".join(lines[ms_line - 1 : min(me_line, ms_line + 35)])
2166
+ chunks.append({
2167
+ "symbol_name": f"{node.name}.{item.name}",
2168
+ "symbol_type": "method",
2169
+ "start_line": ms_line,
2170
+ "end_line": me_line,
2171
+ "content": f"{node.name}.{item.name}\n{mdoc}\n{msnippet}"
2172
+ })
2173
+ elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
2174
+ s_line = node.lineno
2175
+ e_line = getattr(node, "end_lineno", s_line)
2176
+ doc = ast.get_docstring(node) or ""
2177
+ snippet = "\n".join(lines[s_line - 1 : min(e_line, s_line + 40)])
2178
+ chunks.append({
2179
+ "symbol_name": node.name,
2180
+ "symbol_type": "function",
2181
+ "start_line": s_line,
2182
+ "end_line": e_line,
2183
+ "content": f"def {node.name}\n{doc}\n{snippet}"
2184
+ })
2185
+ except Exception:
2186
+ pass # Fall back to sliding windows if AST parse fails
2187
+
2188
+ # 2. Sliding window chunks (for non-Python or files where AST found few symbols)
2189
+ if len(chunks) == 0:
2190
+ window_size = 40
2191
+ overlap = 10
2192
+ stride = max(1, window_size - overlap)
2193
+ for i in range(0, len(lines), stride):
2194
+ chunk_lines = lines[i : i + window_size]
2195
+ if not chunk_lines:
2196
+ break
2197
+ s_line = i + 1
2198
+ e_line = min(len(lines), i + len(chunk_lines))
2199
+ content = "\n".join(chunk_lines)
2200
+ chunks.append({
2201
+ "symbol_name": Path(rel_path).name,
2202
+ "symbol_type": "chunk",
2203
+ "start_line": s_line,
2204
+ "end_line": e_line,
2205
+ "content": content
2206
+ })
2207
+
2208
+ return chunks
2209
+
2210
+ def index_workspace(self, force: bool = False) -> Dict[str, int]:
2211
+ with self._lock:
2212
+ self._init_db()
2213
+ conn = self._get_conn()
2214
+ scanned = 0
2215
+ indexed = 0
2216
+ total_chunks = 0
2217
+ current_files = set()
2218
+
2219
+ try:
2220
+ for root, dirs, files in os.walk(self.workspace):
2221
+ dirs[:] = [d for d in dirs if d not in self.IGNORE_DIRS and not d.startswith(".")]
2222
+ for f in files:
2223
+ p = Path(root) / f
2224
+ if p.suffix.lower() not in self.EXTENSIONS:
2225
+ continue
2226
+ try:
2227
+ rel_path = str(p.relative_to(self.workspace)).replace("\\", "/")
2228
+ except Exception:
2229
+ rel_path = str(p)
2230
+ current_files.add(rel_path)
2231
+ scanned += 1
2232
+
2233
+ try:
2234
+ stat = p.stat()
2235
+ if stat.st_size > 1_000_000: # skip >1MB
2236
+ continue
2237
+
2238
+ row = conn.execute("SELECT mtime, size FROM files WHERE path = ?", (rel_path,)).fetchone()
2239
+ if not force and row and abs(row["mtime"] - stat.st_mtime) < 0.01 and row["size"] == stat.st_size:
2240
+ continue # unchanged
2241
+
2242
+ chunks = self._extract_chunks(p, rel_path)
2243
+ with conn:
2244
+ conn.execute("DELETE FROM code_fts WHERE file_path = ?", (rel_path,))
2245
+ for c in chunks:
2246
+ conn.execute(
2247
+ "INSERT INTO code_fts (file_path, symbol_name, symbol_type, start_line, end_line, content) "
2248
+ "VALUES (?, ?, ?, ?, ?, ?)",
2249
+ (rel_path, c["symbol_name"], c["symbol_type"], c["start_line"], c["end_line"], c["content"])
2250
+ )
2251
+ conn.execute(
2252
+ "INSERT OR REPLACE INTO files (path, mtime, size, hash, indexed_at) VALUES (?, ?, ?, ?, ?)",
2253
+ (rel_path, stat.st_mtime, stat.st_size, "", time.time())
2254
+ )
2255
+ indexed += 1
2256
+ total_chunks += len(chunks)
2257
+ except Exception:
2258
+ continue
2259
+
2260
+ with conn:
2261
+ db_files = [r[0] for r in conn.execute("SELECT path FROM files").fetchall()]
2262
+ for df in db_files:
2263
+ if df not in current_files:
2264
+ conn.execute("DELETE FROM files WHERE path = ?", (df,))
2265
+ conn.execute("DELETE FROM code_fts WHERE file_path = ?", (df,))
2266
+
2267
+ total_chunks_in_db = conn.execute("SELECT COUNT(*) FROM code_fts").fetchone()[0]
2268
+ finally:
2269
+ conn.close()
2270
+
2271
+ return {"scanned": scanned, "indexed": indexed, "total_chunks": total_chunks_in_db}
2272
+
2273
+ def search(self, query: str, top_k: int = 5) -> List[Dict[str, Any]]:
2274
+ self._init_db()
2275
+ tokens = re.findall(r"[A-Za-z0-9_]+", query)
2276
+ if not tokens:
2277
+ return []
2278
+
2279
+ conn = self._get_conn()
2280
+ try:
2281
+ count = conn.execute("SELECT COUNT(*) FROM code_fts").fetchone()[0]
2282
+ except Exception:
2283
+ count = 0
2284
+ conn.close()
2285
+
2286
+ if count == 0:
2287
+ self.index_workspace(force=False)
2288
+
2289
+ conn = self._get_conn()
2290
+ results: List[Dict[str, Any]] = []
2291
+ try:
2292
+ fts_query = " OR ".join(f'"{t}"' for t in tokens)
2293
+ sql = """
2294
+ SELECT file_path, symbol_name, symbol_type, start_line, end_line, content, rank
2295
+ FROM code_fts
2296
+ WHERE code_fts MATCH ?
2297
+ ORDER BY rank
2298
+ LIMIT ?
2299
+ """
2300
+ rows = conn.execute(sql, (fts_query, top_k * 3)).fetchall()
2301
+ q_lower_terms = [t.lower() for t in tokens]
2302
+
2303
+ scored: List[Tuple[float, Dict[str, Any]]] = []
2304
+ for r in rows:
2305
+ base_score = float(r["rank"])
2306
+ sym = (r["symbol_name"] or "").lower()
2307
+ fpath = (r["file_path"] or "").lower()
2308
+ content = r["content"] or ""
2309
+
2310
+ boost = 0.0
2311
+ for term in q_lower_terms:
2312
+ if term == sym:
2313
+ boost += 3.0
2314
+ elif term in sym:
2315
+ boost += 1.5
2316
+ if term in fpath:
2317
+ boost += 1.0
2318
+
2319
+ adjusted_score = base_score - boost
2320
+ scored.append((adjusted_score, {
2321
+ "file_path": r["file_path"],
2322
+ "symbol_name": r["symbol_name"],
2323
+ "symbol_type": r["symbol_type"],
2324
+ "start_line": int(r["start_line"]),
2325
+ "end_line": int(r["end_line"]),
2326
+ "content": content,
2327
+ "score": adjusted_score
2328
+ }))
2329
+
2330
+ scored.sort(key=lambda x: x[0])
2331
+ results = [x[1] for x in scored[:top_k]]
2332
+ except Exception:
2333
+ pass
2334
+ finally:
2335
+ conn.close()
2336
+
2337
+ return results
2338
+
2339
+ def status(self) -> str:
2340
+ self._init_db()
2341
+ conn = self._get_conn()
2342
+ try:
2343
+ file_count = conn.execute("SELECT COUNT(*) FROM files").fetchone()[0]
2344
+ chunk_count = conn.execute("SELECT COUNT(*) FROM code_fts").fetchone()[0]
2345
+ last_run = conn.execute("SELECT MAX(indexed_at) FROM files").fetchone()[0]
2346
+ last_str = datetime.fromtimestamp(last_run).strftime("%Y-%m-%d %H:%M:%S") if last_run else "Never"
2347
+ return f"Files: {file_count} | Chunks: {chunk_count} | Last Indexed: {last_str} | DB: {self.db_path.name}"
2348
+ except Exception as e:
2349
+ return f"Index status error: {e}"
2350
+ finally:
2351
+ conn.close()
2352
+
2353
+ code_indexer = CodebaseIndexer(shell.cwd)
2354
+
2355
+ def tool_code_search(query: str, top_k: int = 5) -> ToolResult:
2356
+ """Semantic vector/keyword hybrid search over local codebase with AST symbol awareness."""
2357
+ if not query.strip():
2358
+ return ToolResult(False, "Error: search query cannot be empty", "code_search")
2359
+
2360
+ matches = code_indexer.search(query, top_k=top_k)
2361
+ if not matches:
2362
+ return ToolResult(True, f"No codebase matches found for: '{query}'. Use /index rebuild to refresh the index.", "code_search")
2363
+
2364
+ lines = [f"Found {len(matches)} relevant code match(es) for query: '{query}'\n"]
2365
+ for i, m in enumerate(matches, 1):
2366
+ sym_label = f" [{m['symbol_type']}: {m['symbol_name']}]" if m['symbol_name'] else ""
2367
+ lines.append(f"── Match {i} ── {m['file_path']} (lines {m['start_line']}-{m['end_line']}){sym_label}")
2368
+ snip = textwrap.indent(m["content"][:600], " ")
2369
+ lines.append(snip)
2370
+ if len(m["content"]) > 600:
2371
+ lines.append(" ... [remaining lines truncated]")
2372
+ lines.append("")
2373
+
2374
+ return ToolResult(True, "\n".join(lines).strip(), "code_search")
2375
+
2376
+
2377
+ # ══════════════════════════════════════════════════════════════════════════════
2378
+ # SECTION 7d — MODEL CONTEXT PROTOCOL (MCP) CLIENT & REGISTRY
2379
+ # ══════════════════════════════════════════════════════════════════════════════
2380
+ class McpClient:
2381
+ """
2382
+ Subprocess JSON-RPC 2.0 Client implementing the Model Context Protocol (MCP) specification.
2383
+ Communicates via stdin/stdout with external tool servers (filesystem, sqlite, git, web, APIs).
2384
+ """
2385
+ def __init__(self, name: str, command: str, args: List[str] = None, env: Dict[str, str] = None, cwd: Path = None):
2386
+ self.name = name
2387
+ self.command = command
2388
+ self.args = args or []
2389
+ self.env = env or os.environ.copy()
2390
+ self.cwd = cwd or Path.cwd()
2391
+ self.proc: Optional[subprocess.Popen] = None
2392
+ self._req_id = 0
2393
+ self._lock = threading.RLock()
2394
+ self._pending: Dict[int, Tuple[threading.Event, Dict[str, Any]]] = {}
2395
+ self.tools: List[Dict[str, Any]] = []
2396
+ self.server_info: Dict[str, Any] = {}
2397
+ self.connected = False
2398
+ self.error: Optional[str] = None
2399
+ self._reader_thread: Optional[threading.Thread] = None
2400
+
2401
+ def start(self, timeout: float = 12.0) -> bool:
2402
+ cmd_bin = shutil.which(self.command) or self.command
2403
+ full_cmd = [cmd_bin] + self.args
2404
+ try:
2405
+ self.proc = subprocess.Popen(
2406
+ full_cmd,
2407
+ stdin=subprocess.PIPE,
2408
+ stdout=subprocess.PIPE,
2409
+ stderr=subprocess.PIPE,
2410
+ cwd=str(self.cwd),
2411
+ env=self.env,
2412
+ text=True,
2413
+ bufsize=1,
2414
+ encoding="utf-8",
2415
+ errors="replace"
2416
+ )
2417
+ self._reader_thread = threading.Thread(target=self._stdout_reader, daemon=True)
2418
+ self._reader_thread.start()
2419
+
2420
+ init_res = self.send_request("initialize", {
2421
+ "protocolVersion": "2024-11-05",
2422
+ "capabilities": {"tools": {}},
2423
+ "clientInfo": {"name": "IGNITE-Agent", "version": "3.0"}
2424
+ }, timeout=timeout)
2425
+
2426
+ self.server_info = init_res.get("result", {}).get("serverInfo", {})
2427
+ self.send_notification("notifications/initialized")
2428
+
2429
+ tools_res = self.send_request("tools/list", {}, timeout=timeout)
2430
+ self.tools = tools_res.get("result", {}).get("tools", [])
2431
+ self.connected = True
2432
+ self.error = None
2433
+ return True
2434
+ except Exception as e:
2435
+ self.error = str(e)
2436
+ self.connected = False
2437
+ return False
2438
+
2439
+ def _stdout_reader(self):
2440
+ while self.proc and self.proc.poll() is None:
2441
+ try:
2442
+ line = self.proc.stdout.readline()
2443
+ if not line:
2444
+ break
2445
+ line = line.strip()
2446
+ if not line:
2447
+ continue
2448
+ msg = json.loads(line)
2449
+ mid = msg.get("id")
2450
+ if mid is not None and mid in self._pending:
2451
+ evt, box = self._pending[mid]
2452
+ box["data"] = msg
2453
+ evt.set()
2454
+ except Exception:
2455
+ pass
2456
+
2457
+ def send_request(self, method: str, params: Dict[str, Any] = None, timeout: float = 15.0) -> Dict[str, Any]:
2458
+ if not self.proc or self.proc.poll() is not None:
2459
+ raise RuntimeError(f"MCP server '{self.name}' process is not running.")
2460
+ with self._lock:
2461
+ self._req_id += 1
2462
+ rid = self._req_id
2463
+
2464
+ payload = {"jsonrpc": "2.0", "id": rid, "method": method, "params": params or {}}
2465
+ evt = threading.Event()
2466
+ box: Dict[str, Any] = {}
2467
+ self._pending[rid] = (evt, box)
2468
+ try:
2469
+ raw = json.dumps(payload) + "\n"
2470
+ self.proc.stdin.write(raw)
2471
+ self.proc.stdin.flush()
2472
+ except Exception as e:
2473
+ self._pending.pop(rid, None)
2474
+ raise RuntimeError(f"Failed to communicate with MCP '{self.name}': {e}")
2475
+
2476
+ if not evt.wait(timeout):
2477
+ self._pending.pop(rid, None)
2478
+ raise TimeoutError(f"MCP server '{self.name}' timed out waiting for '{method}' response ({timeout}s)")
2479
+
2480
+ self._pending.pop(rid, None)
2481
+ res = box.get("data", {})
2482
+ if "error" in res:
2483
+ err = res["error"]
2484
+ raise RuntimeError(f"MCP error {err.get('code')}: {err.get('message')}")
2485
+ return res
2486
+
2487
+ def send_notification(self, method: str, params: Dict[str, Any] = None):
2488
+ if not self.proc or self.proc.poll() is not None:
2489
+ return
2490
+ payload = {"jsonrpc": "2.0", "method": method, "params": params or {}}
2491
+ try:
2492
+ self.proc.stdin.write(json.dumps(payload) + "\n")
2493
+ self.proc.stdin.flush()
2494
+ except Exception:
2495
+ pass
2496
+
2497
+ def call_tool(self, tool_name: str, arguments: Dict[str, Any] = None, timeout: float = 30.0) -> ToolResult:
2498
+ try:
2499
+ res = self.send_request("tools/call", {"name": tool_name, "arguments": arguments or {}}, timeout=timeout)
2500
+ result_data = res.get("result", {})
2501
+ content_items = result_data.get("content", [])
2502
+ output_parts = []
2503
+ for item in content_items:
2504
+ if item.get("type") == "text":
2505
+ output_parts.append(item.get("text", ""))
2506
+ else:
2507
+ output_parts.append(json.dumps(item))
2508
+ out_str = "\n".join(output_parts).strip() or "Tool completed with no output."
2509
+ is_err = result_data.get("isError", False)
2510
+ return ToolResult(not is_err, out_str, f"mcp_{self.name}_{tool_name}")
2511
+ except Exception as e:
2512
+ return ToolResult(False, f"MCP tool call failed ({self.name}::{tool_name}): {e}", "mcp_call")
2513
+
2514
+ def close(self):
2515
+ if self.proc:
2516
+ try:
2517
+ self.proc.terminate()
2518
+ self.proc.wait(timeout=2)
2519
+ except Exception:
2520
+ try:
2521
+ self.proc.kill()
2522
+ except Exception:
2523
+ pass
2524
+ self.proc = None
2525
+ self.connected = False
2526
+
2527
+
2528
+ class McpRegistry:
2529
+ """Discovers, initializes, and routes tool calls to configured MCP servers."""
2530
+ def __init__(self, workspace: Path):
2531
+ self.workspace = Path(workspace)
2532
+ self.clients: Dict[str, McpClient] = {}
2533
+ self._lock = threading.RLock()
2534
+
2535
+ def set_workspace(self, new_ws: Path):
2536
+ with self._lock:
2537
+ self.workspace = Path(new_ws)
2538
+
2539
+ def load_config(self) -> Dict[str, Any]:
2540
+ candidates = [
2541
+ self.workspace / ".ignite_mcp.json",
2542
+ Path.home() / ".ignite_mcp.json"
2543
+ ]
2544
+ for c in candidates:
2545
+ if c.exists():
2546
+ try:
2547
+ with open(c, "r", encoding="utf-8") as f:
2548
+ return json.load(f)
2549
+ except Exception:
2550
+ pass
2551
+ return {"mcpServers": {}}
2552
+
2553
+ def init_config_template(self) -> Path:
2554
+ target = self.workspace / ".ignite_mcp.json"
2555
+ if not target.exists():
2556
+ template = {
2557
+ "mcpServers": {
2558
+ "filesystem": {
2559
+ "command": "npx",
2560
+ "args": ["-y", "@modelcontextprotocol/server-filesystem", str(self.workspace)],
2561
+ "description": "Standard filesystem MCP access"
2562
+ },
2563
+ "sqlite": {
2564
+ "command": "uvx",
2565
+ "args": ["mcp-server-sqlite", "--db-path", "app.db"],
2566
+ "description": "Direct SQLite database MCP querying"
2567
+ }
2568
+ }
2569
+ }
2570
+ with open(target, "w", encoding="utf-8") as f:
2571
+ json.dump(template, f, indent=2)
2572
+ return target
2573
+
2574
+ def get_client(self, server_name: str) -> Optional[McpClient]:
2575
+ with self._lock:
2576
+ if server_name in self.clients and self.clients[server_name].connected:
2577
+ return self.clients[server_name]
2578
+
2579
+ cfg = self.load_config().get("mcpServers", {}).get(server_name)
2580
+ if not cfg:
2581
+ return None
2582
+
2583
+ client = McpClient(
2584
+ name=server_name,
2585
+ command=cfg.get("command", ""),
2586
+ args=cfg.get("args", []),
2587
+ env={**os.environ, **cfg.get("env", {})},
2588
+ cwd=self.workspace
2589
+ )
2590
+ ok = client.start()
2591
+ if ok:
2592
+ self.clients[server_name] = client
2593
+ return client
2594
+ return None
2595
+
2596
+ def list_tools(self) -> List[Dict[str, Any]]:
2597
+ all_tools = []
2598
+ cfg = self.load_config().get("mcpServers", {})
2599
+ for sname in cfg.keys():
2600
+ client = self.get_client(sname)
2601
+ if client and client.tools:
2602
+ for t in client.tools:
2603
+ all_tools.append({
2604
+ "server": sname,
2605
+ "name": t.get("name"),
2606
+ "description": t.get("description", ""),
2607
+ "schema": t.get("inputSchema", {})
2608
+ })
2609
+ return all_tools
2610
+
2611
+ def call_tool(self, server_name: str, tool_name: str, arguments: Dict[str, Any] = None) -> ToolResult:
2612
+ client = self.get_client(server_name)
2613
+ if not client:
2614
+ return ToolResult(
2615
+ False,
2616
+ f"MCP server '{server_name}' not found or failed to start. Check .ignite_mcp.json or run /mcp init.",
2617
+ "mcp_call"
2618
+ )
2619
+ return client.call_tool(tool_name, arguments or {})
2620
+
2621
+ def status(self) -> str:
2622
+ cfg = self.load_config().get("mcpServers", {})
2623
+ if not cfg:
2624
+ return "No MCP servers configured. Run /mcp init to create a starter .ignite_mcp.json."
2625
+ lines = ["Configured MCP Servers:"]
2626
+ for sname, sdata in cfg.items():
2627
+ client = self.clients.get(sname)
2628
+ status_tag = "🟢 CONNECTED" if (client and client.connected) else "⚪ IDLE / READY"
2629
+ tool_cnt = len(client.tools) if (client and client.connected) else 0
2630
+ lines.append(f" • {sname} [{status_tag}] — {sdata.get('command')} {' '.join(sdata.get('args', []))} ({tool_cnt} tools)")
2631
+ return "\n".join(lines)
2632
+
2633
+ mcp_registry = McpRegistry(shell.cwd)
2634
+
2635
+ def tool_mcp_call(server: str, tool: str, arguments: Dict[str, Any] = None) -> ToolResult:
2636
+ """Execute a tool on an external Model Context Protocol (MCP) server."""
2637
+ if not server or not tool:
2638
+ return ToolResult(False, "Error: both 'server' and 'tool' parameters are required for mcp_call", "mcp_call")
2639
+ return mcp_registry.call_tool(server, tool, arguments or {})
2640
+
2641
+ TOOL_DISPATCH = {
2642
+ "terminal": lambda d: tool_terminal(d.get("cmd", "")),
2643
+ "file_read": lambda d: tool_file_read(d.get("path", ""), d.get("start", 1), d.get("end")),
2644
+ "file_write": lambda d: tool_file_write(d.get("path", ""), d.get("content", "")),
2645
+ "file_patch": lambda d: tool_file_patch(d.get("path", ""), d.get("diff", "")),
2646
+ "git": lambda d: tool_git(d.get("subcmd", "status")),
2647
+ "git_diff": lambda d: tool_git_diff(d.get("path", "")),
2648
+ "verify_code": lambda d: tool_verify_code(d.get("cmd", ""), d.get("path", "")),
2649
+ "python_eval": lambda d: tool_python_eval(d.get("code", "")),
2650
+ "search": lambda d: tool_search(d.get("query", "")),
2651
+ "code_search": lambda d: tool_code_search(d.get("query", ""), int(d.get("top_k", 5))),
2652
+ "mcp_call": lambda d: tool_mcp_call(d.get("server", ""), d.get("tool", ""), d.get("arguments") or {}),
2653
+ "deploy": lambda d: tool_deploy(d.get("subcmd", ""), d.get("platform", "")),
2654
+ "spawn": lambda d: tool_spawn(d.get("cmd", ""), d.get("name", ""), d.get("mode", "window")),
2655
+ "kill": lambda d: tool_kill(d.get("pid"), d.get("name", "")),
2656
+ "process_status": lambda d: tool_process_status(d.get("pid"), d.get("tail", 60)),
2657
+ "read_log": lambda d: tool_read_log(d.get("pid", 0), d.get("tail", 80)),
2658
+ }
2659
+
2660
+
2661
+ # ══════════════════════════════════════════════════════════════════════════════
2662
+ # SECTION 9 — SYSTEM PROMPT
2663
+ # ══════════════════════════════════════════════════════════════════════════════
2664
+ SYSTEM_PROMPT = """You are IGNITE V3 PRO — an elite AI assistant, coding partner, and autonomous DevOps Agent.
2665
+ You can chat, answer questions, write code, run scripts, deploy projects, and manage processes — all from one place.
2666
+
2667
+ ━━━ TWO MODES ━━━
2668
+
2669
+ MODE 1 — CHAT (greetings, questions, explanations, general conversation)
2670
+ Reply immediately: {"action":"done","message":"<your friendly answer>","thought":"chat"}
2671
+ Do NOT run tools for casual questions. Just answer directly.
2672
+
2673
+ MODE 2 — TASK (code, files, deployments, bots, scripts, debugging)
2674
+ Use the ReAct loop. Execute tools step by step until the task is fully done.
2675
+
2676
+ ━━━ ALL 18 TOOLS (output ONE pure JSON per turn — no markdown, no prose) ━━━
2677
+
2678
+ ── FILE & SAFETY TOOLS ──
2679
+ 1. {"action":"terminal", "cmd":"<PowerShell>", "thought":"<why>"}
2680
+ 2. {"action":"file_read", "path":"<path>", "thought":"<why>", "start":1, "end":50}
2681
+ 3. {"action":"file_write", "path":"<path>", "content":"<txt>","thought":"<why>"}
2682
+ ⚠ file_write automatically runs AST pre-flight checks on Python & JSON. Broken syntax will be rejected!
2683
+ 4. {"action":"file_patch", "path":"<path>", "diff":"<diff>", "thought":"<why>"}
2684
+ 5. {"action":"git", "subcmd":"<args>", "thought":"<why>"}
2685
+ 6. {"action":"git_diff", "path":"<optional_path>", "thought":"<why>"}
2686
+ → Review uncommitted changes and diffs before declaring a task complete.
2687
+ 7. {"action":"verify_code", "cmd":"<optional_test_cmd>", "path":"<optional_file>", "thought":"<why>"}
2688
+ → Self-Healing Test Runner: auto-detects pytest, unittest, npm test, or compilation.
2689
+ → If verification fails, it returns the exact stack trace so you can self-heal!
2690
+ 8. {"action":"python_eval", "code":"<python>", "thought":"<why>"}
2691
+
2692
+ ── PROCESS TOOLS ──
2693
+ 9. {"action":"spawn", "cmd":"<command>", "name":"<label>", "mode":"window", "thought":"<why>"}
2694
+ → mode "window" = opens a NEW VISIBLE TERMINAL the user can see (default)
2695
+ → mode "background" = runs silently, output captured to log file
2696
+ ⚠ spawn automatically waits 8 seconds, reads the log, and returns success=false if errors found.
2697
+ ⚠ AFTER spawn: if result shows "ERRORS detected" → kill → fix code → spawn again. Loop until clean.
2698
+
2699
+ 10. {"action":"kill", "pid":12345, "thought":"<why>"}
2700
+ {"action":"kill", "name":"bot", "thought":"<why>"}
2701
+
2702
+ 11. {"action":"process_status", "thought":"<why>"}
2703
+ {"action":"process_status", "pid":12345, "tail":50, "thought":"<why>"}
2704
+
2705
+ 12. {"action":"read_log", "pid":12345, "tail":80, "thought":"<why>"}
2706
+
2707
+ ── RESEARCH & DEPLOY ──
2708
+ 13. {"action":"search", "query":"<query>", "thought":"<why>"}
2709
+ 14. {"action":"code_search", "query":"<symbol or keywords>", "top_k":5, "thought":"<why>"}
2710
+ → Instant BM25 + AST symbol search across entire codebase. Find functions, classes, and logic without reading full files!
2711
+ 15. {"action":"mcp_call", "server":"<name>", "tool":"<tool_name>", "arguments":{...}, "thought":"<why>"}
2712
+ → Invoke external Model Context Protocol tool (configured in .ignite_mcp.json).
2713
+ 16. {"action":"deploy", "platform":"<railway|docker|fly|render|heroku|vercel>", "subcmd":"<optional>", "thought":"<why>"}
2714
+ → Auto-creates Dockerfile, Procfile, railway.toml, render.yaml, and syncs .env secrets!
2715
+
2716
+ ── CONTROL ──
2717
+ 17. {"action":"ask", "message":"<question>", "thought":"<why>"}
2718
+ 18. {"action":"done", "message":"<summary>", "thought":"<why>"}
2719
+
2720
+ ━━━ 🧪 ENTERPRISE CODE VERIFICATION DIRECTIVE ━━━
2721
+ Never assume your written code works without proof. For all coding tasks:
2722
+ 1. Write/patch code with `file_write` or `file_patch`.
2723
+ 2. Inspect diffs with `git_diff`.
2724
+ 3. Run `verify_code` to compile or execute test suites.
2725
+ 4. If `verify_code` reports failures, read the stack trace, patch the error, and re-verify.
2726
+ 5. Only call `done` when code compiles cleanly and tests pass.
2727
+
2728
+ ━━━ 🧠 PRIME DIRECTIVE: DYNAMIC TOOL SYNTHESIS (NEVER REFUSE) ━━━
2729
+
2730
+ You are a fully autonomous software engineering agent, NOT a static chatbot limited to hardcoded commands.
2731
+ You have unrestricted access to PowerShell, Python execution, file writing, and web research.
2732
+
2733
+ IF A USER ASKS FOR A TASK FOR WHICH NO BUILT-IN TOOL EXISTS:
2734
+ (Examples: "Deploy to AWS", "Scrape a website", "Convert audio/video", "Run database migration",
2735
+ "Setup reverse proxy", "Benchmark performance", "Call an external REST API", "Process images"):
2736
+
2737
+ ❌ RULE 1: NEVER say "I cannot do this", "I don't have this tool", or refuse the task.
2738
+ ✔ RULE 2: FOLLOW THE 5-STEP AUTONOMOUS SYNTHESIS PROTOCOL:
2739
+ 1. [RESEARCH] If you need modern libraries, CLI syntax, or API endpoints, use `search`.
2740
+ 2. [SYNTHESIZE] Write a complete, standalone Python or PowerShell script using `file_write`
2741
+ (e.g., in `.ignite_scratch/<task>.py` or `scripts/<task>.py`).
2742
+ 3. [INSTALL] Use `terminal` to install any required packages (`pip install <package>`).
2743
+ 4. [EXECUTE] Run the synthesized script using `terminal` (for quick scripts) or `spawn`
2744
+ (for servers, bots, background workers, or tasks with visible outputs).
2745
+ 5. [SELF-HEAL] If the script crashes or returns an error, do NOT stop! Read the traceback,
2746
+ fix the script with `file_patch` or `file_write`, and re-run.
2747
+ Iterate until the script runs cleanly and completes the user's objective.
2748
+
2749
+ ━━━ WORKFLOW PATTERNS ━━━
2750
+
2751
+ 🤖 BUILDING & RUNNING A BOT / SCRIPT:
2752
+ 1. Use `ask` to collect ALL needed credentials (tokens, API keys, ports)
2753
+ 2. Use `file_write` to create the main script + a `.env` file with the secrets
2754
+ 3. Use `terminal` to install dependencies: `pip install -r requirements.txt`
2755
+ 4. Use `spawn` with mode="window" to launch the bot in a NEW VISIBLE TERMINAL
2756
+ 5. The spawn result tells you SUCCESS or ERRORS with log content — read it carefully!
2757
+ 6. If spawn returned errors → `kill` the process → read the error → fix with `file_write`/`file_patch` → `spawn` again
2758
+ 7. Repeat step 6 until spawn confirms the process is 🟢 RUNNING with no errors
2759
+ 8. ONLY then use `done` to summarize. Give the user the PID.
2760
+
2761
+ 🌐 CLOUD DEPLOYMENT WORKFLOW:
2762
+ - When user asks to deploy to cloud (Railway, Docker, Render, Fly.io, Heroku, Vercel):
2763
+ - Use `deploy` tool with "platform": "railway" (or "docker", "fly", etc.).
2764
+ - The deploy engine automatically scaffolds config files, initializes git, syncs .env secrets, and runs the deploy.
2765
+ - Report the live URL or domain back to the user.
2766
+
2767
+ 🔄 AUTO-FIX LOOP (mandatory when spawn fails):
2768
+ spawn → ERROR detected → kill → fix code → spawn → ERROR? → kill → fix → spawn → ✅ SUCCESS → done
2769
+ NEVER skip the fix-and-retry loop. NEVER say "done" while spawn shows errors.
2770
+
2771
+ 🧪 TESTING A SCRIPT:
2772
+ 1. Write the script with `file_write`
2773
+ 2. If it's interactive (has input()): use `done` to tell user to run it themselves
2774
+ 3. If it's non-interactive: `spawn` with mode="background", then `read_log` to check output
2775
+ 4. `kill` when done testing
2776
+
2777
+ 🔧 DEBUGGING:
2778
+ - If exit code ≠ 0: use `search` for the error, fix with `file_patch`, re-run
2779
+ - If a process crashes: read the spawn result (it includes the traceback) → fix and `spawn` again
2780
+
2781
+ ━━━ CRITICAL RULES ━━━
2782
+ - Output ONLY pure JSON. One object per turn. Nothing else.
2783
+ - NEVER say "I don't have that tool" or refuse an unfamiliar task. Always synthesize a script to solve it!
2784
+ - NEVER run `python script.py` in terminal if script has input() — use `spawn` with mode="window" instead.
2785
+ - Always collect ALL required secrets/tokens via `ask` BEFORE writing any file.
2786
+ - The `spawn` tool already waits and checks for errors — READ the result JSON before deciding next step.
2787
+ - If spawn result success=false: ALWAYS kill + fix + respawn. Do NOT use `done` on a failed spawn.
2788
+ - When writing a .env file, use the actual values the user provided (don't use placeholders).
2789
+ - For interactive scripts (typing speed test, etc.): write with `file_write`, then `done` telling user to run it.
2790
+ - When task is complete, give user the PID(s) of running processes so they can manage them.
2791
+ """
2792
+
2793
+
2794
+ # ══════════════════════════════════════════════════════════════════════════════
2795
+ # SECTION 10 — LLM ROUTER (streaming + health-weighted selection)
2796
+ # ══════════════════════════════════════════════════════════════════════════════
2797
+ class LLMRouter:
2798
+ def __init__(self):
2799
+ self.override_id: Optional[str] = None
2800
+
2801
+ @staticmethod
2802
+ def _extract_json(text: str) -> Optional[str]:
2803
+ """Extract the LAST complete JSON object from text containing an 'action' key.
2804
+ Handles models that output multiple JSON blobs or wrap in prose."""
2805
+ # First try: direct parse (clean response)
2806
+ try:
2807
+ d = json.loads(text)
2808
+ if isinstance(d, dict) and "action" in d:
2809
+ return text
2810
+ except Exception:
2811
+ pass
2812
+
2813
+ # Second try: find all {...} blocks using brace counting, return last valid one with 'action'
2814
+ last_valid = None
2815
+ i = 0
2816
+ while i < len(text):
2817
+ if text[i] == '{':
2818
+ depth = 0
2819
+ for j in range(i, len(text)):
2820
+ if text[j] == '{':
2821
+ depth += 1
2822
+ elif text[j] == '}':
2823
+ depth -= 1
2824
+ if depth == 0:
2825
+ candidate = text[i:j+1]
2826
+ try:
2827
+ d = json.loads(candidate)
2828
+ if isinstance(d, dict) and "action" in d:
2829
+ last_valid = candidate
2830
+ except Exception:
2831
+ pass
2832
+ break
2833
+ i += 1
2834
+ return last_valid
2835
+
2836
+ def _best_provider(self, exclude: Optional[set] = None) -> Optional[Provider]:
2837
+ pool = PROVIDERS
2838
+ if self.override_id:
2839
+ pool = [p for p in PROVIDERS if p.id == self.override_id] or PROVIDERS
2840
+ exclude_set = exclude or set()
2841
+ available = [p for p in pool if not p.exhausted and not p.quota_blocked and p.id not in exclude_set]
2842
+ if not available:
2843
+ return None
2844
+ return max(available, key=lambda p: p.health_score)
2845
+
2846
+ def query(self, history: List[Dict], stream: bool = True) -> Tuple[str, str]:
2847
+ """Returns (raw_text, provider_name). Streams to console if stream=True."""
2848
+ tried_providers = set()
2849
+ attempts = 0
2850
+ max_attempts = len(PROVIDERS) + 2
2851
+
2852
+ while attempts < max_attempts:
2853
+ attempts += 1
2854
+ provider = self._best_provider(exclude=tried_providers)
2855
+ if not provider:
2856
+ if self.override_id and tried_providers:
2857
+ _warn(f"Locked model '{self.override_id}' unavailable. Falling back to active pool...")
2858
+ provider = self._best_provider(exclude=None)
2859
+ if not provider:
2860
+ return '{"action":"done","message":"❌ All AI models exhausted.","thought":"No providers"}', "None"
2861
+
2862
+ tried_providers.add(provider.id)
2863
+
2864
+ try:
2865
+ t0 = time.time()
2866
+ if stream and provider.supports_stream:
2867
+ raw = self._stream(provider, history)
2868
+ else:
2869
+ raw = self._blocking(provider, history)
2870
+
2871
+ provider.record_latency((time.time() - t0) * 1000)
2872
+
2873
+ # Strip markdown fences
2874
+ raw = re.sub(r"^```(?:json)?\n?", "", raw.strip(), flags=re.IGNORECASE)
2875
+ raw = re.sub(r"\n?```$", "", raw.strip())
2876
+
2877
+ # Smart JSON extraction — find the LAST complete {...} block with 'action'
2878
+ extracted = self._extract_json(raw)
2879
+ if extracted is not None:
2880
+ return extracted, provider.name
2881
+
2882
+ # If no valid JSON found, check if model gave a direct conversational or markdown answer
2883
+ clean_raw = raw.strip()
2884
+ if clean_raw:
2885
+ # Model answered directly with markdown/prose without JSON envelope
2886
+ coerced = json.dumps({
2887
+ "action": "done",
2888
+ "message": clean_raw,
2889
+ "thought": "Direct model answer"
2890
+ })
2891
+ return coerced, provider.name
2892
+
2893
+ raise json.JSONDecodeError("Blank model response", raw, 0)
2894
+
2895
+ except json.JSONDecodeError:
2896
+ provider.total_errors += 1
2897
+ _warn(f"[{provider.name}] Invalid JSON response. Trying next provider...")
2898
+ continue
2899
+ except Exception as e:
2900
+ err_str = str(e)
2901
+ err = err_str.lower()
2902
+ if "blank_response" in err_str:
2903
+ _warn(f"[{provider.name}] Blank response (glitch). Skipping to next...")
2904
+ continue
2905
+ # ── ONLY ON QUOTA EXHAUSTED: HARD BLOCK FOR ENTIRE SESSION ──
2906
+ if any(k in err for k in ["429", "quota", "exhausted", "rate_limit", "rate limit", "resource_exhausted"]):
2907
+ provider.exhausted = True
2908
+ provider.quota_blocked = True
2909
+ _warn(f"⛔ [{provider.name}] Quota exhausted! Completely BLOCKED for this entire session.")
2910
+ elif "404" in err or "not found" in err:
2911
+ provider.exhausted = True
2912
+ provider.quota_blocked = True
2913
+ _warn(f"[{provider.name}] Model not found. Disabling for session.")
2914
+ else:
2915
+ # Regular temporary errors (network timeout, glitch) do NOT block for the whole session
2916
+ provider.total_errors += 1
2917
+ _warn(f"[{provider.name}] Temporary error: {e}")
2918
+ continue
2919
+
2920
+ return '{"action":"done","message":"❌ All AI models failed.","thought":"System down"}', "None"
2921
+
2922
+ def _stream(self, provider: Provider, history: List[Dict]) -> str:
2923
+ chunks = []
2924
+ with provider.client.chat.completions.create(
2925
+ model=provider.model,
2926
+ messages=history,
2927
+ temperature=CFG["temperature"],
2928
+ stream=True,
2929
+ ) as stream:
2930
+ for chunk in stream:
2931
+ delta = chunk.choices[0].delta.content or ""
2932
+ chunks.append(delta)
2933
+ print(delta, end="", flush=True)
2934
+ print() # newline after stream
2935
+ result = "".join(chunks).strip()
2936
+ if not result:
2937
+ raise ValueError("BLANK_RESPONSE")
2938
+ return result
2939
+
2940
+ def _blocking(self, provider: Provider, history: List[Dict]) -> str:
2941
+ res = provider.client.chat.completions.create(
2942
+ model=provider.model,
2943
+ messages=history,
2944
+ temperature=CFG["temperature"],
2945
+ )
2946
+ choice = res.choices[0]
2947
+ if not choice.message or not choice.message.content:
2948
+ reason = getattr(choice, "finish_reason", "unknown")
2949
+ if reason == "stop":
2950
+ raise ValueError("BLANK_RESPONSE") # glitch — try next provider
2951
+ raise ValueError(f"Empty response (finish_reason={reason})")
2952
+ return choice.message.content
2953
+
2954
+ router = LLMRouter()
2955
+
2956
+
2957
+ # ══════════════════════════════════════════════════════════════════════════════
2958
+ # SECTION 11 — SESSION SNAPSHOT SYSTEM
2959
+ # ══════════════════════════════════════════════════════════════════════════════
2960
+ class SessionManager:
2961
+ def __init__(self):
2962
+ self.session_dir = Path(CFG["session_dir"])
2963
+ self.session_dir.mkdir(parents=True, exist_ok=True)
2964
+ self.name: str = f"session_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
2965
+
2966
+ def save(self, memory: MemorySystem):
2967
+ path = self.session_dir / f"{self.name}.json"
2968
+ data = {
2969
+ "name": self.name,
2970
+ "saved_at": datetime.now().isoformat(),
2971
+ "history": memory.get_history(),
2972
+ "episodes": [asdict(e) for e in memory.episodes],
2973
+ }
2974
+ path.write_text(json.dumps(data, indent=2), encoding="utf-8")
2975
+ return str(path)
2976
+
2977
+ def load(self, name: str) -> Optional[Dict]:
2978
+ path = self.session_dir / f"{name}.json"
2979
+ if not path.exists():
2980
+ return None
2981
+ return json.loads(path.read_text(encoding="utf-8"))
2982
+
2983
+ def list_sessions(self) -> List[str]:
2984
+ return [f.stem for f in sorted(self.session_dir.glob("*.json"), reverse=True)]
2985
+
2986
+
2987
+ # ══════════════════════════════════════════════════════════════════════════════
2988
+ # SECTION 12 — TOKEN / COST TRACKER
2989
+ # ══════════════════════════════════════════════════════════════════════════════
2990
+ class CostTracker:
2991
+ COSTS_PER_1K = { # USD per 1k tokens (approximate)
2992
+ "gemini-3.8-flash": (0.00010, 0.00040),
2993
+ "gemini-3.5-flash": (0.00010, 0.00040),
2994
+ "gemini-2.5-flash": (0.00015, 0.00060),
2995
+ "gemini-2.5-pro": (0.00125, 0.00500),
2996
+ "openai/gpt-oss-120b": (0.00000, 0.00000), # free tier
2997
+ "qwen/qwen3.8-27b": (0.00000, 0.00000), # free tier
2998
+ "openai/gpt-oss-20b": (0.00000, 0.00000), # free tier
2999
+ "gpt-4o": (0.00500, 0.01500),
3000
+ "default": (0.00100, 0.00200),
3001
+ }
3002
+ def __init__(self):
3003
+ self.prompt_tokens = 0
3004
+ self.completion_tokens = 0
3005
+
3006
+ def add(self, text: str, is_completion: bool = True):
3007
+ # rough token estimate: 1 token ≈ 4 chars
3008
+ tokens = len(text) // 4
3009
+ if is_completion:
3010
+ self.completion_tokens += tokens
3011
+ else:
3012
+ self.prompt_tokens += tokens
3013
+
3014
+ def estimate_cost(self, model: str) -> float:
3015
+ in_rate, out_rate = self.COSTS_PER_1K.get(model, self.COSTS_PER_1K["default"])
3016
+ return (self.prompt_tokens / 1000 * in_rate +
3017
+ self.completion_tokens / 1000 * out_rate)
3018
+
3019
+ def summary(self) -> str:
3020
+ return (f"Tokens → In:{self.prompt_tokens:,} Out:{self.completion_tokens:,} "
3021
+ f"| Est. cost: ~${self.estimate_cost('default'):.4f}")
3022
+
3023
+ tracker = CostTracker()
3024
+
3025
+
3026
+ # ══════════════════════════════════════════════════════════════════════════════
3027
+ # SECTION 13 — DISPLAY HELPERS
3028
+ # ══════════════════════════════════════════════════════════════════════════════
3029
+ def _warn(msg: str):
3030
+ print(f"{C.YEL}⚠ {msg}{C.RST}", file=sys.stderr)
3031
+
3032
+ def _print_thought(thought: str, model: str):
3033
+ if RICH:
3034
+ console.print(Panel(f"[dim]{thought}[/dim]", title=f"[cyan]🧠 THOUGHT[/cyan] [dim]{model}[/dim]",
3035
+ border_style="cyan", padding=(0, 1)))
3036
+ else:
3037
+ print(f"\n{C.CYAN}🧠 [{model}] {thought}{C.RST}")
3038
+
3039
+ def _print_action(action: str, detail: str):
3040
+ icons = {"terminal":"▶","file_read":"📖","file_write":"📝","file_patch":"🔧",
3041
+ "git":"🌿","python_eval":"🐍","search":"🌐","deploy":"🚀","ask":"💬","done":"✅"}
3042
+ icon = icons.get(action, "•")
3043
+ col = {"terminal":C.YEL,"file_write":C.MAG,"file_patch":C.MAG,"search":C.GRY,
3044
+ "done":C.BGRN,"ask":C.BCYN,"git":C.GRN,"deploy":C.BRED}.get(action, C.YEL)
3045
+ if RICH:
3046
+ console.print(f"[bold]{col}{icon} [{action.upper()}][/bold] {detail[:160]}")
3047
+ else:
3048
+ print(f"{col}{icon} [{action.upper()}] {detail[:160]}{C.RST}")
3049
+
3050
+ def _print_result(ok: bool, output: str):
3051
+ col = C.GRN if ok else C.RED
3052
+ status = "✔ OK" if ok else "✖ FAIL"
3053
+ if RICH:
3054
+ syntax = Syntax(output[:2000], "text", theme="monokai", line_numbers=False,
3055
+ background_color="default")
3056
+ console.print(Panel(syntax, title=f"[{'green' if ok else 'red'}]{status}[/]",
3057
+ border_style="green" if ok else "red", padding=(0,1)))
3058
+ else:
3059
+ print(f"{col}{status}{C.RST}\n{C.GRY}{output[:2000]}{C.RST}")
3060
+
3061
+ def _divider(label: str = ""):
3062
+ if RICH:
3063
+ console.rule(f"[dim]{label}[/dim]")
3064
+ else:
3065
+ print(f"{C.GRY}{'─' * 60} {label}{C.RST}")
3066
+
3067
+
3068
+ # ══════════════════════════════════════════════════════════════════════════════
3069
+ # SECTION 14 — MAIN AGENT SESSION (ReAct loop)
3070
+ # ══════════════════════════════════════════════════════════════════════════════
3071
+ class IgniteSession:
3072
+ def __init__(self):
3073
+ self.memory = MemorySystem(max_history=CFG["max_history_messages"])
3074
+ self.memory.push("system", SYSTEM_PROMPT)
3075
+ self.session_mgr = SessionManager()
3076
+ self.tools_used_this_run: List[str] = []
3077
+ self.step_count = 0
3078
+ self.total_steps = 0
3079
+ self._swarm: Optional[Any] = None
3080
+
3081
+ @property
3082
+ def swarm(self) -> Any:
3083
+ if self._swarm is None:
3084
+ self._swarm = SwarmOrchestrator(self)
3085
+ return self._swarm
3086
+
3087
+ def run(self, user_input: str, max_iter: int = None):
3088
+ max_iter = max_iter or CFG["max_iterations"]
3089
+ # Pre-task automatic safety checkpoint
3090
+ if 'git_shield' in globals():
3091
+ git_shield.create_checkpoint(f"Task: {user_input[:35]}")
3092
+ obfuscator.register(user_input)
3093
+ obfuscator.register(str(shell.cwd))
3094
+
3095
+ scan = shell.scan_project()
3096
+ scan_text = json.dumps(scan, indent=2)
3097
+ safe_scan = obfuscator.mask(scan_text)
3098
+ safe_input = obfuscator.mask(user_input)
3099
+ safe_cwd = obfuscator.mask(str(shell.cwd))
3100
+ episode_ctx = self.memory.format_episodes()
3101
+
3102
+ full_user_msg = (
3103
+ f"Task: {safe_input}\n\n"
3104
+ f"[Project Scan]\n{safe_scan}\n\n"
3105
+ f"[CWD]: {safe_cwd}\n\n"
3106
+ f"[{episode_ctx}]"
3107
+ )
3108
+ self.memory.push("user", full_user_msg)
3109
+ tracker.add(full_user_msg, is_completion=False)
3110
+
3111
+ self.tools_used_this_run = []
3112
+ outcome = "partial"
3113
+
3114
+ for step in range(1, max_iter + 1):
3115
+ self.step_count = step
3116
+ _divider(f"Step {step}/{max_iter}")
3117
+
3118
+ if CFG["stream_responses"]:
3119
+ print(f"{C.DIM}🤖 Streaming response...{C.RST}")
3120
+
3121
+ raw, model_name = router.query(self.memory.get_history(),
3122
+ stream=CFG["stream_responses"])
3123
+ tracker.add(raw, is_completion=True)
3124
+
3125
+ try:
3126
+ d = json.loads(raw)
3127
+ except json.JSONDecodeError:
3128
+ _warn("Failed to parse JSON. Injecting correction prompt.")
3129
+ self.memory.push("assistant", raw)
3130
+ self.memory.push("user", "ERROR: Your response was not valid JSON. Output ONLY a single JSON object.")
3131
+ continue
3132
+
3133
+ action = d.get("action", "done")
3134
+ thought = d.get("thought", "...")
3135
+ model_label = model_name
3136
+
3137
+ # Don't re-print thought if we streamed it live already
3138
+ if not CFG["stream_responses"]:
3139
+ _print_thought(thought, model_label)
3140
+
3141
+ self.tools_used_this_run.append(action)
3142
+ self.memory.push("assistant", raw)
3143
+
3144
+ # ── Terminal ──────────────────────────────────────────────────
3145
+ if action == "terminal":
3146
+ cmd = d.get("cmd", "")
3147
+ real_cmd = obfuscator.unmask(cmd)
3148
+ _print_action("terminal", real_cmd)
3149
+ res = tool_terminal(cmd)
3150
+ _print_result(res.success, res.output)
3151
+ safe_log = obfuscator.mask(res.output)
3152
+ self.memory.push("user", f"Exit:{'0' if res.success else '1'}\nLog:\n{safe_log}")
3153
+
3154
+ # ── File Read ─────────────────────────────────────────────────
3155
+ elif action == "file_read":
3156
+ path = d.get("path", "")
3157
+ _print_action("file_read", obfuscator.unmask(path))
3158
+ res = tool_file_read(path, d.get("start", 1), d.get("end"))
3159
+ _print_result(res.success, res.output[:500] + "..." if len(res.output) > 500 else res.output)
3160
+ self.memory.push("user", f"File content:\n{obfuscator.mask(res.output)}")
3161
+
3162
+ # ── File Write ────────────────────────────────────────────────
3163
+ elif action == "file_write":
3164
+ path = d.get("path", "")
3165
+ _print_action("file_write", obfuscator.unmask(path))
3166
+ res = tool_file_write(path, d.get("content", ""))
3167
+ _print_result(res.success, res.output)
3168
+ self.memory.push("user", res.output)
3169
+
3170
+ # ── File Patch ────────────────────────────────────────────────
3171
+ elif action == "file_patch":
3172
+ path = d.get("path", "")
3173
+ _print_action("file_patch", obfuscator.unmask(path))
3174
+ res = tool_file_patch(path, d.get("diff", ""))
3175
+ _print_result(res.success, res.output)
3176
+ self.memory.push("user", res.output)
3177
+
3178
+ # ── Git ───────────────────────────────────────────────────────
3179
+ elif action == "git":
3180
+ subcmd = d.get("subcmd", "status")
3181
+ _print_action("git", f"git {subcmd}")
3182
+ res = tool_git(subcmd)
3183
+ _print_result(res.success, res.output)
3184
+ self.memory.push("user", f"git {subcmd}:\n{obfuscator.mask(res.output)}")
3185
+
3186
+ # ── Python Eval ───────────────────────────────────────────────
3187
+ elif action == "python_eval":
3188
+ code = d.get("code", "")
3189
+ _print_action("python_eval", code[:80])
3190
+ res = tool_python_eval(code)
3191
+ _print_result(res.success, res.output)
3192
+ self.memory.push("user", f"Python output:\n{res.output}")
3193
+
3194
+ # ── Search ────────────────────────────────────────────────────
3195
+ elif action == "search":
3196
+ query = d.get("query", "")
3197
+ _print_action("search", obfuscator.unmask(query))
3198
+ res = tool_search(query)
3199
+ _print_result(True, res.output[:600])
3200
+ self.memory.push("user", f"Search results:\n{res.output}")
3201
+
3202
+ # ── Code Search (Semantic Indexer) ───────────────────────────
3203
+ elif action == "code_search":
3204
+ query = d.get("query", "")
3205
+ top_k = int(d.get("top_k", 5))
3206
+ _print_action("code_search", f"'{query}' (top {top_k})")
3207
+ res = tool_code_search(query, top_k)
3208
+ _print_result(res.success, res.output[:600])
3209
+ self.memory.push("user", f"Code search results:\n{res.output}")
3210
+
3211
+ # ── MCP Call (Model Context Protocol) ────────────────────────
3212
+ elif action == "mcp_call":
3213
+ server = d.get("server", "")
3214
+ tool_name = d.get("tool", "")
3215
+ args = d.get("arguments") or {}
3216
+ _print_action("mcp_call", f"{server}::{tool_name}")
3217
+ res = tool_mcp_call(server, tool_name, args)
3218
+ _print_result(res.success, res.output[:600])
3219
+ self.memory.push("user", f"MCP [{server}::{tool_name}] output:\n{res.output}")
3220
+
3221
+ # ── Deploy ────────────────────────────────────────────────────
3222
+ elif action == "deploy":
3223
+ platform = d.get("platform", "")
3224
+ subcmd = d.get("subcmd", "")
3225
+ detail = f"{platform} {subcmd}".strip() or "auto"
3226
+ _print_action("deploy", detail)
3227
+ res = tool_deploy(subcmd, platform)
3228
+ _print_result(res.success, res.output)
3229
+ self.memory.push("user", res.output)
3230
+
3231
+ # ── Spawn (new terminal window or background process) ─────────
3232
+ elif action == "spawn":
3233
+ cmd = d.get("cmd", "")
3234
+ name = d.get("name", "")
3235
+ mode = d.get("mode", "window")
3236
+ real_cmd = obfuscator.unmask(cmd)
3237
+ mode_label = "🪟 NEW WINDOW" if mode == "window" else "⚙ BACKGROUND"
3238
+ _print_action("spawn", f"{mode_label} → {real_cmd[:80]}")
3239
+ res = tool_spawn(cmd, name, mode)
3240
+ _print_result(res.success, res.output)
3241
+ self.memory.push("user", res.output)
3242
+
3243
+ # ── Kill ──────────────────────────────────────────────────────
3244
+ elif action == "kill":
3245
+ pid = d.get("pid")
3246
+ name = d.get("name", "")
3247
+ label = f"PID {pid}" if pid else f"name={name}"
3248
+ _print_action("kill", f"🔴 Killing {label}")
3249
+ res = tool_kill(pid, name)
3250
+ _print_result(res.success, res.output)
3251
+ self.memory.push("user", res.output)
3252
+
3253
+ # ── Process Status ────────────────────────────────────────────
3254
+ elif action == "process_status":
3255
+ pid = d.get("pid")
3256
+ _print_action("process_status", f"PID={pid}" if pid else "all processes")
3257
+ res = tool_process_status(pid, d.get("tail", 60))
3258
+ _print_result(True, res.output)
3259
+ self.memory.push("user", res.output)
3260
+
3261
+ # ── Read Log ──────────────────────────────────────────────────
3262
+ elif action == "read_log":
3263
+ pid = d.get("pid", 0)
3264
+ tail = d.get("tail", 80)
3265
+ _print_action("read_log", f"PID={pid} (last {tail} lines)")
3266
+ res = tool_read_log(pid, tail)
3267
+ _print_result(True, res.output)
3268
+ self.memory.push("user", f"Process log:\n{res.output}")
3269
+
3270
+ # ── Ask ───────────────────────────────────────────────────────
3271
+ elif action == "ask":
3272
+ msg = d.get("message", "I need your input.")
3273
+ _print_action("ask", msg)
3274
+ if RICH:
3275
+ console.print(Panel(f"[bold cyan]{msg}[/bold cyan]", title="💬 IGNITE NEEDS INPUT",
3276
+ border_style="cyan"))
3277
+ else:
3278
+ print(f"\n{C.BCYN}💬 {msg}{C.RST}")
3279
+ reply = input(f"{C.BYEL}Your reply> {C.RST}").strip()
3280
+ obfuscator.register(reply)
3281
+ safe_reply = obfuscator.mask(reply)
3282
+ self.memory.push("user", f"User replied: {safe_reply}")
3283
+
3284
+ # ── Generic Registered Tool Execution (verify_code, git_diff, etc.) ──
3285
+ elif action in TOOL_DISPATCH:
3286
+ fn = TOOL_DISPATCH[action]
3287
+ arg_summary = str(d.get("cmd") or d.get("path") or d.get("query") or "")
3288
+ _print_action(action, arg_summary[:80])
3289
+ res = fn(d)
3290
+ _print_result(res.success, res.output[:500] + "..." if len(res.output) > 500 else res.output)
3291
+ self.memory.push("user", res.output)
3292
+
3293
+ # ── Done ──────────────────────────────────────────────────────
3294
+ elif action == "done":
3295
+ msg = d.get("message", "Task complete.")
3296
+ if RICH:
3297
+ console.print(Panel(Markdown(msg), title="[bold green]✅ IGNITE FINISHED[/bold green]",
3298
+ border_style="green", padding=(1, 2)))
3299
+ else:
3300
+ print(f"\n{C.BGRN}✅ {msg}{C.RST}\n")
3301
+ outcome = "success"
3302
+ self.memory.record_episode(user_input[:80], msg[:200],
3303
+ self.tools_used_this_run, outcome)
3304
+ break
3305
+
3306
+ else:
3307
+ print(f"\n{C.RED}⏱ Max iterations reached. Pausing agent.{C.RST}")
3308
+ self.memory.record_episode(user_input[:80], "Max iterations hit",
3309
+ self.tools_used_this_run, "partial")
3310
+
3311
+ self.total_steps += step
3312
+ if CFG["show_token_stats"]:
3313
+ _divider()
3314
+ print(f"{C.GRY}📊 {tracker.summary()} | Steps: {step} | Model: {model_name}{C.RST}")
3315
+
3316
+
3317
+ # ══════════════════════════════════════════════════════════════════════════════
3318
+ # SECTION 14b — MULTI-AGENT SWARM (Planner · Engineer · Critic Triad)
3319
+ # ══════════════════════════════════════════════════════════════════════════════
3320
+ @dataclass
3321
+ class SwarmMilestone:
3322
+ id: int
3323
+ title: str
3324
+ description: str
3325
+ acceptance_criteria: str
3326
+ status: str = "PENDING" # PENDING | IN_PROGRESS | COMPLETED | FAILED
3327
+ critic_notes: str = ""
3328
+ attempts: int = 0
3329
+
3330
+ class SwarmOrchestrator:
3331
+ """
3332
+ Enterprise Hierarchical Multi-Agent Triad:
3333
+ 1. The Architect (Planner): Decomposes goal into Milestone DAG.
3334
+ 2. The Engineer (Coder): Precision milestone executor.
3335
+ 3. The Critic (Reviewer): Automated quality & verification gatekeeper.
3336
+ """
3337
+ def __init__(self, session: IgniteSession):
3338
+ self.session = session
3339
+ self.active_plan: List[SwarmMilestone] = []
3340
+ self.plan_title: str = ""
3341
+ self.enabled: bool = True
3342
+
3343
+ def render_board(self):
3344
+ """Render a live status board of all milestones."""
3345
+ if not self.active_plan:
3346
+ return
3347
+
3348
+ rows = []
3349
+ for m in self.active_plan:
3350
+ if m.status == "COMPLETED":
3351
+ icon = f"{C.BGRN}✔ DONE{C.RST}"
3352
+ elif m.status == "IN_PROGRESS":
3353
+ icon = f"{C.BYEL}▶ ACTIVE{C.RST}"
3354
+ elif m.status == "FAILED":
3355
+ icon = f"{C.BRED}✖ FAILED{C.RST}"
3356
+ else:
3357
+ icon = f"{C.DIM}⏳ PENDING{C.RST}"
3358
+ rows.append((str(m.id), icon, m.title, m.acceptance_criteria[:50]))
3359
+
3360
+ if RICH:
3361
+ t = Table(title=f"⚡ MULTI-AGENT SWARM BOARD — {self.plan_title}", box=box.ROUNDED)
3362
+ t.add_column("#", style="dim", width=4)
3363
+ t.add_column("Status", width=12)
3364
+ t.add_column("Milestone Title", style="bold cyan")
3365
+ t.add_column("Acceptance Criteria", style="dim")
3366
+ for r in rows:
3367
+ t.add_row(*r)
3368
+ console.print(t)
3369
+ else:
3370
+ print(f"\n{C.BCYN}╔════ ⚡ MULTI-AGENT SWARM BOARD: {self.plan_title} ════╗{C.RST}")
3371
+ for r in rows:
3372
+ print(f" [{r[0]}] {r[1]} {r[2]} — {r[3]}")
3373
+ print(f"{C.BCYN}╚{'═' * 55}╝{C.RST}\n")
3374
+
3375
+ def plan_goal(self, goal: str) -> List[SwarmMilestone]:
3376
+ """Architect Agent decomposes high-level goal into verifiable milestones."""
3377
+ scan = shell.scan_project()
3378
+ prompt = f"""You are the ARCHITECT AGENT in IGNITE V3 PRO.
3379
+ Your job is to break down the user's software engineering goal into 3 to 6 logical, verifiable milestones.
3380
+
3381
+ Goal: {goal}
3382
+ Current Workspace: {shell.cwd}
3383
+ Stacks Detected: {', '.join(scan['stacks'])}
3384
+ Files: {list(scan['structure'].keys())[:12]}
3385
+
3386
+ Output ONLY a JSON object in this exact schema:
3387
+ {{
3388
+ "plan_title": "<short descriptive title of plan>",
3389
+ "milestones": [
3390
+ {{
3391
+ "id": 1,
3392
+ "title": "<concise title>",
3393
+ "description": "<what needs to be created or configured>",
3394
+ "acceptance_criteria": "<how to test or verify this milestone is finished>"
3395
+ }}
3396
+ ]
3397
+ }}
3398
+ Do NOT output any markdown, explanations, or prose. Just pure JSON.
3399
+ """
3400
+ messages = [
3401
+ {"role": "system", "content": "You are a Principal Software Architect. Output ONLY JSON."},
3402
+ {"role": "user", "content": prompt}
3403
+ ]
3404
+ raw, model_name = router.query(messages, stream=False)
3405
+ try:
3406
+ cleaned = re.sub(r"^```(?:json)?\n?|\n?```$", "", raw.strip(), flags=re.IGNORECASE)
3407
+ data = json.loads(cleaned)
3408
+ self.plan_title = data.get("plan_title", goal[:40])
3409
+ self.active_plan = []
3410
+ for item in data.get("milestones", []):
3411
+ self.active_plan.append(SwarmMilestone(
3412
+ id=item.get("id", len(self.active_plan) + 1),
3413
+ title=item.get("title", f"Milestone {len(self.active_plan) + 1}"),
3414
+ description=item.get("description", ""),
3415
+ acceptance_criteria=item.get("acceptance_criteria", "Code compiles cleanly")
3416
+ ))
3417
+ except Exception:
3418
+ self.plan_title = goal[:40]
3419
+ self.active_plan = [
3420
+ SwarmMilestone(1, "Setup & Dependencies", "Configure environment and packages", "Dependencies verified"),
3421
+ SwarmMilestone(2, "Core Implementation", goal, "Core feature implementation complete"),
3422
+ SwarmMilestone(3, "Verification & Audit", "Run automated tests and audit functionality", "All tests pass green")
3423
+ ]
3424
+ return self.active_plan
3425
+
3426
+ def audit_milestone(self, milestone: SwarmMilestone) -> Tuple[bool, str]:
3427
+ """Critic Agent audits milestone output using git diff and verification."""
3428
+ diff_text = git_shield.diff()
3429
+ verif_res = tool_verify_code()
3430
+
3431
+ prompt = f"""You are the CRITIC & CODE REVIEWER in IGNITE V3 PRO.
3432
+ Audit the following milestone completion:
3433
+ Milestone: {milestone.title}
3434
+ Acceptance Criteria: {milestone.acceptance_criteria}
3435
+
3436
+ Recent Diffs:
3437
+ {diff_text[:1200]}
3438
+
3439
+ Verification Output:
3440
+ {verif_res.output[:800]}
3441
+
3442
+ Evaluate if this milestone is acceptable to mark as COMPLETED.
3443
+ Output ONLY JSON in this format:
3444
+ {{
3445
+ "verdict": "APPROVED" or "REJECTED",
3446
+ "score": <0-100>,
3447
+ "feedback": "<brief rationale>",
3448
+ "required_fixes": ["<fix 1>", "<fix 2>"]
3449
+ }}
3450
+ """
3451
+ messages = [
3452
+ {"role": "system", "content": "You are an Elite Code Reviewer & Security Auditor. Output ONLY JSON."},
3453
+ {"role": "user", "content": prompt}
3454
+ ]
3455
+ raw, model_name = router.query(messages, stream=False)
3456
+ try:
3457
+ cleaned = re.sub(r"^```(?:json)?\n?|\n?```$", "", raw.strip(), flags=re.IGNORECASE)
3458
+ data = json.loads(cleaned)
3459
+ verdict = data.get("verdict", "APPROVED").upper()
3460
+ feedback = data.get("feedback", "Code meets requirements.")
3461
+ return verdict == "APPROVED", feedback
3462
+ except Exception:
3463
+ return verif_res.success, "Verification pass completed."
3464
+
3465
+ def execute_plan(self, goal: str):
3466
+ """Orchestrate the complete Swarm lifecycle: Architect ➔ Engineer ➔ Critic."""
3467
+ print(f"\n{C.BCYN}🚀 INITIATING MULTI-AGENT SWARM ORCHESTRATION...{C.RST}")
3468
+ print(f"{C.GRY}Role 1: 🏛 The Architect (Decomposing Goal into Milestone DAG){C.RST}")
3469
+
3470
+ milestones = self.plan_goal(goal)
3471
+ self.render_board()
3472
+
3473
+ completed_count = 0
3474
+ for m in milestones:
3475
+ m.status = "IN_PROGRESS"
3476
+ self.render_board()
3477
+
3478
+ print(f"\n{C.BBLU}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━{C.RST}")
3479
+ print(f"{C.BGRN}👷 Engineer Agent executing Milestone {m.id}: {m.title}{C.RST}")
3480
+ print(f"{C.DIM}Target: {m.description}{C.RST}")
3481
+ print(f"{C.BBLU}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━{C.RST}\n")
3482
+
3483
+ task_prompt = (
3484
+ f"You are the ENGINEER AGENT working on Milestone {m.id}/{len(milestones)} of goal: '{self.plan_title}'.\n"
3485
+ f"Milestone Title: {m.title}\n"
3486
+ f"Description: {m.description}\n"
3487
+ f"Acceptance Criteria: {m.acceptance_criteria}\n"
3488
+ f"Execute the necessary file_write, file_patch, terminal, spawn, or verify_code operations. "
3489
+ f"When this milestone is satisfied, output: {{\"action\":\"done\",\"message\":\"Milestone {m.id} completed\"}}"
3490
+ )
3491
+ self.session.run(task_prompt, max_iter=8)
3492
+
3493
+ # Critic Review Pass
3494
+ print(f"\n{C.MAG}🕵️ Critic & Reviewer Agent auditing Milestone {m.id}...{C.RST}")
3495
+ approved, feedback = self.audit_milestone(m)
3496
+
3497
+ if approved:
3498
+ m.status = "COMPLETED"
3499
+ m.critic_notes = feedback
3500
+ completed_count += 1
3501
+ print(f"{C.BGRN}✔ Critic APPROVED Milestone {m.id}: {feedback}{C.RST}\n")
3502
+ else:
3503
+ print(f"{C.BYEL}⚠ Critic requested refinements: {feedback}{C.RST}")
3504
+ print(f"{C.CYAN}🔄 Triggering Engineer auto-correction pass...{C.RST}")
3505
+ fix_prompt = f"CRITIC FEEDBACK on Milestone '{m.title}': {feedback}. Fix these issues now and run verify_code."
3506
+ self.session.run(fix_prompt, max_iter=4)
3507
+ m.status = "COMPLETED"
3508
+ completed_count += 1
3509
+
3510
+ self.render_board()
3511
+
3512
+ _divider()
3513
+ if RICH:
3514
+ console.print(Panel(
3515
+ f"[bold green]✔ All {completed_count}/{len(milestones)} Milestones Completed & Verified![/bold green]\n"
3516
+ f"Goal: [bold cyan]{self.plan_title}[/bold cyan]\n"
3517
+ f"Inspect changes with [bold yellow]/diff[/bold yellow] or rollback with [bold red]/undo[/bold red].",
3518
+ title="🏆 SWARM GOAL ACHIEVED",
3519
+ border_style="green",
3520
+ padding=(1, 2)
3521
+ ))
3522
+ else:
3523
+ print(f"\n{C.BGRN}🏆 SWARM GOAL ACHIEVED: All {completed_count}/{len(milestones)} Milestones Completed & Verified!{C.RST}\n")
3524
+
3525
+
3526
+ # ══════════════════════════════════════════════════════════════════════════════
3527
+ # SECTION 15 — SLASH COMMAND HANDLER
3528
+ # ══════════════════════════════════════════════════════════════════════════════
3529
+ def interactive_model_selector() -> Optional[str]:
3530
+ """
3531
+ Interactive terminal model selector:
3532
+ - Arrow keys (Up/Down) to navigate
3533
+ - Enter to select highlighted model
3534
+ - Number key (0-9) to select immediately
3535
+ - 'r' to reset quota blocks
3536
+ - 'q' or Esc to cancel
3537
+ """
3538
+ items = [("auto", "Auto / Smart Failover (Dynamic Priority Routing)")]
3539
+ for p in PROVIDERS:
3540
+ if p.quota_blocked:
3541
+ st = f"{C.RED}[BLOCKED - Quota]{C.RST}"
3542
+ elif p.exhausted:
3543
+ st = f"{C.YEL}[EXHAUSTED]{C.RST}"
3544
+ else:
3545
+ st = f"{C.GRN}[ACTIVE]{C.RST}"
3546
+ items.append((p.id, f"{p.name} {C.DIM}({p.model}){C.RST} {st}"))
3547
+
3548
+ # Determine current index
3549
+ selected_idx = 0
3550
+ if router.override_id:
3551
+ for idx, (pid, _) in enumerate(items):
3552
+ if pid == router.override_id:
3553
+ selected_idx = idx
3554
+ break
3555
+
3556
+ # If stdin is not an interactive tty
3557
+ if not sys.stdin.isatty():
3558
+ print(f"\n{C.BCYN}── Select AI Model ──{C.RST}")
3559
+ for i, (pid, label) in enumerate(items):
3560
+ cur = f" {C.CYN}★ CURRENT{C.RST}" if i == selected_idx else ""
3561
+ print(f" [{i}] {label}{cur}")
3562
+ print(f" [r] Reset all quota blocks")
3563
+ print(f" [q] Cancel")
3564
+ try:
3565
+ choice = input(f"\nSelect [0-{len(items)-1}, r, q]: ").strip().lower()
3566
+ if choice in ("r", "reset"): return "reset"
3567
+ if choice in ("q", ""): return None
3568
+ if choice.isdigit() and 0 <= int(choice) < len(items):
3569
+ return items[int(choice)][0]
3570
+ except Exception:
3571
+ pass
3572
+ return None
3573
+
3574
+ # Interactive console selection (Windows msvcrt)
3575
+ import msvcrt
3576
+ os.system("") # Enable ANSI terminal processing
3577
+
3578
+ total_lines = len(items) + 3
3579
+
3580
+ def draw(cur: int, first: bool = False):
3581
+ if not first:
3582
+ sys.stdout.write(f"\033[{total_lines}F")
3583
+ lines = []
3584
+ lines.append(f"{C.BCYN}🤖 Select AI Model (Use ↑/↓ Arrow Keys, or press 1, 2, ..., Enter to choose):{C.RST}\033[K")
3585
+ for i, (pid, label) in enumerate(items):
3586
+ active_mark = f" {C.CYN}★ CURRENT{C.RST}" if (router.override_id == pid or (pid == 'auto' and router.override_id is None)) else ""
3587
+ if i == cur:
3588
+ lines.append(f" {C.BBLU}▶ [{i}] {label}{active_mark}{C.RST}\033[K")
3589
+ else:
3590
+ lines.append(f" [{i}] {label}{active_mark}\033[K")
3591
+ lines.append(f" {C.DIM}[r] Reset Quota Blocks | [q/Esc] Cancel{C.RST}\033[K")
3592
+ lines.append("\033[K")
3593
+ sys.stdout.write("\n".join(lines) + "\n")
3594
+ sys.stdout.flush()
3595
+
3596
+ try:
3597
+ sys.stdout.write("\033[?25l") # Hide cursor
3598
+ draw(selected_idx, first=True)
3599
+
3600
+ while True:
3601
+ ch = msvcrt.getch()
3602
+
3603
+ # Arrow keys & scan codes on Windows
3604
+ if ch in (b'\x00', b'\xe0'):
3605
+ code = msvcrt.getch()
3606
+ if code == b'H': # UP
3607
+ selected_idx = (selected_idx - 1) % len(items)
3608
+ draw(selected_idx)
3609
+ elif code == b'P': # DOWN
3610
+ selected_idx = (selected_idx + 1) % len(items)
3611
+ draw(selected_idx)
3612
+ continue
3613
+
3614
+ # Enter confirms current selection
3615
+ if ch in (b'\r', b'\n'):
3616
+ return items[selected_idx][0]
3617
+
3618
+ # Cancel
3619
+ if ch in (b'\x1b', b'q', b'Q'):
3620
+ return None
3621
+
3622
+ # Reset
3623
+ if ch in (b'r', b'R'):
3624
+ return "reset"
3625
+
3626
+ # Direct number keys (0-9)
3627
+ if ch.isdigit():
3628
+ val = int(ch.decode('ascii'))
3629
+ if val < len(items):
3630
+ selected_idx = val
3631
+ draw(selected_idx)
3632
+ return items[selected_idx][0]
3633
+ finally:
3634
+ sys.stdout.write("\033[?25h") # Restore cursor
3635
+ sys.stdout.flush()
3636
+
3637
+ return None
3638
+
3639
+ def handle_slash(cmd: str, session: IgniteSession) -> bool:
3640
+ """Returns True if command was handled (skip LLM)."""
3641
+ parts = cmd.strip().split(None, 1)
3642
+ verb = parts[0].lower()
3643
+ arg = parts[1] if len(parts) > 1 else ""
3644
+
3645
+ if verb == "/model":
3646
+ selected = None
3647
+ if not arg:
3648
+ selected = interactive_model_selector()
3649
+ if selected is None:
3650
+ print(f"{C.DIM}Model selection cancelled.{C.RST}")
3651
+ return True
3652
+ else:
3653
+ arg_clean = arg.strip().lower()
3654
+ if arg_clean in ("reset", "r"):
3655
+ selected = "reset"
3656
+ elif arg_clean in ("0", "auto"):
3657
+ selected = "auto"
3658
+ elif arg_clean.isdigit():
3659
+ num = int(arg_clean)
3660
+ if num == 0:
3661
+ selected = "auto"
3662
+ elif 1 <= num <= len(PROVIDERS):
3663
+ selected = PROVIDERS[num - 1].id
3664
+ else:
3665
+ print(f"{C.RED}Invalid model number: {arg}. Available: 0 to {len(PROVIDERS)}{C.RST}")
3666
+ return True
3667
+ else:
3668
+ match = next((p for p in PROVIDERS if p.id.lower() == arg_clean or arg_clean in p.name.lower()), None)
3669
+ if match:
3670
+ selected = match.id
3671
+ else:
3672
+ print(f"{C.RED}Unknown model: {arg}. Type /model to open selector.{C.RST}")
3673
+ return True
3674
+
3675
+ if selected == "reset":
3676
+ for p in PROVIDERS:
3677
+ p.exhausted = False
3678
+ p.quota_blocked = False
3679
+ p.total_errors = 0
3680
+ router.override_id = None
3681
+ print(f"{C.GRN}✔ All session quota blocks cleared. Auto routing active.{C.RST}")
3682
+ elif selected == "auto":
3683
+ router.override_id = None
3684
+ print(f"{C.GRN}✔ Switched to Auto (Smart Failover Router active){C.RST}")
3685
+ else:
3686
+ match = next((p for p in PROVIDERS if p.id == selected), None)
3687
+ if match:
3688
+ router.override_id = selected
3689
+ print(f"{C.GRN}✔ Locked to: {match.name} ({match.model}){C.RST}")
3690
+ else:
3691
+ print(f"{C.RED}Unknown model id: {selected}{C.RST}")
3692
+ return True
3693
+
3694
+ if verb == "/memory":
3695
+ print(session.memory.format_episodes(10))
3696
+ print(f"History messages: {len(session.memory.get_history())}")
3697
+ return True
3698
+
3699
+ if verb == "/clear":
3700
+ session.memory._history = session.memory.get_history()[:1] # keep system prompt
3701
+ print(f"{C.GRN}✔ Chat history cleared (episodes preserved){C.RST}")
3702
+ return True
3703
+
3704
+ if verb == "/session":
3705
+ sub = arg.split()[0] if arg else ""
3706
+ if sub == "save":
3707
+ path = session.session_mgr.save(session.memory)
3708
+ print(f"{C.GRN}✔ Saved → {path}{C.RST}")
3709
+ elif sub == "list":
3710
+ sessions = session.session_mgr.list_sessions()
3711
+ print("Sessions:\n" + "\n".join(f" {s}" for s in sessions[:20]))
3712
+ elif sub == "load":
3713
+ name = arg.split(None,1)[1] if " " in arg else ""
3714
+ data = session.session_mgr.load(name)
3715
+ if data:
3716
+ session.memory._history = data.get("history", [])
3717
+ session.memory.episodes = [Episode(**e) for e in data.get("episodes", [])]
3718
+ print(f"{C.GRN}✔ Loaded session: {name}{C.RST}")
3719
+ else:
3720
+ print(f"{C.RED}Session not found: {name}{C.RST}")
3721
+ else:
3722
+ print("Usage: /session save | list | load <name>")
3723
+ return True
3724
+
3725
+ if verb in ("/undo", "/rollback"):
3726
+ ok, msg = git_shield.rollback()
3727
+ if ok:
3728
+ print(f"{C.BGRN}✔ {msg}{C.RST}")
3729
+ else:
3730
+ print(f"{C.YEL}⚠ {msg}{C.RST}")
3731
+ return True
3732
+
3733
+ if verb == "/diff":
3734
+ diff_text = git_shield.diff(arg.strip())
3735
+ if RICH:
3736
+ syntax = Syntax(diff_text, "diff", theme="monokai", line_numbers=True)
3737
+ console.print(Panel(syntax, title="🔍 Working Tree Diff", box=box.ROUNDED))
3738
+ else:
3739
+ print(f"\n{C.BCYN}── Working Tree Diff ──{C.RST}\n{diff_text}\n")
3740
+ return True
3741
+
3742
+ if verb == "/verify":
3743
+ res = tool_verify_code(cmd=arg.strip())
3744
+ if res.success:
3745
+ print(f"{C.BGRN}{res.output}{C.RST}")
3746
+ else:
3747
+ print(f"{C.BRED}{res.output}{C.RST}")
3748
+ return True
3749
+
3750
+ if verb == "/commit":
3751
+ msg = arg.strip()
3752
+ if not msg:
3753
+ msg = "chore: update codebase via IGNITE V3 PRO"
3754
+ st = shell.git_op("add -A")
3755
+ cm = shell.git_op(f"commit -m \"{msg}\"")
3756
+ if cm.ok:
3757
+ print(f"{C.BGRN}✔ Committed: {msg}{C.RST}\n{cm.stdout}")
3758
+ else:
3759
+ print(f"{C.YEL}Git commit output:\n{cm.log}{C.RST}")
3760
+ return True
3761
+
3762
+ if verb == "/checkpoint":
3763
+ chk_id = git_shield.create_checkpoint(arg.strip() or "Manual Checkpoint")
3764
+ print(f"{C.BGRN}✔ Safety checkpoint created: {chk_id}{C.RST}")
3765
+ return True
3766
+
3767
+ if verb == "/plan":
3768
+ sub = arg.strip()
3769
+ if not sub or sub.lower() == "status":
3770
+ if session.swarm and session.swarm.active_plan:
3771
+ session.swarm.render_board()
3772
+ else:
3773
+ print(f"{C.YEL}No active swarm plan.{C.RST}")
3774
+ print(f"Usage: /plan <goal> (e.g., /plan build full-stack auth system)")
3775
+ elif sub.lower() in ("clear", "reset"):
3776
+ if session.swarm:
3777
+ session.swarm.active_plan = []
3778
+ session.swarm.plan_title = ""
3779
+ print(f"{C.GRN}✔ Active plan board cleared.{C.RST}")
3780
+ else:
3781
+ session.swarm.execute_plan(sub)
3782
+ return True
3783
+
3784
+ if verb == "/swarm":
3785
+ sub = arg.lower().strip()
3786
+ if sub in ("on", "enable"):
3787
+ session.swarm.enabled = True
3788
+ print(f"{C.BGRN}✔ Multi-Agent Swarm mode ENABLED.{C.RST}")
3789
+ elif sub in ("off", "disable"):
3790
+ session.swarm.enabled = False
3791
+ print(f"{C.BYEL}⚠ Multi-Agent Swarm mode DISABLED (Direct mode active).{C.RST}")
3792
+ else:
3793
+ status = "ENABLED" if session.swarm.enabled else "DISABLED"
3794
+ print(f"{C.BCYN}⚡ Swarm Status: {status}{C.RST}")
3795
+ print(f" Architect (Planner) ➔ Engineer (Coder) ➔ Critic (Auditor)")
3796
+ print(f" Run: /plan <goal> to launch multi-agent milestone execution.")
3797
+ return True
3798
+
3799
+ if verb == "/index":
3800
+ sub = arg.strip().lower()
3801
+ if sub in ("rebuild", "force", "reset"):
3802
+ print(f"{C.BCYN}🔄 Rebuilding codebase semantic index from scratch...{C.RST}")
3803
+ stats = code_indexer.index_workspace(force=True)
3804
+ print(f"{C.BGRN}✔ Index rebuilt:{C.RST} Scanned {stats['scanned']} files, indexed {stats['indexed']} files, {stats['total_chunks']} total code chunks.")
3805
+ elif sub == "status":
3806
+ print(f"{C.BCYN}📊 Codebase Index Status:{C.RST} {code_indexer.status()}")
3807
+ else:
3808
+ print(f"{C.BCYN}🔍 Scanning & incrementally updating codebase index...{C.RST}")
3809
+ stats = code_indexer.index_workspace(force=False)
3810
+ print(f"{C.BGRN}✔ Index synced:{C.RST} Scanned {stats['scanned']} files, updated {stats['indexed']} files, {stats['total_chunks']} total code chunks in DB.")
3811
+ print(f"{C.GRY}Run /search <keywords/symbol> to query or /index rebuild to force re-indexing.{C.RST}")
3812
+ return True
3813
+
3814
+ if verb in ("/search", "/find"):
3815
+ if not arg.strip():
3816
+ print(f"{C.YEL}Usage: /search <keywords or symbol name> (e.g., /search verify_token){C.RST}")
3817
+ return True
3818
+ matches = code_indexer.search(arg.strip(), top_k=6)
3819
+ if not matches:
3820
+ print(f"{C.YEL}No code matches found for '{arg.strip()}'. Try /index rebuild or broader terms.{C.RST}")
3821
+ return True
3822
+ print(f"\n{C.BCYN}🔍 Codebase Search Results for '{arg.strip()}':{C.RST}")
3823
+ for i, m in enumerate(matches, 1):
3824
+ sym = f" [{m['symbol_type']}: {m['symbol_name']}]" if m['symbol_name'] else ""
3825
+ print(f"\n{C.BGRN}{i}. {m['file_path']}:{m['start_line']}-{m['end_line']}{C.BCYN}{sym}{C.RST}")
3826
+ snip = textwrap.indent(m["content"][:400], " ")
3827
+ print(f"{C.DIM}{snip}{C.RST}")
3828
+ if len(m["content"]) > 400:
3829
+ print(f"{C.GRY} ... [truncated]{C.RST}")
3830
+ print()
3831
+ return True
3832
+
3833
+ if verb == "/mcp":
3834
+ sub = arg.strip()
3835
+ parts = sub.split()
3836
+ cmd_type = parts[0].lower() if parts else "list"
3837
+
3838
+ if cmd_type == "init":
3839
+ p = mcp_registry.init_config_template()
3840
+ print(f"{C.BGRN}✔ Created starter MCP configuration template at:{C.RST} {p}")
3841
+ print(f"{C.GRY}Edit .ignite_mcp.json to add your MCP servers, then run /mcp list.{C.RST}")
3842
+ elif cmd_type in ("status", "servers"):
3843
+ print(f"{C.BCYN}⚡ Model Context Protocol (MCP) Status:{C.RST}\n{mcp_registry.status()}")
3844
+ elif cmd_type == "call" and len(parts) >= 3:
3845
+ server_name = parts[1]
3846
+ tool_name = parts[2]
3847
+ raw_args = " ".join(parts[3:]) if len(parts) > 3 else "{}"
3848
+ try:
3849
+ call_args = json.loads(raw_args)
3850
+ except Exception as e:
3851
+ print(f"{C.RED}Invalid JSON arguments: {e}{C.RST}")
3852
+ return True
3853
+ print(f"{C.CYAN}Invoking {server_name}::{tool_name}...{C.RST}")
3854
+ res = mcp_registry.call_tool(server_name, tool_name, call_args)
3855
+ col = C.BGRN if res.success else C.BRED
3856
+ print(f"{col}{res.output}{C.RST}")
3857
+ else:
3858
+ cfg = mcp_registry.load_config().get("mcpServers", {})
3859
+ if not cfg:
3860
+ print(f"{C.YEL}No MCP servers configured in .ignite_mcp.json.{C.RST}")
3861
+ print(f"{C.GRY}Run {C.BCYN}/mcp init{C.GRY} to create a starter configuration.{C.RST}")
3862
+ return True
3863
+ tools = mcp_registry.list_tools()
3864
+ print(f"\n{C.BCYN}🔌 Model Context Protocol (MCP) Tools ({len(tools)} available):{C.RST}")
3865
+ if tools:
3866
+ for t in tools:
3867
+ print(f" • {C.BGRN}{t['server']}::{t['name']}{C.RST} — {t['description']}")
3868
+ else:
3869
+ print(f" {C.DIM}No tools reported. Check server connection status with /mcp status.{C.RST}")
3870
+ print(f"{C.GRY}Commands: /mcp list | /mcp status | /mcp init | /mcp call <srv> <tool> <json>{C.RST}\n")
3871
+ return True
3872
+
3873
+ if verb in ("/setup", "/keys", "/config-keys"):
3874
+ if run_first_time_setup():
3875
+ new_provs = build_providers()
3876
+ PROVIDERS.clear()
3877
+ PROVIDERS.extend(new_provs)
3878
+ print(f"{C.BGRN}✔ Active AI providers reloaded ({len(PROVIDERS)} providers active).{C.RST}")
3879
+ return True
3880
+
3881
+ if verb == "/tools":
3882
+ tools = list(TOOL_DISPATCH.keys()) + ["ask", "done"]
3883
+ if RICH:
3884
+ t = Table(title="🛠 Available Tools (18 Tools)", box=box.SIMPLE)
3885
+ t.add_column("Tool", style="cyan")
3886
+ t.add_column("Description")
3887
+ descs = {
3888
+ "terminal": "Run PowerShell commands with streaming output",
3889
+ "file_read": "Read file contents with line ranges",
3890
+ "file_write": "Create/overwrite files with AST syntax pre-flight",
3891
+ "file_patch": "Apply unified diff patches surgical-style",
3892
+ "git": "Git operations (status/commit/push/clone/branch)",
3893
+ "git_diff": "Inspect uncommitted code changes in working tree",
3894
+ "verify_code": "Self-healing test runner (pytest/unittest/npm/compile)",
3895
+ "python_eval": "Run sandboxed Python code inside agent",
3896
+ "spawn": "Launch process in new visible terminal window or background",
3897
+ "kill": "Terminate running process by PID or name",
3898
+ "process_status": "Inspect running processes or inspect process logs",
3899
+ "read_log": "Read live output logs from background/window tasks",
3900
+ "search": "Web search (DuckDuckGo/Bing fallback)",
3901
+ "code_search": "Semantic hybrid BM25 + AST symbol search across project",
3902
+ "mcp_call": "Call external tool on Model Context Protocol (MCP) server",
3903
+ "deploy": "Auto-detect & run deployment workflow",
3904
+ "ask": "Pause and request credentials/decisions from user",
3905
+ "done": "Complete task or answer user directly",
3906
+ }
3907
+ for tool in tools:
3908
+ t.add_row(f"[cyan]{tool}[/cyan]", descs.get(tool, ""))
3909
+ console.print(t)
3910
+ else:
3911
+ for tool in tools:
3912
+ print(f" {tool}")
3913
+ return True
3914
+
3915
+ if verb in ("/ps", "/processes"):
3916
+ if arg:
3917
+ pid = int(arg) if arg.isdigit() else None
3918
+ print(proc_registry.status(pid=pid, tail=40))
3919
+ else:
3920
+ print(proc_registry.status())
3921
+ return True
3922
+
3923
+ if verb == "/kill":
3924
+ if not arg:
3925
+ print(f"{C.RED}Usage: /kill <pid or process_name>{C.RST}")
3926
+ else:
3927
+ if arg.isdigit():
3928
+ res = proc_registry.kill(int(arg))
3929
+ else:
3930
+ res = proc_registry.kill_by_name(arg)
3931
+ print(f"{C.GRN}{res}{C.RST}")
3932
+ return True
3933
+
3934
+ if verb == "/status":
3935
+ scan = shell.scan_project()
3936
+ if RICH:
3937
+ t = Table(title=f"📁 Project Status — {shell.cwd}", box=box.MINIMAL_DOUBLE_HEAD)
3938
+ t.add_column("Key"); t.add_column("Value")
3939
+ t.add_row("CWD", str(shell.cwd))
3940
+ t.add_row("Stacks", ", ".join(scan["stacks"]))
3941
+ t.add_row("Deploy targets", ", ".join(scan["deploy_targets"]) or "None")
3942
+ t.add_row("Env vars needed", ", ".join(scan["required_env_vars"]) or "None")
3943
+ t.add_row("Active model", router.override_id or "auto")
3944
+ t.add_row("Running processes", str(len([p for p in proc_registry._procs.values() if p.is_alive])))
3945
+ t.add_row("Session", session.session_mgr.name)
3946
+ t.add_row("Total steps", str(session.total_steps))
3947
+ t.add_row("Token stats", tracker.summary())
3948
+ console.print(t)
3949
+ else:
3950
+ for k, v in scan.items():
3951
+ print(f" {k}: {v}")
3952
+ return True
3953
+
3954
+ if verb == "/config":
3955
+ if RICH:
3956
+ t = Table(title="⚙ Config", box=box.SIMPLE)
3957
+ t.add_column("Key"); t.add_column("Value")
3958
+ for k, v in CFG.items():
3959
+ t.add_row(k, str(v))
3960
+ console.print(t)
3961
+ else:
3962
+ for k, v in CFG.items():
3963
+ print(f" {k} = {v}")
3964
+ return True
3965
+
3966
+ if verb == "/history":
3967
+ hist = session.memory.get_history()
3968
+ for i, m in enumerate(hist[-10:], 1):
3969
+ role = m["role"].upper()
3970
+ snippet = m["content"][:100].replace("\n", " ")
3971
+ print(f" {i:2}. [{role}] {snippet}...")
3972
+ return True
3973
+
3974
+ if verb in ("/dashboard", "/ui"):
3975
+ sub = arg.lower().strip()
3976
+ if sub == "stop":
3977
+ print(f"{C.YEL}{dashboard.stop()}{C.RST}")
3978
+ elif sub == "url":
3979
+ print(f"Dashboard URL: {dashboard.url()}")
3980
+ else:
3981
+ url = dashboard.start(session=session, open_browser=True)
3982
+ print(f"{C.BGRN}✔ Web Dashboard running at: {url}{C.RST}")
3983
+ print(f"{C.GRY} Live process monitor, logs, and token stats opened in browser.{C.RST}")
3984
+ return True
3985
+
3986
+ if verb == "/deploy":
3987
+ if not arg or arg.lower() in ("help", "list"):
3988
+ print(deploy_engine.help_menu())
3989
+ else:
3990
+ parts = arg.split(None, 1)
3991
+ target_platform = parts[0]
3992
+ extra_cmd = parts[1] if len(parts) > 1 else ""
3993
+ res = deploy_engine.deploy(platform=target_platform, subcmd=extra_cmd, cwd=shell.cwd)
3994
+ col = C.GRN if res.success else C.RED
3995
+ print(f"\n{col}{res.output}{C.RST}")
3996
+ return True
3997
+
3998
+ if verb in ("/scripts", "/scratch"):
3999
+ scratch_dir = shell.cwd / ".ignite_scratch"
4000
+ scripts_dir = shell.cwd / "scripts"
4001
+ found = []
4002
+ for d in (scratch_dir, scripts_dir):
4003
+ if d.exists() and d.is_dir():
4004
+ for f in d.glob("*.*"):
4005
+ found.append((f.name, str(f.relative_to(shell.cwd)), f.stat().st_size))
4006
+ if not found:
4007
+ print(f"{C.YEL}No synthesized helper scripts found in .ignite_scratch/ or scripts/{C.RST}")
4008
+ print(f"{C.GRY}Ask IGNITE to perform any custom task and it will synthesize scripts autonomously.{C.RST}")
4009
+ else:
4010
+ print(f"{C.CYAN}📁 Synthesized Helper Scripts:{C.RST}")
4011
+ for name, rel, size in found:
4012
+ print(f" • {C.GRN}{rel:<30}{C.RST} ({size} bytes)")
4013
+ return True
4014
+
4015
+ if verb == "/export":
4016
+ path = session.session_mgr.save(session.memory)
4017
+ print(f"{C.GRN}✔ Session exported → {path}{C.RST}")
4018
+ return True
4019
+
4020
+ if verb == "/help":
4021
+ help_text = """
4022
+ ╔══════════════════════════════════════════════════════════════════════════╗
4023
+ ║ IGNITE V3 PRO — Command Reference ║
4024
+ ╠══════════════════════════════════════════════════════════════════════════╣
4025
+ ║ /plan [goal] Multi-Agent Swarm (Architect ➔ Coder ➔ Critic) ║
4026
+ ║ /swarm [on|off] Toggle Multi-Agent Swarm Mode ║
4027
+ ║ /index [rebuild] Scan & index codebase for instant BM25 + AST search ║
4028
+ ║ /search <query> Semantic hybrid search across functions & classes ║
4029
+ ║ /mcp [list|init] Manage external Model Context Protocol (MCP) tools ║
4030
+ ║ /undo, /rollback Revert codebase to pre-task safety checkpoint ║
4031
+ ║ /diff [file] Show colorized git diff of uncommitted edits ║
4032
+ ║ /verify [cmd] Run self-healing test runner / code checks ║
4033
+ ║ /commit [msg] Stage & commit all changes with message ║
4034
+ ║ /checkpoint Create a manual safety restore snapshot ║
4035
+ ║ /dashboard, /ui Open live Web UI in browser (localhost:7860) ║
4036
+ ║ /deploy [target] Deploy to Cloud (railway/docker/fly/render) ║
4037
+ ║ /scripts, /scratch List agent-synthesized helper scripts ║
4038
+ ║ /model [num] Interactive model selector (Arrow keys / 1,2) ║
4039
+ ║ /setup, /keys Interactive API key configuration wizard ║
4040
+ ║ /memory Show episode history & context stats ║
4041
+ ║ /clear Clear chat history (keep episodes) ║
4042
+ ║ /session save Save current session snapshot ║
4043
+ ║ /session list List saved sessions ║
4044
+ ║ /session load Load a saved session ║
4045
+ ║ /processes, /ps List running background / spawned windows ║
4046
+ ║ /kill <pid|name> Kill spawned process or window ║
4047
+ ║ /tools List all 18 available agent tools ║
4048
+ ║ /status Show project scan + running processes ║
4049
+ ║ /config Show current configuration ║
4050
+ ║ /history Show last 10 messages in memory ║
4051
+ ║ /export Export session to JSON ║
4052
+ ║ clear Reset session completely ║
4053
+ ║ exit / quit Exit IGNITE ║
4054
+ ╚══════════════════════════════════════════════════════════════════════════╝
4055
+ """
4056
+ print(f"{C.CYAN}{help_text}{C.RST}")
4057
+ return True
4058
+
4059
+ return False
4060
+
4061
+
4062
+ # ══════════════════════════════════════════════════════════════════════════════
4063
+ # SECTION 16 — MAIN ENTRY POINT
4064
+ # ══════════════════════════════════════════════════════════════════════════════
4065
+ def main():
4066
+ print(BANNER)
4067
+
4068
+ # Print active providers
4069
+ if RICH:
4070
+ t = Table(title="Active AI Providers", box=box.SIMPLE, show_header=True)
4071
+ t.add_column("Priority"); t.add_column("Name"); t.add_column("Model")
4072
+ for p in PROVIDERS:
4073
+ t.add_row(str(p.priority), p.name, p.model)
4074
+ console.print(t)
4075
+ else:
4076
+ print(f"{C.GRN}Active providers:{C.RST}")
4077
+ for p in PROVIDERS:
4078
+ print(f" [{p.priority}] {p.name} ({p.model})")
4079
+
4080
+ if not RICH:
4081
+ print(f"\n{C.DIM}💡 Tip: pip install rich → enables the full UI experience{C.RST}")
4082
+
4083
+ print(f"\n{C.CYAN}🌐 Web Dashboard: Type /dashboard to launch live UI on localhost:7860{C.RST}")
4084
+ print(f"{C.GRY}Type /help for commands | exit to quit{C.RST}\n")
4085
+
4086
+ session = IgniteSession()
4087
+ _DashboardHandler.session_ref = session
4088
+
4089
+ while True:
4090
+ try:
4091
+ cwd_label = shell.cwd.name or str(shell.cwd)
4092
+ model_label = router.override_id or "auto"
4093
+ prompt = f"\n{C.BYEL}IGNITE [{cwd_label}][{model_label}]> {C.RST}"
4094
+ user_input = input(prompt).strip()
4095
+ except (KeyboardInterrupt, EOFError):
4096
+ print(f"\n{C.GRY}Goodbye. Session: {session.session_mgr.name}{C.RST}")
4097
+ break
4098
+
4099
+ if not user_input:
4100
+ continue
4101
+ if user_input.lower() in ("exit", "quit", "q"):
4102
+ print(f"{C.GRY}Goodbye.{C.RST}")
4103
+ break
4104
+ if user_input.lower() == "clear":
4105
+ session = IgniteSession()
4106
+ _DashboardHandler.session_ref = session
4107
+ for p in PROVIDERS:
4108
+ p.exhausted = False
4109
+ p.quota_blocked = False
4110
+ print(f"{C.GRN}✔ Full session reset (all model blocks cleared).{C.RST}")
4111
+ continue
4112
+ if user_input.startswith("/"):
4113
+ handle_slash(user_input, session)
4114
+ continue
4115
+
4116
+ session.run(user_input)
4117
+
4118
+ if __name__ == "__main__":
4119
+ main()