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.
- package/.env.example +26 -0
- package/.ignite_mcp.example.json +22 -0
- package/LICENSE +21 -0
- package/README.md +303 -2
- package/bin/ignite.js +61 -0
- package/ignite_agent.py +4119 -0
- package/package.json +46 -4
- package/requirements.txt +3 -0
package/ignite_agent.py
ADDED
|
@@ -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 · Dashboard auto-refreshes every 3s · <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,'&').replace(/</g,'<').replace(/>/g,'>')
|
|
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,'<')}${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()
|