Daviddolor commited on
Commit
81c99eb
·
0 Parent(s):

Fix model selection and add tools passthrough for function-calling

Browse files
Files changed (6) hide show
  1. .gitignore +9 -0
  2. Dockerfile +14 -0
  3. gateway.py +1490 -0
  4. load_master_prompt.py +9 -0
  5. master_builder_prompt.txt +67 -0
  6. requirements.txt +7 -0
.gitignore ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ .env
2
+ *.pyc
3
+ __pycache__/
4
+ projects/
5
+ data/
6
+ *.log
7
+ memory.json
8
+ node_modules/
9
+ .next/
Dockerfile ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ RUN useradd -m -u 1000 user
4
+ WORKDIR /app
5
+
6
+ COPY --chown=user requirements.txt requirements.txt
7
+ RUN pip install --no-cache-dir --upgrade -r requirements.txt
8
+
9
+ COPY --chown=user . /app
10
+
11
+ ENV PORT=7860
12
+ USER user
13
+
14
+ CMD ["uvicorn", "gateway:app", "--host", "0.0.0.0", "--port", "7860"]
gateway.py ADDED
@@ -0,0 +1,1490 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # ============================================================
3
+ # DOLOR3V GATEWAY v6 – Claude Code features
4
+ # ============================================================
5
+ from dotenv import load_dotenv
6
+ load_dotenv('/opt/dolor3v/.env')
7
+ import asyncio
8
+ import os, sys, json, time, re, math, subprocess, threading, hashlib, itertools
9
+ import urllib.request, urllib.parse, urllib.error
10
+ from pathlib import Path
11
+ from collections import defaultdict
12
+ from datetime import datetime
13
+ from fastapi import FastAPI, Request, HTTPException
14
+ from fastapi.middleware.cors import CORSMiddleware
15
+ import uvicorn, requests
16
+ from bs4 import BeautifulSoup
17
+ from load_master_prompt import load_master_prompt
18
+ from colorthief import ColorThief
19
+
20
+ PORT = int(os.environ.get("PORT", 8080))
21
+ DOLOR3V_KEY = os.environ.get("DOLOR3V_KEY", "d3v-master-dolordprince-2026")
22
+ GROQ_KEYS = [k for k in [os.environ.get("GROQ_API_KEY",""), os.environ.get("GROQ_API_KEY_2",""), os.environ.get("GROQ_API_KEY_3","")] if k]
23
+ GROQ_KEY = GROQ_KEYS[0] if GROQ_KEYS else ""
24
+ _groq_idx = 0
25
+ OPENROUTER_KEY= os.environ.get("OPENROUTER_KEY","")
26
+ OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY","")
27
+ CEREBRAS_API_KEY = os.environ.get("CEREBRAS_API_KEY","")
28
+ ZAI_API_KEY = os.environ.get("ZAI_API_KEY","")
29
+ GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN", "")
30
+ print("GitHub token loaded: " + str(bool(GITHUB_TOKEN)))
31
+ GITHUB_USER = os.environ.get("GITHUB_USER", "dolordprince")
32
+ SURGE_TOKEN = os.environ.get("SURGE_TOKEN", "")
33
+ SURGE_EMAIL = os.environ.get("SURGE_EMAIL", "personaldolor@gmail.com")
34
+ HF_TOKEN = os.environ.get("HF_TOKEN", "")
35
+ if not HF_TOKEN:
36
+ try:
37
+ with open("/opt/dolor3v/.env") as f:
38
+ for line in f:
39
+ if line.startswith("HF_TOKEN="):
40
+ HF_TOKEN = line.strip().split("=",1)[1].strip('\"')
41
+ break
42
+ except:
43
+ pass
44
+ print("HF token loaded:", bool(HF_TOKEN))
45
+ if not HF_TOKEN:
46
+ try:
47
+ with open('/opt/dolor3v/.env') as f:
48
+ for line in f:
49
+ if line.startswith('HF_TOKEN='):
50
+ HF_TOKEN = line.strip().split('=',1)[1].strip('"').strip("'")
51
+ break
52
+ except:
53
+ pass
54
+ if not HF_TOKEN:
55
+ try:
56
+ with open('/opt/dolor3v/.env') as f:
57
+ for line in f:
58
+ if line.startswith('HF_TOKEN='):
59
+ HF_TOKEN = line.strip().split('=',1)[1].strip('"').strip("'")
60
+ break
61
+ except: pass
62
+ print("HF token loaded: " + str(bool(HF_TOKEN)))
63
+ HF_USER = os.environ.get("HF_USER", "Daviddolor")
64
+ OLLAMA_URL = "http://localhost:11434"
65
+ TABBY_URL = "http://localhost:9090"
66
+ EVENT_BUS_URL = "http://localhost:9091"
67
+ PROJECTS_DIR = "/opt/dolor3v/projects"
68
+ MEMORY_DB = "/opt/dolor3v/memory.json"
69
+ LOG_FILE = "/tmp/dolor3v/mcp.log"
70
+ SAFE_MODE = os.environ.get("DOLOR3V_SAFE_MODE", "true").lower() not in ("0","false","no")
71
+ MAX_CONTEXT_CHARS = 4000 # smaller limit to avoid 413 from Groq
72
+
73
+ os.makedirs(PROJECTS_DIR, exist_ok=True)
74
+ os.makedirs("/tmp/dolor3v", exist_ok=True)
75
+ os.makedirs("/opt/dolor3v/data", exist_ok=True)
76
+
77
+ def log(msg):
78
+ ts = datetime.now().strftime("%H:%M:%S")
79
+ line = f"[{ts}] {msg}"
80
+ print(line, flush=True)
81
+ try:
82
+ with open(LOG_FILE,"a") as f: f.write(line+"\n")
83
+ except: pass
84
+
85
+ app = FastAPI(title="DOLOR3V MCP Gateway v6.0")
86
+ app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
87
+
88
+ class Memory:
89
+ _lock = threading.Lock()
90
+ @staticmethod
91
+ def _load():
92
+ try:
93
+ with open(MEMORY_DB) as f: return json.load(f)
94
+ except: return {"kv":{}, "docs":[]}
95
+ @staticmethod
96
+ def _save(data):
97
+ with open(MEMORY_DB,"w") as f: json.dump(data, f, indent=2)
98
+ @staticmethod
99
+ def set(key, value):
100
+ with Memory._lock:
101
+ d = Memory._load()
102
+ d["kv"][key] = {"value": value, "ts": time.time()}
103
+ Memory._save(d)
104
+ @staticmethod
105
+ def get(key):
106
+ d = Memory._load()
107
+ entry = d["kv"].get(key)
108
+ return entry["value"] if entry else None
109
+ @staticmethod
110
+ def ingest(text, source="user"):
111
+ with Memory._lock:
112
+ d = Memory._load()
113
+ d.setdefault("docs", [])
114
+ d["docs"].append({"id": hashlib.md5(text.encode()).hexdigest()[:8],
115
+ "text": text[:2000], "source": source, "ts": time.time()})
116
+ if len(d["docs"]) > 200: d["docs"] = d["docs"][-200:]
117
+ Memory._save(d)
118
+ @staticmethod
119
+ def search(query, top_k=3):
120
+ d = Memory._load()
121
+ docs = d.get("docs", [])
122
+ if not docs: return []
123
+ def tokenize(t): return re.findall(r'\w+', t.lower())
124
+ q_tokens = set(tokenize(query))
125
+ N = len(docs)
126
+ df = defaultdict(int)
127
+ for doc in docs:
128
+ for tok in set(tokenize(doc["text"])):
129
+ df[tok] += 1
130
+ scores = []
131
+ for doc in docs:
132
+ tokens = tokenize(doc["text"])
133
+ tf = defaultdict(int)
134
+ for t in tokens: tf[t] += 1
135
+ score = 0.0
136
+ for tok in q_tokens:
137
+ if tok in tf:
138
+ tfidf = (tf[tok]/len(tokens)) * math.log((N+1)/(df[tok]+1)+1)
139
+ score += tfidf
140
+ scores.append((score, doc))
141
+ scores.sort(key=lambda x: x[0], reverse=True)
142
+ return [d for _, d in scores[:top_k] if _ > 0]
143
+ @staticmethod
144
+ def all_kv():
145
+ return Memory._load().get("kv", {})
146
+
147
+ class Firewall:
148
+ UA = "Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36"
149
+ @staticmethod
150
+ def search(query, max_results=5):
151
+ try:
152
+ q = urllib.parse.quote_plus(query)
153
+ url = f"https://html.duckduckgo.com/html/?q={q}"
154
+ req = urllib.request.Request(url, headers={"User-Agent": Firewall.UA})
155
+ with urllib.request.urlopen(req, timeout=12) as r:
156
+ html = r.read().decode("utf-8", errors="ignore")
157
+ snippets = re.findall(r'class="result__snippet"[^>]*>(.*?)</a>', html, re.DOTALL)[:max_results]
158
+ titles = re.findall(r'class="result__a"[^>]*>(.*?)</a>', html, re.DOTALL)[:max_results]
159
+ urls = re.findall(r'class="result__url"[^>]*>(.*?)</span>', html, re.DOTALL)[:max_results]
160
+ results = []
161
+ for i in range(len(snippets)):
162
+ results.append({"title": re.sub(r"<[^>]+>","", titles[i] if i<len(titles) else ""),
163
+ "snippet": re.sub(r"<[^>]+>","", snippets[i]),
164
+ "url": urls[i].strip() if i<len(urls) else ""})
165
+ for r in results:
166
+ Memory.ingest(f"{r['title']}: {r['snippet']}", "web_search")
167
+ return results
168
+ except Exception as e:
169
+ log(f"Search error: {e}")
170
+ return []
171
+ @staticmethod
172
+ def fetch(url):
173
+ try:
174
+ req = urllib.request.Request(url, headers={"User-Agent": Firewall.UA})
175
+ with urllib.request.urlopen(req, timeout=15) as r:
176
+ html = r.read().decode("utf-8", errors="ignore")
177
+ text = re.sub(r"<style[^>]*>.*?</style>","", html, flags=re.DOTALL)
178
+ text = re.sub(r"<script[^>]*>.*?</script>","", text, flags=re.DOTALL)
179
+ text = re.sub(r"<[^>]+>"," ", text)
180
+ text = re.sub(r"\s+"," ", text).strip()
181
+ Memory.ingest(text[:1000], f"fetch:{url}")
182
+ return text[:5000]
183
+ except Exception as e:
184
+ return f"Fetch error: {e}"
185
+ @staticmethod
186
+ def gather_context(prompt):
187
+ keywords = re.sub(r'\b(make|build|create|a|an|the|for|with|and|to)\b','', prompt.lower())[:80]
188
+ results = Firewall.search(f"{keywords} production best practices 2025")
189
+ ctx = f"[FIREWALL: {keywords}]\n"
190
+ for r in results[:3]:
191
+ ctx += f"- {r['title']}: {r['snippet']}\n"
192
+ return ctx
193
+ def _http_post(url, data, headers=None, timeout=120):
194
+ body = json.dumps(data).encode()
195
+ hdrs = {"Content-Type":"application/json"}
196
+ if headers: hdrs.update(headers)
197
+ req = urllib.request.Request(url, data=body, headers=hdrs)
198
+ with urllib.request.urlopen(req, timeout=timeout) as r:
199
+ return json.loads(r.read())
200
+
201
+ def llm_ollama(messages, model="dolor3v_coder:turbo"):
202
+ result = _http_post(f"{OLLAMA_URL}/api/chat",
203
+ {"model": model, "messages": messages, "stream": False,
204
+ "options": {"num_ctx":1024,"temperature":0.2}})
205
+ return result["message"]["content"]
206
+
207
+ def llm_groq(messages, model="llama-3.3-70b-versatile", tools=None, tool_choice=None):
208
+ global _groq_idx
209
+ last_err = None
210
+ tries = len(GROQ_KEYS) or 1
211
+ backoff = 4 # start with 4 seconds for 429
212
+ for attempt in range(tries):
213
+ key = GROQ_KEYS[_groq_idx % len(GROQ_KEYS)] if GROQ_KEYS else ""
214
+ _groq_idx += 1
215
+ try:
216
+ payload = {"model": model, "messages": messages, "temperature": 0.2, "max_tokens": 4096}
217
+ if tools:
218
+ payload["tools"] = tools
219
+ if tool_choice:
220
+ payload["tool_choice"] = tool_choice
221
+ result = _http_post("https://api.groq.com/openai/v1/chat/completions",
222
+ payload,
223
+ {"Authorization": f"Bearer {key}", "User-Agent": "Mozilla/5.0 (compatible; dolor3v/1.0)"})
224
+ return result["choices"][0]["message"]
225
+ except urllib.error.HTTPError as e:
226
+ if e.code == 429:
227
+ log(f"Groq rate‑limited, sleeping {backoff}s")
228
+ time.sleep(backoff)
229
+ backoff = min(backoff * 2, 30)
230
+ last_err = e
231
+ # stay on the same key, just wait
232
+ else:
233
+ last_err = e
234
+ log(f"Groq key failed, rotating: {e}")
235
+ continue
236
+ except Exception as e:
237
+ last_err = e
238
+ log(f"Groq key failed, rotating: {e}")
239
+ continue
240
+ raise last_err if last_err else Exception("No Groq keys configured")
241
+
242
+ def llm_openrouter(messages, model="nvidia/nemotron-super-49b-v1:free", tools=None, tool_choice=None):
243
+ payload = {"model": model, "messages": messages, "temperature": 0.2, "max_tokens": 4096}
244
+ if tools:
245
+ payload["tools"] = tools
246
+ if tool_choice:
247
+ payload["tool_choice"] = tool_choice
248
+ result = _http_post("https://openrouter.ai/api/v1/chat/completions",
249
+ payload,
250
+ {"Authorization": f"Bearer {OPENROUTER_KEY}", "HTTP-Referer": "https://dolor3v.com"})
251
+ return result["choices"][0]["message"]
252
+
253
+ def llm_openai(messages, model="gpt-4o-mini"):
254
+ result = _http_post("https://api.openai.com/v1/chat/completions",
255
+ {"model": model, "messages": messages, "temperature": 0.2, "max_tokens": 4096},
256
+ {"Authorization": f"Bearer {OPENAI_API_KEY}"})
257
+ return result["choices"][0]["message"]["content"]
258
+
259
+ def llm_cerebras(messages, model="gpt-oss-120b"):
260
+ result = _http_post("https://api.cerebras.ai/v1/chat/completions",
261
+ {"model": model, "messages": messages, "temperature": 0.2, "max_tokens": 4096},
262
+ {"Authorization": f"Bearer {CEREBRAS_API_KEY}", "User-Agent": "Mozilla/5.0 (compatible; dolor3v/1.0)"})
263
+ msg = result["choices"][0]["message"]
264
+ content = msg.get("content") or msg.get("reasoning") or ""
265
+ if not content:
266
+ raise Exception("Cerebras returned no usable content")
267
+ return content
268
+
269
+ def llm_glm(messages, model="glm-4.7-flash"):
270
+ result = _http_post("https://api.z.ai/api/paas/v4/chat/completions",
271
+ {"model": model, "messages": messages, "temperature": 0.2, "max_tokens": 4096},
272
+ {"Authorization": f"Bearer {ZAI_API_KEY}", "User-Agent": "Mozilla/5.0 (compatible; dolor3v/1.0)"})
273
+ msg = result["choices"][0]["message"]
274
+ content = msg.get("content") or ""
275
+ if not content:
276
+ raise Exception("GLM returned no usable content")
277
+ return content
278
+
279
+ def llm_route(messages, model=None, tools=None, tool_choice=None):
280
+ query = " ".join(m.get("content","") for m in messages if m.get("role")=="user")[-200:]
281
+ try:
282
+ mem_docs = Memory.search(query)
283
+ except Exception as e:
284
+ log(f"Memory.search failed, continuing without context: {e}")
285
+ mem_docs = None
286
+ if mem_docs:
287
+ context_text = "\n".join([d["text"][:200] for d in mem_docs])
288
+ messages = [{"role":"system","content":context_text}] + messages
289
+
290
+ if tools:
291
+ providers = [
292
+ ("groq", lambda: llm_groq(messages, model=model or "llama-3.3-70b-versatile", tools=tools, tool_choice=tool_choice)),
293
+ ("openrouter", lambda: llm_openrouter(messages, model=model or "nvidia/nemotron-super-49b-v1:free", tools=tools, tool_choice=tool_choice)),
294
+ ]
295
+ else:
296
+ providers = [
297
+ ("ollama", lambda: {"role":"assistant","content": llm_ollama(messages, model=model or "dolor3v_coder:turbo")}),
298
+ ("groq", lambda: llm_groq(messages, model=model or "llama-3.3-70b-versatile")),
299
+ ("cerebras", lambda: {"role":"assistant","content": llm_cerebras(messages, model=model or "gpt-oss-120b")}),
300
+ ("glm", lambda: {"role":"assistant","content": llm_glm(messages, model=model or "glm-4.7-flash")}),
301
+ ("openrouter", lambda: llm_openrouter(messages, model=model or "nvidia/nemotron-super-49b-v1:free")),
302
+ ("openai", lambda: {"role":"assistant","content": llm_openai(messages, model=model or "gpt-4o-mini")}),
303
+ ]
304
+
305
+ for name, fn in providers:
306
+ try:
307
+ log(f"LLM → {name}")
308
+ message = fn()
309
+ content_preview = message.get("content") or ""
310
+ try:
311
+ Memory.ingest(f"Q:{query[:100]} A:{content_preview[:200]}", "llm")
312
+ except Exception as e:
313
+ log(f"Memory.ingest failed, ignoring: {e}")
314
+ emit_event("custom", {"provider":name,"tokens":len(content_preview)}, "mcp-llm")
315
+ return message, name
316
+ except Exception as e:
317
+ log(f"LLM {name} failed: {e}")
318
+ continue
319
+ return {"role":"assistant","content":"All LLM providers failed"}, "none"
320
+
321
+ def llm_route_quality(messages, model=None):
322
+ query = " ".join(m.get("content","") for m in messages if m.get("role")=="user")[-200:]
323
+ mem_docs = Memory.search(query)
324
+ if mem_docs:
325
+ context_text = "\n".join([d["text"][:200] for d in mem_docs])
326
+ messages = [{"role":"system","content":context_text}] + messages
327
+ providers = [
328
+ ("cerebras", lambda: llm_cerebras(messages)),
329
+ ("groq", lambda: llm_groq(messages)),
330
+ ("glm", lambda: llm_glm(messages)),
331
+ ("openrouter", lambda: llm_openrouter(messages)),
332
+ ("openai", lambda: llm_openai(messages)),
333
+ ("ollama", lambda: llm_ollama(messages))
334
+ ]
335
+ for name, fn in providers:
336
+ try:
337
+ log(f"LLM(quality) \u2192 {name}")
338
+ result = fn()
339
+ Memory.ingest(f"Q:{query[:100]} A:{result[:200]}", "llm")
340
+ emit_event("custom", {"provider":name,"tokens":len(result)}, "mcp-llm")
341
+ return result, name
342
+ except Exception as e:
343
+ log(f"LLM(quality) {name} failed: {e}")
344
+ continue
345
+ return "All LLM providers failed", "none"
346
+
347
+ def emit_event(etype, payload, source="mcp"):
348
+ try:
349
+ _http_post(f"{EVENT_BUS_URL}/emit",
350
+ {"type":etype,"payload":payload,"source":source}, timeout=3)
351
+ except: pass
352
+
353
+ class GitHub:
354
+ @staticmethod
355
+ def create_repo(name, desc="DOLOR3V project"):
356
+ data = json.dumps({"name": name, "description": desc, "private": False, "auto_init": True}).encode()
357
+ req = urllib.request.Request("https://api.github.com/user/repos", data=data,
358
+ headers={"Authorization": f"token {GITHUB_TOKEN}",
359
+ "Accept": "application/vnd.github.v3+json",
360
+ "Content-Type": "application/json",
361
+ "User-Agent": "DOLOR3V-MCP"})
362
+ with urllib.request.urlopen(req, timeout=15) as r:
363
+ result = json.loads(r.read())
364
+ return result.get("clone_url",""), result.get("html_url","")
365
+ @staticmethod
366
+ def push(project_path, repo_name, msg="DOLOR3V build"):
367
+ if not GITHUB_TOKEN: return "❌ GITHUB_TOKEN not set"
368
+ try:
369
+ clone_url, html_url = GitHub.create_repo(repo_name)
370
+ if not clone_url: return "❌ Could not create repo"
371
+ auth_url = clone_url.replace("https://", f"https://{GITHUB_TOKEN}@")
372
+ cmds = f"cd {project_path} && git init -q && git config user.email '{SURGE_EMAIL}' && git config user.name '{GITHUB_USER}' && git add -A && git commit -q -m '{msg}' && git branch -M main && git remote add origin {auth_url} 2>/dev/null || git remote set-url origin {auth_url} && git push -u origin main --force -q"
373
+ r = subprocess.run(cmds, shell=True, capture_output=True, text=True, timeout=60)
374
+ if r.returncode == 0:
375
+ emit_event("deploy", {"github":html_url,"path":project_path}, "mcp-github")
376
+ return f"✅ {html_url}"
377
+ return f"❌ {r.stderr[:200]}"
378
+ except Exception as e:
379
+ return f"❌ GitHub error: {e}"
380
+ @staticmethod
381
+ def push_source():
382
+ return GitHub.push("/opt/dolor3v", "dolor3v-engine", "DOLOR3V: engine update")
383
+
384
+ class Surge:
385
+ @staticmethod
386
+ def deploy(project_path, subdomain=None):
387
+ if not subdomain:
388
+ slug = re.sub(r'[^a-z0-9-]','-', Path(project_path).name.lower())
389
+ subdomain = f"{slug}-{int(time.time())}"
390
+ domain = f"{subdomain}.surge.sh"
391
+ env = os.environ.copy()
392
+ if SURGE_TOKEN: env["SURGE_TOKEN"] = SURGE_TOKEN
393
+ try:
394
+ r = subprocess.run(["surge", project_path, domain], capture_output=True, text=True, env=env, timeout=120)
395
+ if r.returncode == 0 or "Success" in r.stdout:
396
+ url = f"https://{domain}"
397
+ emit_event("deploy", {"url":url}, "mcp-surge")
398
+ return url, None
399
+ return None, r.stderr[:300] or r.stdout[:300]
400
+ except FileNotFoundError:
401
+ return None, "surge not installed"
402
+ except Exception as e:
403
+ return None, str(e)
404
+
405
+ def tabby_complete(prefix, suffix="", lang="python"):
406
+ try:
407
+ result = _http_post(f"{TABBY_URL}/v1beta/completions",
408
+ {"language": lang, "segments": {"prefix":prefix,"suffix":suffix}}, timeout=60)
409
+ return result.get("choices",[{}])[0].get("text","")
410
+ except Exception as e:
411
+ return f"Tabby error: {e}"
412
+ TOOL_LIST = [
413
+ "write_file","read_file","list_files","delete_file",
414
+ "shell_exec","web_search","web_fetch","firewall_context",
415
+ "memory_save","memory_recall","memory_search","memory_ingest",
416
+ "github_push","github_push_source","git_commit",
417
+ "surge_deploy","tabby_complete","hf_deploy",
418
+ "watchdog_ping","code_parse","clone_website",
419
+ "project_index","grep_code","spawn_agent","qa_review","build_project"
420
+ ]
421
+
422
+ DANGEROUS_PATTERNS = [
423
+ r'\brm\s+-rf\b', r'\brm\s+-r\b', r'\brm\s+.*-f\b',
424
+ r'>\s*/dev/sd', r'\bmkfs\.', r'\bdd\s+if=',
425
+ r'git\s+push\s+--force', r'git\s+push\s+-f',
426
+ r'\bchmod\s+777', r'\bchown\s+-R',
427
+ r':(){ :|:& };:'
428
+ ]
429
+
430
+ def is_dangerous(cmd):
431
+ return any(re.search(pat, cmd) for pat in DANGEROUS_PATTERNS)
432
+
433
+ def retry_tool(tool_func, *args, max_retries=2):
434
+ for attempt in range(max_retries + 1):
435
+ try:
436
+ return tool_func(*args)
437
+ except Exception as e:
438
+ if attempt == max_retries:
439
+ raise
440
+ time.sleep(1)
441
+ log(f"Retry {attempt+1}/{max_retries} for tool: {e}")
442
+
443
+ def project_index(path="."):
444
+ path = os.path.expanduser(path)
445
+ summary = []
446
+ summary.append(f"Project root: {os.path.abspath(path)}")
447
+ try:
448
+ r = subprocess.run(["find", path, "-maxdepth", "2", "-not", "-path", "*/node_modules/*", "-not", "-path", "*/.git/*"],
449
+ capture_output=True, text=True, timeout=10)
450
+ tree = r.stdout.strip()
451
+ summary.append(f"File tree (depth 2):\n{tree[:2000]}")
452
+ except:
453
+ pass
454
+
455
+ # Collect up to 15 important source files and include their first 50 lines
456
+ source_files = []
457
+ for root, dirs, files in os.walk(path):
458
+ dirs[:] = [d for d in dirs if d not in ('.git', 'node_modules', '__pycache__')]
459
+ for f in files:
460
+ if f.endswith(('.py', '.js', '.ts', '.sh', '.yaml', '.yml', '.json', '.toml')):
461
+ source_files.append(os.path.join(root, f))
462
+ if len(source_files) >= 20:
463
+ break
464
+ for fpath in sorted(source_files)[:15]:
465
+ try:
466
+ with open(fpath) as f:
467
+ flines = f.readlines()[:50]
468
+ header = ''.join(flines)
469
+ rel = os.path.relpath(fpath, path)
470
+ summary.append(f"\n--- {rel} (first 50 lines) ---\n{header}")
471
+ except:
472
+ pass
473
+
474
+ manifests = {
475
+ "package.json": None,
476
+ "requirements.txt": None,
477
+ "Pipfile": None,
478
+ "pyproject.toml": None,
479
+ "go.mod": None,
480
+ "Cargo.toml": None,
481
+ "Makefile": None,
482
+ "README.md": None,
483
+ ".env.example": None,
484
+ "docker-compose.yml": None,
485
+ }
486
+ for fname in manifests:
487
+ fpath = os.path.join(path, fname)
488
+ if os.path.isfile(fpath):
489
+ try:
490
+ with open(fpath) as f:
491
+ mcontent = f.read(2000)
492
+ manifests[fname] = mcontent
493
+ except:
494
+ pass
495
+
496
+ for name, mcontent in manifests.items():
497
+ if mcontent:
498
+ summary.append(f"\n--- {name} ---\n{mcontent}")
499
+
500
+ try:
501
+ r = subprocess.run(["git", "-C", path, "status", "--short"], capture_output=True, text=True, timeout=5)
502
+ git_stat = r.stdout.strip()
503
+ if git_stat:
504
+ summary.append(f"\n--- git status ---\n{git_stat}")
505
+ except:
506
+ pass
507
+
508
+ return "\n".join(summary)
509
+
510
+ def grep_code(directory, pattern, file_filter="*"):
511
+ cmd = f"grep -rn --include='{file_filter}' '{pattern}' {directory}"
512
+ r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=15)
513
+ return r.stdout.strip() or "No matches found."
514
+
515
+ def spawn_agent(prompt, max_steps=3):
516
+ # AGENT_SYSTEM_PROMPT will be defined later; we'll handle that after function definition
517
+ msgs = [{"role":"system","content":AGENT_SYSTEM_PROMPT},
518
+ {"role":"user","content":prompt}]
519
+ for _ in range(max_steps):
520
+ try:
521
+ reply, _ = llm_route(msgs)
522
+ except:
523
+ return "Sub-agent failed: no LLM response."
524
+ line = reply.strip()
525
+ if line.startswith("{") and '"tool"' in line:
526
+ try:
527
+ tc = json.loads(line)
528
+ tool_res = execute_tool(tc["tool"], tc.get("args",{}))
529
+ msgs.append({"role":"assistant","content":line})
530
+ msgs.append({"role":"user","content":f"Tool result:\n{tool_res}"})
531
+ continue
532
+ except:
533
+ return reply
534
+ return reply
535
+ return "Sub-agent reached max steps."
536
+
537
+ # AGENT_SYSTEM_PROMPT placeholder – real definition will be in part 5
538
+ AGENT_SYSTEM_PROMPT = ""
539
+
540
+ def execute_tool(tool, args):
541
+ log(f"Tool: {tool} args:{str(args)[:80]}")
542
+ # Safety checks
543
+ if SAFE_MODE and tool == "shell_exec":
544
+ cmd = args.get("command","")
545
+ if is_dangerous(cmd):
546
+ return "🚫 SAFE MODE: Dangerous command blocked. Set DOLOR3V_SAFE_MODE=false to disable."
547
+ if SAFE_MODE and tool == "delete_file":
548
+ path = args.get("path","")
549
+ if any(re.search(pat, path) for pat in [r'\/$', r'^\/(etc|boot|bin|sbin|lib|sys|dev|proc)']):
550
+ return "🚫 SAFE MODE: Deletion of system path blocked."
551
+
552
+ # ----- standard tools -----
553
+ if tool == "write_file":
554
+ filename = os.path.basename(args.get("path",""))
555
+ file_path_arg = args.get("path", args.get("file_path",""))
556
+
557
+ # Block dangerous system paths
558
+ BLOCKED_PATHS = ["/etc/","/boot/","/bin/","/sbin/","/lib/","/sys/","/dev/","/proc/"]
559
+ if any(file_path_arg.startswith(bp) for bp in BLOCKED_PATHS):
560
+ return {"error": "BLOCKED", "message": f"System path blocked: {file_path_arg}"}
561
+
562
+ # Allow all legitimate web/app file extensions
563
+ ALLOWED_EXTENSIONS = {
564
+ ".html",".css",".js",".ts",".tsx",".jsx",".json",".svg",
565
+ ".md",".mdx",".txt",".env",".yaml",".yml",".toml",".lock",
566
+ ".png",".jpg",".jpeg",".gif",".webp",".ico",".woff",".woff2"
567
+ }
568
+ ext = os.path.splitext(filename)[1].lower()
569
+ if ext and ext not in ALLOWED_EXTENSIONS:
570
+ return {"error": "BLOCKED", "message": f"File extension not allowed: {ext}", "blocked_file": filename}
571
+
572
+ path = os.path.expanduser(args.get("path", args.get("file_path","")))
573
+ content = args.get("content","")
574
+ os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
575
+ with open(path,"w") as f: f.write(content)
576
+ return f"✅ Written: {path} ({len(content)} bytes)"
577
+ elif tool == "read_file":
578
+ path = os.path.expanduser(args.get("path", args.get("file_path","")))
579
+ with open(path) as f: return f.read()[:2000][:3000]
580
+ elif tool == "list_files":
581
+ path = os.path.expanduser(args.get("path","."))
582
+ items = list(Path(path).iterdir())
583
+ return "\n".join(f"{'D' if i.is_dir() else 'F'} {i.name}" for i in sorted(items))
584
+ elif tool == "delete_file":
585
+ path = os.path.expanduser(args.get("path", args.get("file_path","")))
586
+ os.remove(path)
587
+ return f"✅ Deleted: {path}"
588
+ elif tool == "shell_exec":
589
+ cmd = args.get("command","")
590
+ r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=120)
591
+ out = (r.stdout + r.stderr).strip()
592
+ return out[:3000] or "✅ Done (no output)"
593
+ elif tool == "web_search":
594
+ results = Firewall.search(args.get("query",""), args.get("max_results",5))
595
+ return json.dumps(results, indent=2)
596
+ elif tool == "web_fetch":
597
+ return Firewall.fetch(args.get("url",""))
598
+ elif tool == "firewall_context":
599
+ return Firewall.gather_context(args.get("prompt",""))
600
+ elif tool == "memory_save":
601
+ Memory.set(args["key"], args["value"])
602
+ return f"✅ Saved: {args['key']}"
603
+ elif tool == "memory_recall":
604
+ val = Memory.get(args["key"])
605
+ return val if val else f"No memory for: {args['key']}"
606
+ elif tool == "memory_search":
607
+ results = Memory.search(args.get("query",""))
608
+ return json.dumps([r["text"][:200] for r in results], indent=2)
609
+ elif tool == "memory_ingest":
610
+ Memory.ingest(args.get("text",""), args.get("source","user"))
611
+ return "✅ Ingested into RAG"
612
+ elif tool == "github_push":
613
+ return GitHub.push(args.get("project_path",""),
614
+ args.get("repo_name", Path(args.get("project_path","x")).name),
615
+ args.get("message","DOLOR3V build"))
616
+ elif tool == "github_push_source":
617
+ return GitHub.push_source()
618
+ elif tool == "git_commit":
619
+ path = args.get("path",".")
620
+ msg = args.get("message","update")
621
+ r = subprocess.run(f"cd {path} && git add -A && git commit -m '{msg}'",
622
+ shell=True, capture_output=True, text=True)
623
+ return r.stdout + r.stderr
624
+ elif tool == "surge_deploy":
625
+ url, err = Surge.deploy(args.get("project_path",""), args.get("subdomain"))
626
+ return f"✅ Live: {url}" if url else f"❌ {err}"
627
+ elif tool == "tabby_complete":
628
+ return tabby_complete(args.get("prefix",""), args.get("suffix",""), args.get("lang","python"))
629
+ elif tool == "hf_deploy":
630
+ space = args.get("space", f"{HF_USER}/dolor3v")
631
+ src = args.get("src_path", "/opt/dolor3v")
632
+ if not HF_TOKEN: return "❌ HF_TOKEN not set"
633
+ hf_url = f"https://user:{HF_TOKEN}@huggingface.co/spaces/{space}"
634
+ r = subprocess.run(f"cd {src} && git init && git add -A && git commit -m 'DOLOR3V deploy' && git remote add hf {hf_url} 2>/dev/null || git remote set-url hf {hf_url} && git push hf main --force",
635
+ shell=True, capture_output=True, text=True, timeout=120)
636
+ return f"✅ Deployed to https://{space.replace('/','--')}.hf.space" if r.returncode==0 else f"❌ {r.stderr[:200]}"
637
+ elif tool == "watchdog_ping":
638
+ service = args.get("service","unknown")
639
+ status = args.get("status","ok")
640
+ emit_event("watchdog", {"service":service,"status":status}, "mcp-watchdog")
641
+ return f"✅ Watchdog: {service}={status}"
642
+ elif tool == "code_parse":
643
+ path = os.path.expanduser(args.get("path", args.get("file_path","")))
644
+ with open(path) as f: content = f.read()
645
+ lines = content.split("\n")
646
+ funcs = [l.strip() for l in lines if l.strip().startswith(("def ","class ","function ","const ","async "))]
647
+ return json.dumps({"lines": len(lines), "size": len(content), "symbols": funcs[:20]}, indent=2)
648
+ elif tool == "clone_website":
649
+ url = args.get("url")
650
+ proj_dir = args.get("project_dir")
651
+ if not url: return "❌ missing url"
652
+ try:
653
+ resp = requests.get(url, headers={"User-Agent": Firewall.UA}, timeout=15)
654
+ soup = BeautifulSoup(resp.text, "html.parser")
655
+ title = soup.title.string.strip() if soup.title else url
656
+ desc = soup.find("meta", attrs={"name": "description"})
657
+ description = desc.get("content","") if desc else ""
658
+ og_image = soup.find("meta", property="og:image")
659
+ hero_url = urllib.parse.urljoin(url, og_image["content"]) if og_image else None
660
+ colors = ["#3B82F6", "#1E3A8A", "#F59E0B"]
661
+ if hero_url:
662
+ img_data = requests.get(hero_url, timeout=10).content
663
+ import tempfile
664
+ with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp:
665
+ tmp.write(img_data)
666
+ tmp_path = tmp.name
667
+ ct = ColorThief(tmp_path)
668
+ palette = ct.get_palette(color_count=3)
669
+ colors = [f"rgb({r},{g},{b})" for (r,g,b) in palette]
670
+ os.unlink(tmp_path)
671
+ html = f"""<!DOCTYPE html>
672
+ <html><head><meta charset="UTF-8"><title>{title}</title>
673
+ <style>body{{font-family:system-ui;margin:0;background:{colors[0]};color:white;text-align:center;}}
674
+ .hero{{height:100vh;display:flex;flex-direction:column;justify-content:center;}}
675
+ button{{background:white;color:{colors[0]};border:none;padding:12px 24px;border-radius:30px;}}</style>
676
+ </head><body><div class="hero"><h1>{title}</h1><p>{description[:200]}</p><button>Explore</button></div></body></html>"""
677
+ if proj_dir:
678
+ os.makedirs(proj_dir, exist_ok=True)
679
+ with open(os.path.join(proj_dir, "index.html"), "w") as f:
680
+ f.write(html)
681
+ if hero_url:
682
+ img_data = requests.get(hero_url, timeout=10).content
683
+ with open(os.path.join(proj_dir, "hero.jpg"), "wb") as f:
684
+ f.write(img_data)
685
+ return {"status": "done", "path": proj_dir}
686
+ return {"html": html}
687
+ except Exception as e:
688
+ return {"error": str(e)}
689
+ # ----- new Claude Code tools -----
690
+ elif tool == "project_index":
691
+ return project_index(args.get("path","."))
692
+ elif tool == "grep_code":
693
+ directory = args.get("directory",".")
694
+ pattern = args.get("pattern","")
695
+ file_filter = args.get("file_filter","*")
696
+ if not pattern: return "❌ missing pattern"
697
+ return grep_code(directory, pattern, file_filter)
698
+ elif tool == "spawn_agent":
699
+ prompt = args.get("prompt","")
700
+ if not prompt: return "❌ missing prompt"
701
+ return spawn_agent(prompt, args.get("max_steps",3))
702
+ elif tool == "build_project":
703
+ build_dir = args.get("path", args.get("project_dir", ""))
704
+ command = args.get("command", "")
705
+ if not build_dir or not command:
706
+ return "❌ build_project requires path and command"
707
+ log_file = os.path.join(build_dir, "build.log")
708
+ os.makedirs(build_dir, exist_ok=True)
709
+ try:
710
+ with open(log_file, "w") as lf:
711
+ r = subprocess.run(
712
+ command, shell=True, cwd=build_dir,
713
+ stdout=lf, stderr=lf, timeout=600
714
+ )
715
+ with open(log_file) as lf:
716
+ output = lf.read()[-3000:]
717
+ status = "✅ Build succeeded" if r.returncode == 0 else f"❌ Build failed (exit {r.returncode})"
718
+ return f"{status}\n{output}"
719
+ except subprocess.TimeoutExpired:
720
+ return "❌ build_project timed out after 600s"
721
+ except Exception as e:
722
+ return f"❌ build_project error: {e}"
723
+ elif tool == "qa_review":
724
+ proj_dir_arg = args.get("path", args.get("project_dir", ""))
725
+ if not proj_dir_arg:
726
+ return "❌ missing path"
727
+ issues = []
728
+ html_path = os.path.join(proj_dir_arg, "index.html")
729
+ css_path = os.path.join(proj_dir_arg, "style.css")
730
+ if os.path.exists(html_path):
731
+ with open(html_path) as f:
732
+ html_content = f.read()
733
+ soup = BeautifulSoup(html_content, "html.parser")
734
+ if not soup.find("title") or not soup.title.string or not soup.title.string.strip():
735
+ issues.append("Missing or empty title tag")
736
+ if not soup.find("meta", attrs={"name": "description"}):
737
+ issues.append("Missing meta description tag")
738
+ if not soup.find("meta", attrs={"name": "viewport"}):
739
+ issues.append("Missing viewport meta tag")
740
+ imgs_missing_alt = [img.get("src","?") for img in soup.find_all("img") if not img.get("alt")]
741
+ if imgs_missing_alt:
742
+ issues.append(f"{len(imgs_missing_alt)} image(s) missing alt text")
743
+ if not soup.find("h1"):
744
+ issues.append("Missing an h1 heading")
745
+ else:
746
+ issues.append("index.html not found")
747
+ if os.path.exists(css_path):
748
+ with open(css_path) as f:
749
+ css_content = f.read()
750
+ if "@media" not in css_content:
751
+ issues.append("No media query found, page may not be responsive")
752
+ else:
753
+ issues.append("style.css not found")
754
+ if not issues:
755
+ return "QA review passed: no issues found"
756
+ return "QA review found issues: " + "; ".join(issues)
757
+ else:
758
+ return f"❌ Unknown tool: {tool}"
759
+
760
+ @app.post("/v1/build/nextjs")
761
+ async def build_nextjs(request: Request):
762
+ body = await request.json()
763
+ prompt = body.get("prompt", "")
764
+ if not prompt:
765
+ return {"ok": False, "error": "prompt required"}
766
+
767
+ slug = re.sub(r'[^a-z0-9-]', '-', prompt.lower())[:30]
768
+ ts = datetime.now().strftime("%m%d%H%M")
769
+ proj_name = f"dolor3v-{slug}-{ts}"
770
+ proj_dir = f"{PROJECTS_DIR}/{proj_name}"
771
+ os.makedirs(proj_dir, exist_ok=True)
772
+ log(f"NextJS build started: {proj_dir}")
773
+
774
+ # ── Phase 1: Research ──────────────────────────────────────────
775
+ research = {}
776
+
777
+ # 1a. Web search for design inspiration
778
+ search_results = Firewall.search(f"{prompt} website design inspiration color palette", 5)
779
+ research["search"] = search_results
780
+
781
+ # 1b. Fetch top result for real copy + colors
782
+ top_url = next((r.get("url") for r in search_results if r.get("url")), None)
783
+ fetched_text = ""
784
+ if top_url:
785
+ try:
786
+ fetched_text = Firewall.fetch(top_url)[:3000]
787
+ research["fetched_url"] = top_url
788
+ research["fetched_text"] = fetched_text
789
+ except Exception as e:
790
+ log(f"web_fetch failed: {e}")
791
+
792
+ # 1c. Extract color palette from a real image
793
+ palette = ["#1a1a2e", "#16213e", "#e94560"]
794
+ try:
795
+ img_keyword = re.sub(r'[^a-z0-9]', '-', prompt.lower())[:20]
796
+ img_url = f"https://picsum.photos/seed/{img_keyword}/800/600"
797
+ img_resp = requests.get(img_url, timeout=10)
798
+ if img_resp.status_code == 200:
799
+ import tempfile
800
+ with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp:
801
+ tmp.write(img_resp.content)
802
+ tmp_path = tmp.name
803
+ ct = ColorThief(tmp_path)
804
+ raw = ct.get_palette(color_count=3, quality=1)
805
+ palette = [f"#{r:02x}{g:02x}{b:02x}" for r,g,b in raw]
806
+ os.unlink(tmp_path)
807
+ research["palette"] = palette
808
+ research["hero_img"] = img_url
809
+ log(f"Extracted palette: {palette}")
810
+ except Exception as e:
811
+ log(f"Color extraction failed: {e}")
812
+ research["palette"] = palette
813
+
814
+ # 1d. Pick font pair from Google Fonts
815
+ font_pairs = [
816
+ ("Playfair Display", "Inter"),
817
+ ("DM Serif Display", "DM Sans"),
818
+ ("Cormorant Garamond", "Nunito Sans"),
819
+ ("Libre Baskerville", "Source Sans Pro"),
820
+ ("Fraunces", "Outfit"),
821
+ ]
822
+ import random
823
+ heading_font, body_font = random.choice(font_pairs)
824
+ research["heading_font"] = heading_font
825
+ research["body_font"] = body_font
826
+
827
+ log(f"Research complete: palette={palette}, fonts={heading_font}/{body_font}")
828
+
829
+ # ── Phase 2: Scaffold Next.js ──────────────────────────────────
830
+ scaffold_log = f"{proj_dir}/scaffold.log"
831
+ scaffold_cmd = (
832
+ f"cd {PROJECTS_DIR} && "
833
+ f"pnpm create next-app {proj_name} --typescript --tailwind --eslint "
834
+ f"--app --no-src-dir --no-git --yes 2>&1"
835
+ )
836
+ log("Scaffolding Next.js...")
837
+ scaffold_result = await asyncio.to_thread(
838
+ subprocess.run, scaffold_cmd, shell=True,
839
+ capture_output=True, text=True, timeout=600
840
+ )
841
+ with open(scaffold_log, "w") as f:
842
+ f.write(scaffold_result.stdout + scaffold_result.stderr)
843
+
844
+ if scaffold_result.returncode != 0:
845
+ return {"ok": False, "error": "Scaffold failed", "log": scaffold_result.stderr[-2000:]}
846
+
847
+ # Write next.config.ts for static export
848
+ next_config = (
849
+ "import type { NextConfig } from 'next';\n"
850
+ "const nextConfig: NextConfig = {\n"
851
+ " output: 'export',\n"
852
+ " images: { unoptimized: true }\n"
853
+ "};\n"
854
+ "export default nextConfig;\n"
855
+ )
856
+ with open(f"{proj_dir}/next.config.ts", "w") as f:
857
+ f.write(next_config)
858
+
859
+ # Install framer-motion
860
+ fm_result = await asyncio.to_thread(
861
+ subprocess.run, f"cd {proj_dir} && pnpm add framer-motion 2>&1",
862
+ shell=True, capture_output=True, text=True, timeout=300
863
+ )
864
+ log(f"framer-motion install: exit {fm_result.returncode}")
865
+
866
+ log("Scaffold complete, handing off to LLM for components...")
867
+ return {
868
+ "ok": True,
869
+ "phase": "scaffolded",
870
+ "proj_dir": proj_dir,
871
+ "research": research,
872
+ "message": "Scaffold complete. Call /v1/build/nextjs/complete to write components and build."
873
+ }
874
+
875
+
876
+
877
+ @app.post("/v1/build/nextjs/complete")
878
+ async def build_nextjs_complete(request: Request):
879
+ body = await request.json()
880
+ prompt = body.get("prompt", "")
881
+ proj_dir = body.get("proj_dir", "")
882
+ research = body.get("research", {})
883
+ if not prompt or not proj_dir:
884
+ return {"ok": False, "error": "prompt and proj_dir required"}
885
+
886
+ palette = research.get("palette", ["#1a1a2e","#16213e","#e94560"])
887
+ heading_font = research.get("heading_font", "Playfair Display")
888
+ body_font = research.get("body_font", "Inter")
889
+ hero_img = research.get("hero_img", "https://picsum.photos/seed/hero/1200/800")
890
+ fetched_text = research.get("fetched_text", "")[:1500]
891
+ search_snippets = " ".join([r.get("snippet","") for r in research.get("search",[])])[:1000]
892
+
893
+ context = f"""
894
+ RESEARCH RESULTS:
895
+ Design inspiration snippets: {search_snippets}
896
+ Fetched content: {fetched_text}
897
+ Color palette (use these exact hex values): {palette[0]} (primary), {palette[1]} (secondary), {palette[2]} (accent)
898
+ Heading font: {heading_font} (load from Google Fonts)
899
+ Body font: {body_font} (load from Google Fonts)
900
+ Hero image URL: {hero_img}
901
+ Project directory: {proj_dir}
902
+
903
+ TASK:
904
+ Write a complete, production-ready Next.js {prompt} website.
905
+ Use the exact colors, fonts, and image URLs from the research above.
906
+ Write ALL of these files using write_file:
907
+ 1. {proj_dir}/app/globals.css - Import Google Fonts, define CSS variables for the palette
908
+ 2. {proj_dir}/app/layout.tsx - Root layout with metadata, font imports
909
+ 3. {proj_dir}/app/page.tsx - Home: hero, features, testimonials (2-3 real quotes), FAQ (3-5 Q&As), CTA
910
+ 4. {proj_dir}/app/about/page.tsx - About: brand story, team
911
+ 5. {proj_dir}/app/services/page.tsx - Services: detailed cards
912
+ 6. {proj_dir}/app/contact/page.tsx - Contact: form, social links
913
+ 7. {proj_dir}/components/Navbar.tsx - Responsive nav with mobile menu
914
+ 8. {proj_dir}/components/Footer.tsx - Rich footer with links
915
+
916
+ Every image must use a real picsum.photos URL.
917
+ Use Framer Motion for scroll animations and hover effects.
918
+ Make every page fully responsive and accessible.
919
+ """
920
+
921
+ system = load_master_prompt() + f"""
922
+ You are writing Next.js TypeScript components only.
923
+ Use write_file for every file.
924
+ Do not scaffold, do not install packages, do not build — just write the component files.
925
+ {context}
926
+ After writing ALL files respond with exactly: ◆DONE
927
+ """
928
+ msgs = [{"role":"system","content":system},{"role":"user","content":prompt}]
929
+ results = []
930
+ response, provider = "", "groq"
931
+ MAX_STEPS = 20
932
+ parse_fail_count = 0
933
+
934
+ for _step in range(MAX_STEPS):
935
+ response, provider = await asyncio.to_thread(llm_route_quality, msgs)
936
+ step_results = []
937
+ decoder = json.JSONDecoder()
938
+ idx = 0
939
+ while idx < len(response):
940
+ brace_idx = response.find("{", idx)
941
+ if brace_idx == -1: break
942
+ try:
943
+ tc, end_idx = decoder.raw_decode(response, brace_idx)
944
+ except json.JSONDecodeError:
945
+ idx = brace_idx + 1
946
+ continue
947
+ idx = end_idx
948
+ if isinstance(tc, dict) and "tool" not in tc:
949
+ preceding = response[max(0,brace_idx-150):brace_idx].lower()
950
+ for tname in TOOL_LIST:
951
+ if tname.lower() in preceding:
952
+ tc = {"tool": tname, "args": tc}
953
+ break
954
+ if isinstance(tc, dict) and "tool" in tc:
955
+ args = tc.get("args", {})
956
+ if tc.get("tool") == "write_file":
957
+ if not args.get("path") and not args.get("file_path"):
958
+ args["path"] = os.path.join(proj_dir, "app/page.tsx")
959
+ content = str(args.get("content",""))
960
+ img_pattern = re.compile(r'src=["\'](?!https?://)(.*?)["\']')
961
+ def fix_img(m):
962
+ kw = re.sub(r'[^a-z0-9]','-',m.group(1).lower())[:20] or "photo"
963
+ return f'src="https://picsum.photos/seed/{kw}/800/600"'
964
+ args["content"] = img_pattern.sub(fix_img, content)
965
+ r = await asyncio.to_thread(retry_tool, execute_tool, tc["tool"], args)
966
+ results.append({"tool":tc["tool"],"result":str(r)[:200]})
967
+ step_results.append({"tool":tc["tool"],"result":str(r)[:1500]})
968
+
969
+ if "◆DONE" in response or "\u25c6DONE" in response:
970
+ break
971
+ if not step_results:
972
+ parse_fail_count += 1
973
+ if parse_fail_count > 2: break
974
+ msgs.append({"role":"assistant","content":response})
975
+ msgs.append({"role":"user","content":"Could not parse tool call. Use ONLY: {\"tool\":\"write_file\",\"args\":{\"path\":\"...\",\"content\":\"...\"}}. No prose."})
976
+ continue
977
+ parse_fail_count = 0
978
+ msgs.append({"role":"assistant","content":response})
979
+ msgs.append({"role":"user","content":"Tool results:\n"+json.dumps(step_results)[:2000]+"\nContinue writing remaining files, or ◆DONE if all done."})
980
+
981
+ # ── Phase 3: Build with auto-fix ──────────────────────────────
982
+ build_result = {"status":"not_run","log":""}
983
+ for attempt in range(3):
984
+ log(f"pnpm build attempt {attempt+1}...")
985
+ br = await asyncio.to_thread(
986
+ subprocess.run,
987
+ f"cd {proj_dir} && pnpm run build 2>&1",
988
+ shell=True, capture_output=True, text=True, timeout=600
989
+ )
990
+ build_log = (br.stdout + br.stderr)[-3000:]
991
+ if br.returncode == 0:
992
+ build_result = {"status":"success","log":build_log}
993
+ log("Build succeeded")
994
+ break
995
+ log(f"Build failed attempt {attempt+1}, searching for fix...")
996
+ err_lines = [l for l in build_log.splitlines() if "error" in l.lower() or "Error" in l][:5]
997
+ err_summary = " ".join(err_lines)[:300]
998
+ fix_search = Firewall.search(f"Next.js TypeScript build error fix: {err_summary}", 3)
999
+ fix_context = " ".join([r.get("snippet","") for r in fix_search])[:1000]
1000
+ fix_msgs = [
1001
+ {"role":"system","content":"You are a Next.js expert. Fix the build error. Use write_file to overwrite the broken file only. Respond with one write_file tool call then ◆DONE."},
1002
+ {"role":"user","content":f"Build error:\n{err_summary}\n\nFix suggestions:\n{fix_context}\n\nProject dir: {proj_dir}"}
1003
+ ]
1004
+ fix_resp, _ = await asyncio.to_thread(llm_route_quality, fix_msgs)
1005
+ fix_decoder = json.JSONDecoder()
1006
+ fix_idx = 0
1007
+ while fix_idx < len(fix_resp):
1008
+ bi = fix_resp.find("{", fix_idx)
1009
+ if bi == -1: break
1010
+ try:
1011
+ ftc, fend = fix_decoder.raw_decode(fix_resp, bi)
1012
+ except json.JSONDecodeError:
1013
+ fix_idx = bi + 1
1014
+ continue
1015
+ fix_idx = fend
1016
+ if isinstance(ftc, dict) and ftc.get("tool") == "write_file":
1017
+ await asyncio.to_thread(retry_tool, execute_tool, "write_file", ftc.get("args",{}))
1018
+ results.append({"tool":"write_file","result":"auto-fix applied"})
1019
+ build_result = {"status":f"failed_attempt_{attempt+1}","log":build_log}
1020
+
1021
+ # ── Phase 4: Deploy ───────────────────────────────────────────
1022
+ gh_url, surge_url = "", ""
1023
+ out_dir = f"{proj_dir}/out"
1024
+ if build_result["status"] == "success" and os.path.isdir(out_dir):
1025
+ slug = re.sub(r'[^a-z0-9-]','-',Path(proj_dir).name.lower())
1026
+ if GITHUB_TOKEN:
1027
+ try:
1028
+ gh_url = await asyncio.to_thread(GitHub.push, proj_dir, slug)
1029
+ except Exception as e:
1030
+ gh_url = f"❌ {e}"
1031
+ try:
1032
+ surge_url_r, _ = await asyncio.to_thread(Surge.deploy, out_dir)
1033
+ if surge_url_r: surge_url = surge_url_r
1034
+ except Exception as e:
1035
+ surge_url = f"❌ {e}"
1036
+ else:
1037
+ surge_url = "❌ Build did not produce out/ folder"
1038
+
1039
+ qa_result = execute_tool("qa_review", {"path": proj_dir})
1040
+
1041
+ return {
1042
+ "ok": True,
1043
+ "prompt": prompt,
1044
+ "project": proj_dir,
1045
+ "provider": provider,
1046
+ "tools_run": results,
1047
+ "build": build_result["status"],
1048
+ "build_log": build_result["log"][-500:],
1049
+ "github": gh_url,
1050
+ "surge": surge_url,
1051
+ "qa": qa_result,
1052
+ "response": "◆DONE" if build_result["status"]=="success" else "Build failed"
1053
+ }
1054
+
1055
+
1056
+ # ========== Routes (all blocking calls offloaded to threads) ==========
1057
+ @app.get("/health")
1058
+ async def health():
1059
+ kv = Memory.all_kv()
1060
+ return {
1061
+ "status": "ok",
1062
+ "service": "DOLOR3V MCP Gateway v6.0",
1063
+ "version": "6.0.0",
1064
+ "port": PORT,
1065
+ "tools": TOOL_LIST,
1066
+ "safe_mode": SAFE_MODE,
1067
+ "providers": {
1068
+ "ollama": True,
1069
+ "groq": bool(GROQ_KEY),
1070
+ "openrouter": bool(OPENROUTER_KEY),
1071
+ "github": bool(GITHUB_TOKEN),
1072
+ "surge": bool(SURGE_TOKEN),
1073
+ "hf": bool(HF_TOKEN),
1074
+ "tabby": True,
1075
+ "event_bus": True
1076
+ },
1077
+ "memory_keys": len(kv),
1078
+ "uptime": time.time()
1079
+ }
1080
+
1081
+ @app.get("/v1/tools")
1082
+ async def list_tools():
1083
+ return {"tools": TOOL_LIST}
1084
+
1085
+ @app.get("/v1/memory")
1086
+ async def get_memory():
1087
+ return Memory.all_kv()
1088
+
1089
+ @app.post("/v1/tools/call")
1090
+ async def call_tool(request: Request):
1091
+ body = await request.json()
1092
+ tool = body.get("tool")
1093
+ args = body.get("arguments", body.get("args", {}))
1094
+ t0 = time.time()
1095
+ try:
1096
+ result = await asyncio.to_thread(retry_tool, execute_tool, tool, args)
1097
+ ms = int((time.time()-t0)*1000)
1098
+ emit_event("custom", {"tool":tool,"ms":ms}, "mcp")
1099
+ return {"result": result, "tool": tool, "ms": ms}
1100
+ except Exception as e:
1101
+ raise HTTPException(500, detail=str(e))
1102
+
1103
+ @app.post("/v1/chat/completions")
1104
+ async def chat(request: Request):
1105
+ body = await request.json()
1106
+ messages = body.get("messages", [])
1107
+ model = body.get("model", "")
1108
+ tools = body.get("tools")
1109
+ tool_choice = body.get("tool_choice")
1110
+ message, provider = await asyncio.to_thread(llm_route, messages, model, tools, tool_choice)
1111
+ finish_reason = "tool_calls" if message.get("tool_calls") else "stop"
1112
+ return {
1113
+ "id": f"dolor3v-{int(time.time())}",
1114
+ "object": "chat.completion",
1115
+ "provider": provider,
1116
+ "choices": [{"index": 0, "message": message, "finish_reason": finish_reason}]
1117
+ }
1118
+
1119
+ @app.post("/v1/search")
1120
+ async def search(request: Request):
1121
+ body = await request.json()
1122
+ results = await asyncio.to_thread(Firewall.search, body.get("query",""), body.get("max_results",5))
1123
+ return {"results": results}
1124
+
1125
+ @app.post("/v1/memory")
1126
+ async def memory_ops(request: Request):
1127
+ body = await request.json()
1128
+ action = body.get("action","set")
1129
+ if action == "set":
1130
+ await asyncio.to_thread(Memory.set, body["key"], body["value"])
1131
+ return {"ok": True}
1132
+ elif action == "get":
1133
+ val = await asyncio.to_thread(Memory.get, body["key"])
1134
+ return {"value": val}
1135
+ elif action == "search":
1136
+ r = await asyncio.to_thread(Memory.search, body.get("query",""))
1137
+ return {"results": [x["text"][:300] for x in r]}
1138
+ elif action == "ingest":
1139
+ await asyncio.to_thread(Memory.ingest, body.get("text",""), body.get("source","api"))
1140
+ return {"ok": True}
1141
+ else:
1142
+ raise HTTPException(400, "unknown action")
1143
+
1144
+ @app.post("/v1/deploy/github")
1145
+ async def deploy_github(request: Request):
1146
+ body = await request.json()
1147
+ result = await asyncio.to_thread(GitHub.push, body.get("project_path",""), body.get("repo_name","dolor3v-project"), body.get("message","DOLOR3V build"))
1148
+ return {"result": result}
1149
+
1150
+ @app.post("/v1/deploy/surge")
1151
+ async def deploy_surge(request: Request):
1152
+ body = await request.json()
1153
+ url, err = await asyncio.to_thread(Surge.deploy, body.get("project_path",""), body.get("subdomain"))
1154
+ if url:
1155
+ return {"url": url}
1156
+ else:
1157
+ raise HTTPException(500, detail=err)
1158
+
1159
+ @app.post("/v1/deploy/hf")
1160
+ async def deploy_hf(request: Request):
1161
+ body = await request.json()
1162
+ result = await asyncio.to_thread(execute_tool, "hf_deploy", body)
1163
+ return {"result": result}
1164
+
1165
+ @app.post("/v1/deploy/source")
1166
+ async def deploy_source():
1167
+ result = await asyncio.to_thread(GitHub.push_source)
1168
+ return {"result": result}
1169
+
1170
+ @app.post("/v1/complete")
1171
+ async def complete(request: Request):
1172
+ body = await request.json()
1173
+ result = await asyncio.to_thread(tabby_complete, body.get("prefix",""), body.get("suffix",""), body.get("lang","python"))
1174
+ return {"completion": result}
1175
+
1176
+ @app.post("/v1/firewall")
1177
+ async def firewall(request: Request):
1178
+ body = await request.json()
1179
+ ctx = await asyncio.to_thread(Firewall.gather_context, body.get("prompt",""))
1180
+ return {"context": ctx}
1181
+
1182
+ @app.post("/watchdog")
1183
+ async def watchdog(request: Request):
1184
+ body = await request.json()
1185
+ emit_event("watchdog", body, "mcp-watchdog")
1186
+ return {"ok": True}
1187
+
1188
+ @app.post("/v1/build")
1189
+ async def build(request: Request):
1190
+ body = await request.json()
1191
+ prompt = body.get("prompt","")
1192
+ slug = re.sub(r'[^a-z0-9-]','-', prompt.lower())[:30]
1193
+ ts = datetime.now().strftime("%m%d%H%M")
1194
+ proj_dir = f"{PROJECTS_DIR}/{slug}-{ts}"
1195
+ os.makedirs(proj_dir, exist_ok=True)
1196
+
1197
+ fw_ctx = await asyncio.to_thread(Firewall.gather_context, prompt)
1198
+ system = load_master_prompt() + f"""
1199
+
1200
+ You are DOLOR3V autonomous production web builder.
1201
+
1202
+ Tools available:
1203
+ {', '.join(TOOL_LIST)}
1204
+
1205
+ Project directory:
1206
+ {proj_dir}
1207
+
1208
+ Firewall context:
1209
+ {fw_ctx}
1210
+
1211
+
1212
+ STRICT BUILD RULES:
1213
+
1214
+ 1. You are building a real website.
1215
+ 2. NEVER create:
1216
+ - config.json
1217
+ - output.txt
1218
+ - output.typescript
1219
+ - package files
1220
+ - random files
1221
+
1222
+ 3. ONLY create:
1223
+ index.html
1224
+ style.css
1225
+ script.js
1226
+
1227
+
1228
+ 4. Before writing files:
1229
+ - use web_search once
1230
+ - gather design inspiration
1231
+
1232
+
1233
+ 5. HTML requirements:
1234
+ - semantic HTML5
1235
+ - SEO meta tags
1236
+ - hero section
1237
+ - features
1238
+ - testimonials
1239
+ - FAQ
1240
+ - footer
1241
+
1242
+
1243
+ 6. CSS requirements:
1244
+ - custom color palette
1245
+ - responsive mobile design
1246
+ - animations
1247
+ - modern UI
1248
+
1249
+
1250
+ 7. JS requirements:
1251
+ - real interactions
1252
+ - no empty files
1253
+
1254
+
1255
+ 8. Use write_file for every file.
1256
+
1257
+ 9. After completion reply ONLY:
1258
+
1259
+ ◆DONE
1260
+
1261
+ """
1262
+ msgs = [{"role": "system", "content": system}, {"role": "user", "content": prompt}]
1263
+
1264
+ results = []
1265
+ parse_fail_count = 0
1266
+ response, provider = "", "groq"
1267
+ MAX_BUILD_STEPS = 20
1268
+ for _step in range(MAX_BUILD_STEPS):
1269
+ response, provider = await asyncio.to_thread(llm_route_quality, msgs)
1270
+
1271
+ step_results = []
1272
+ decoder = json.JSONDecoder()
1273
+ idx = 0
1274
+ text_len = len(response)
1275
+ while idx < text_len:
1276
+ brace_idx = response.find("{", idx)
1277
+ if brace_idx == -1:
1278
+ break
1279
+ try:
1280
+ tc, end_idx = decoder.raw_decode(response, brace_idx)
1281
+ except json.JSONDecodeError:
1282
+ idx = brace_idx + 1
1283
+ continue
1284
+ idx = end_idx
1285
+ if isinstance(tc, dict) and "tool" not in tc:
1286
+ preceding = response[max(0, brace_idx-150):brace_idx].lower()
1287
+ inferred_tool = None
1288
+ for tname in TOOL_LIST:
1289
+ if tname.lower() in preceding:
1290
+ inferred_tool = tname
1291
+ break
1292
+ if inferred_tool:
1293
+ tc = {"tool": inferred_tool, "args": tc}
1294
+ if isinstance(tc, dict) and "tool" in tc:
1295
+ args = tc.get("args", {})
1296
+ if tc.get("tool") == "write_file" and not args.get("path") and not args.get("file_path"):
1297
+ content_preview = str(args.get("content", ""))
1298
+ if "<!DOCTYPE" in content_preview or "<html" in content_preview:
1299
+ default_name = "index.html"
1300
+ elif content_preview.strip().startswith("function") or "document." in content_preview[:200]:
1301
+ default_name = "script.js"
1302
+ elif "{" in content_preview[:100] and ":" in content_preview[:100] and "<" not in content_preview[:50]:
1303
+ default_name = "style.css"
1304
+ else:
1305
+ default_name = "output.txt"
1306
+ args["path"] = os.path.join(proj_dir, default_name)
1307
+ r = await asyncio.to_thread(retry_tool, execute_tool, tc["tool"], args)
1308
+ results.append({"tool": tc["tool"], "result": str(r)[:200]})
1309
+ step_results.append({"tool": tc["tool"], "result": str(r)[:1500]})
1310
+
1311
+ if not step_results:
1312
+ parse_fail_count += 1
1313
+ if parse_fail_count > 2:
1314
+ break
1315
+ msgs.append({"role": "assistant", "content": response})
1316
+ msgs.append({"role": "user", "content": "I could not parse a valid tool call from that response. Respond with ONLY one JSON object in the exact shape: {\"tool\": \"<tool_name>\", \"args\": {...}}. No narration, no repeated objects, no other text. Valid tool names: " + ", ".join(TOOL_LIST)})
1317
+ continue
1318
+
1319
+ qa_passed = any(
1320
+ r.get("tool") == "qa_review" and "passed" in str(r.get("result", "")).lower()
1321
+ for r in step_results
1322
+ )
1323
+ if qa_passed:
1324
+ response = "\u25c6DONE"
1325
+ break
1326
+
1327
+ msgs.append({"role": "assistant", "content": response})
1328
+ msgs.append({"role": "user", "content": "Tool results:\n" + json.dumps(step_results)[:3000] + "\nPerform QA review before completion. Check design quality, responsiveness, completeness, and production readiness. Improve if needed before responding \u25c6DONE."})
1329
+
1330
+ wrote_files = any(r.get("tool") == "write_file" for r in results)
1331
+ # If no files were written via tool calls, auto-extract code blocks and save them
1332
+ if not wrote_files:
1333
+ code_blocks = re.findall(r'```(\w+)?\n(.*?)```', response, re.DOTALL)
1334
+ if code_blocks:
1335
+ for i, (lang, code) in enumerate(code_blocks):
1336
+ ext_map = {
1337
+ 'html': 'index.html', 'css': 'style.css', 'js': 'script.js',
1338
+ 'javascript': 'script.js', 'python': 'main.py', 'py': 'main.py',
1339
+ 'sh': 'setup.sh', 'bash': 'setup.sh', 'json': 'config.json',
1340
+ 'yaml': 'config.yaml', 'yml': 'config.yaml', '': 'output.txt'
1341
+ }
1342
+ ext = lang.strip() if lang else 'html'
1343
+ fname = ext_map.get(ext, f'output.{ext}')
1344
+ fpath = os.path.join(proj_dir, fname)
1345
+ with open(fpath, 'w') as f:
1346
+ f.write(code.strip())
1347
+ results.append({"tool": "write_file", "result": f"Saved {fname} ({len(code)} bytes)"})
1348
+ log(f"Auto-saved extracted code block: {fname}")
1349
+ else:
1350
+ html_match = re.search(r'(<!DOCTYPE html>.*?</html>|<html[^>]*>.*?</html>)', response, re.DOTALL | re.IGNORECASE)
1351
+ if html_match:
1352
+ extracted_html = html_match.group(1)
1353
+ fpath = os.path.join(proj_dir, 'index.html')
1354
+ with open(fpath, 'w') as f:
1355
+ f.write(extracted_html)
1356
+ results.append({"tool": "write_file", "result": f"Saved index.html extracted from response ({len(extracted_html)} bytes)"})
1357
+ log("Auto-extracted HTML block from response and saved as index.html")
1358
+ else:
1359
+ log("No extractable HTML found in malformed response; nothing written")
1360
+
1361
+ gh_url, surge_url = "", ""
1362
+ if GITHUB_TOKEN:
1363
+ try:
1364
+ gh_url = await asyncio.to_thread(GitHub.push, proj_dir, f"dolor3v-{slug}-{ts}")
1365
+ except:
1366
+ gh_url = "GitHub push failed – check GITHUB_TOKEN"
1367
+ try:
1368
+ surge_url_r, _ = await asyncio.to_thread(Surge.deploy, proj_dir)
1369
+ if surge_url_r: surge_url = surge_url_r
1370
+ except:
1371
+ pass
1372
+
1373
+ return {
1374
+ "ok": True,
1375
+ "prompt": prompt,
1376
+ "project": proj_dir,
1377
+ "provider": provider,
1378
+ "tools_run": results,
1379
+ "github": gh_url,
1380
+ "surge": surge_url,
1381
+ "response": response[:500]
1382
+ }
1383
+ AGENT_SYSTEM_PROMPT = (
1384
+ "You are DOLOR3V Agent, an autonomous coding/ops assistant with real tool access.\n\n"
1385
+ "Available tools: " + ", ".join(TOOL_LIST) + "\n\n"
1386
+ "To use a tool, respond with ONLY one JSON object on its own line and nothing else, in this exact shape:\n"
1387
+ '{"tool": "<tool_name>", "args": {"...": "..."}}\n\n'
1388
+ "After a tool result is returned to you, you may call another tool the same way, "
1389
+ "or give your final answer as plain text with no JSON and no markdown fences. "
1390
+ "Only call a tool when you actually need it; if you already know the answer, just answer directly.\n\n"
1391
+ "🔥 NEW: Use `project_index` to get an overview of the codebase, and `grep_code` to search for patterns. "
1392
+ "You can `spawn_agent` to handle subtasks in parallel. "
1393
+ "Safety: dangerous shell commands (rm -rf, git push --force, etc.) will be blocked unless DOLOR3V_SAFE_MODE=false."
1394
+ )
1395
+
1396
+ def maybe_summarize(messages, max_chars=MAX_CONTEXT_CHARS):
1397
+ """Keep total payload under limit by keeping system prompt + last few messages only."""
1398
+ # Quick check: if already small enough, return as-is
1399
+ total = sum(len(str(m.get("content",""))) for m in messages)
1400
+ if total <= max_chars:
1401
+ return messages
1402
+ # Always keep system messages (first 2 usually)
1403
+ system_msgs = [m for m in messages if m["role"]=="system"][:2]
1404
+ # Keep the last 6 non-system messages
1405
+ other_msgs = [m for m in messages if m["role"]!="system"]
1406
+ keep = other_msgs[-6:] if len(other_msgs) > 6 else other_msgs
1407
+ # Insert a summary note
1408
+ summary = {"role":"system","content":"[Earlier conversation truncated to fit context window]"}
1409
+ return system_msgs + [summary] + keep
1410
+
1411
+ def agent_llm(messages):
1412
+ return llm_route(messages)
1413
+
1414
+ @app.post("/v1/agent")
1415
+ async def agent(request: Request):
1416
+ body = await request.json()
1417
+ user_prompt = body.get("prompt", "")
1418
+ history = body.get("history", [])
1419
+ max_steps = body.get("max_steps", 6)
1420
+
1421
+ # Automatic project context (Claude Code feature)
1422
+ proj_index = await asyncio.to_thread(project_index, ".")
1423
+ sys_content = AGENT_SYSTEM_PROMPT + "\n\n=== Current project context ===\n" + proj_index
1424
+
1425
+ messages = [{"role":"system","content":sys_content}] + history + [{"role":"user","content":user_prompt}]
1426
+ steps = []
1427
+ final_text = ""
1428
+ provider = "none"
1429
+
1430
+ for _ in range(max_steps):
1431
+ messages = maybe_summarize(messages)
1432
+ # Try primary LLM call, then fallback if it fails
1433
+ reply = None
1434
+ provider = "none"
1435
+ llm_error = ""
1436
+ for attempt in range(2):
1437
+ try:
1438
+ reply, provider = await asyncio.to_thread(agent_llm, messages)
1439
+ break
1440
+ except Exception as e:
1441
+ llm_error = str(e)
1442
+ log(f"Agent LLM attempt {attempt+1} failed: {e}")
1443
+ # On first failure, try forcing the next provider by removing Ollama from list
1444
+ if attempt == 0:
1445
+ messages.insert(0, {"role":"system","content":"Previous LLM failed. Please use a different provider."})
1446
+ if reply is None:
1447
+ final_text = f"All LLM providers failed: {llm_error}"
1448
+ break
1449
+
1450
+ line = reply.strip()
1451
+ if line.startswith("{") and '"tool"' in line:
1452
+ try:
1453
+ tc = json.loads(line)
1454
+ tool_name = tc.get("tool")
1455
+ tool_args = tc.get("args", {})
1456
+ tool_result = await asyncio.to_thread(retry_tool, execute_tool, tool_name, tool_args)
1457
+ steps.append({"tool": tool_name, "args": tool_args, "result": str(tool_result)[:500]})
1458
+ messages.append({"role":"assistant","content":line})
1459
+ messages.append({"role":"user","content":f"Tool result:\n{tool_result}"})
1460
+ continue
1461
+ except Exception:
1462
+ final_text = reply
1463
+ break
1464
+ # small delay to avoid Groq rate limits
1465
+ await asyncio.sleep(2)
1466
+ final_text = reply
1467
+ break
1468
+ else:
1469
+ final_text = "Max steps reached without a final answer."
1470
+
1471
+ emit_event("agent", {"prompt": user_prompt[:100], "steps": len(steps)}, "mcp-agent")
1472
+
1473
+ return {
1474
+ "response": final_text,
1475
+ "provider": provider,
1476
+ "steps": steps
1477
+ }
1478
+
1479
+ if __name__ == "__main__":
1480
+ print(f"""
1481
+ DOLOR3V MCP GATEWAY v6.0
1482
+ Port : {PORT}
1483
+ Tools : {len(TOOL_LIST)}
1484
+ Safe mode: {SAFE_MODE}
1485
+ GitHub : {"✅" if GITHUB_TOKEN else "❌ set GITHUB_TOKEN"}
1486
+ Surge : {"✅" if SURGE_TOKEN else "❌ set SURGE_TOKEN"}
1487
+ HF : {"✅" if HF_TOKEN else "❌ set HF_TOKEN"}
1488
+ Groq : {"✅" if GROQ_KEY else "❌ set GROQ_API_KEY"}
1489
+ """)
1490
+ uvicorn.run(app, host="0.0.0.0", port=PORT)
load_master_prompt.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+
3
+ MASTER_PROMPT_FILE = "/opt/dolor3v/master_builder_prompt.txt"
4
+
5
+ def load_master_prompt():
6
+ try:
7
+ return Path(MASTER_PROMPT_FILE).read_text(encoding="utf-8")
8
+ except Exception as e:
9
+ return f"MASTER PROMPT LOAD ERROR: {e}"
master_builder_prompt.txt ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ You are DOLOR3V Ultra Builder — a senior product designer and staff frontend engineer who builds hyper-realistic, production-ready websites and applications. No demos. No basics. No templates.
2
+
3
+ STACK
4
+ Default stack for every build:
5
+ - Next.js (App Router) + TypeScript
6
+ - Tailwind CSS v4
7
+ - Framer Motion for animations
8
+ - Real working image URLs from https://picsum.photos/seed/keyword/width/height
9
+ - Google Fonts (pair a display font with a clean body font)
10
+
11
+ RESEARCH FIRST
12
+ Before writing any code:
13
+ 1. Call web_search to find real design inspiration, color palettes, and copy ideas relevant to the prompt.
14
+ 2. Optionally call web_fetch on the most promising result to extract real content and visual direction.
15
+ 3. Use memory_search to recall any relevant past context.
16
+
17
+ SCAFFOLD
18
+ After research, scaffold the project:
19
+ {"tool": "build_project", "args": {"path": "PROJECT_DIR", "command": "pnpm create next-app . --typescript --tailwind --eslint --app --no-src-dir --no-git --yes"}}
20
+
21
+ Then install Framer Motion:
22
+ {"tool": "build_project", "args": {"path": "PROJECT_DIR", "command": "pnpm add framer-motion"}}
23
+
24
+ Then configure static export (required for Surge deployment):
25
+ {"tool": "write_file", "args": {"path": "PROJECT_DIR/next.config.ts", "content": "import type { NextConfig } from 'next';
26
+ const nextConfig: NextConfig = { output: 'export', images: { unoptimized: true } };
27
+ export default nextConfig;"}}
28
+
29
+ DESIGN QUALITY BAR
30
+ - Deliberate color palette (2-3 colors) inspired by your web_search results. Never default browser blue or plain white.
31
+ - Glassmorphism, gradients, subtle hover effects, scroll animations via Framer Motion where appropriate.
32
+ - Every image: real working URL from picsum.photos or placehold.co. Never broken or fake src.
33
+ - Typography: pair a Google Fonts display heading font with a clean sans-serif body font.
34
+
35
+ PAGE STRUCTURE (minimum)
36
+ Build these pages as separate files under app/:
37
+ - page.tsx (Home): hero, features, testimonials (2-3 real quotes), FAQ (3-5 Q&As), CTA section
38
+ - about/page.tsx: brand story, team section
39
+ - services/page.tsx: detailed service cards
40
+ - contact/page.tsx: contact form, location, social links
41
+ - Shared components/: Navbar.tsx, Footer.tsx, shared UI components
42
+
43
+ BRANDING
44
+ Give the project a real brand identity:
45
+ - A memorable name fitting the prompt
46
+ - A short punchy tagline
47
+ - Consistent color, font, and tone across all pages
48
+
49
+ BUILD
50
+ After writing all files, build the project:
51
+ {"tool": "build_project", "args": {"path": "PROJECT_DIR", "command": "pnpm run build"}}
52
+
53
+ If the build fails, call web_search for the specific error message, fix the code, and rebuild.
54
+
55
+ DEPLOY
56
+ After a successful build, deploy:
57
+ {"tool": "surge_deploy", "args": {"path": "PROJECT_DIR/out"}}
58
+ {"tool": "github_push", "args": {"path": "PROJECT_DIR", "repo": "REPO_NAME"}}
59
+
60
+ QUALITY CHECK
61
+ After deploying, call qa_review on PROJECT_DIR to check for issues. Fix anything found.
62
+
63
+ TOOL RESULTS
64
+ Tool results are always returned to you automatically in the next message. Never try to read a tool result from a file. Never omit the "tool" key from a tool call.
65
+
66
+ COMPLETION
67
+ Once built, deployed, and qa_review passes, respond with exactly: ◆DONE
requirements.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ fastapi
2
+ uvicorn
3
+ requests
4
+ beautifulsoup4
5
+ python-dotenv
6
+ colorthief
7
+ pillow