GitHub Action commited on
Commit
e6fbc88
·
1 Parent(s): de4b6b2

sync: backend da GitHub 2026-05-18T11:59:53Z

Browse files
.gitignore ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ *.pyo
5
+ chroma_db/
6
+ *.egg-info/
7
+ .env
8
+
Dockerfile CHANGED
@@ -1,14 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  FROM python:3.11-slim
2
 
3
- RUN useradd -m -u 1000 user
4
- USER user
5
- ENV PATH="/home/user/.local/bin:$PATH"
 
 
 
6
  WORKDIR /app
7
 
8
- COPY --chown=user requirements.txt .
9
- RUN pip install --no-cache-dir -r requirements.txt
 
 
 
 
 
 
 
 
 
10
 
11
- COPY --chown=user . .
 
 
 
12
 
 
 
 
 
 
 
 
 
 
13
  EXPOSE 7860
14
- CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
 
 
1
+ # ──────────────────────────────────────────────────────────────────────────────
2
+ # Dockerfile — Backend FastAPI per HuggingFace Spaces
3
+ #
4
+ # HuggingFace Spaces: server sempre attivo, 16GB RAM, gratuito
5
+ # Supabase: database per memoria, file, conversazioni (esterno)
6
+ #
7
+ # Deploy:
8
+ # 1. Crea uno Space su https://huggingface.co/new-space (SDK: Docker)
9
+ # 2. Connetti il repo GitHub Baida98/AI
10
+ # 3. Aggiungi i secrets Supabase in Settings → Repository secrets:
11
+ # SUPABASE_URL, SUPABASE_KEY
12
+ # e i secret LLM opzionali:
13
+ # OPENROUTER_API_KEY, GROQ_API_KEY, GEMINI_API_KEY, HF_TOKEN
14
+ # 4. HF Spaces costruisce e avvia automaticamente questo Dockerfile
15
+ # ──────────────────────────────────────────────────────────────────────────────
16
+
17
  FROM python:3.11-slim
18
 
19
+ # Variabili HuggingFace Spaces
20
+ ENV PYTHONUNBUFFERED=1 \
21
+ PYTHONDONTWRITEBYTECODE=1 \
22
+ PORT=7860 \
23
+ FRONTEND_DIST=/app/backend/static
24
+
25
  WORKDIR /app
26
 
27
+ # Dipendenze sistema (minimali)
28
+ RUN apt-get update && apt-get install -y --no-install-recommends \
29
+ build-essential curl git \
30
+ && rm -rf /var/lib/apt/lists/*
31
+
32
+ # Dipendenze Python
33
+ COPY backend/requirements.txt /app/backend/requirements.txt
34
+ RUN pip install --no-cache-dir -r /app/backend/requirements.txt
35
+
36
+ # Copia il backend
37
+ COPY backend/ /app/backend/
38
 
39
+ # Copia il frontend buildato (se presente)
40
+ # La build Vite va fatta prima del deploy e committata in backend/static/
41
+ # oppure buildala qui se il repo contiene i sorgenti frontend
42
+ # COPY artifacts/agente-ai/dist/ /app/backend/static/
43
 
44
+ # Crea user non-root (richiesto da HF Spaces)
45
+ RUN useradd -m -u 1000 user
46
+ USER user
47
+ ENV HOME=/home/user PATH=/home/user/.local/bin:$PATH
48
+
49
+ WORKDIR /home/user/app
50
+ COPY --chown=user backend/ /home/user/app/backend/
51
+
52
+ # HuggingFace Spaces usa la porta 7860
53
  EXPOSE 7860
54
+
55
+ CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "1"]
README.md CHANGED
@@ -1,11 +1,42 @@
1
- ---
2
- title: Terminal
3
- emoji: 🐢
4
- colorFrom: indigo
5
- colorTo: yellow
6
- sdk: docker
7
- pinned: false
8
- short_description: Terminal AI python
9
- ---
10
-
11
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Agente AI — Backend locale
2
+
3
+ Backend Python FastAPI con Ollama + ChromaDB + 4-layer memory.
4
+
5
+ ## Setup rapido
6
+
7
+ ```bash
8
+ # 1. Installa Ollama: https://ollama.com
9
+ ollama serve
10
+ ollama pull qwen2.5-coder:7b # consigliato (~4GB)
11
+
12
+ # 2. Avvia il backend
13
+ cd backend && bash start.sh
14
+ ```
15
+
16
+ Poi apri l'app: il frontend rileva automaticamente il backend locale.
17
+
18
+ ## Architettura
19
+
20
+ Frontend React → Backend :8000 → Ollama :11434
21
+ → ChromaDB (vector memory)
22
+
23
+ ## 4 Layer di memoria
24
+
25
+ | Layer | Scopo | Storage |
26
+ |-----------|------------------------------|-----------|
27
+ | Working | Ultime 20 chat | RAM |
28
+ | Episodic | Task completati, errori, fix | SQLite |
29
+ | Semantic | Preferenze, concetti chiave | ChromaDB |
30
+ | Reflection| Pattern errori ricorrenti | JSON |
31
+
32
+ ## Endpoints
33
+
34
+ - GET /health — stato Ollama + modelli disponibili
35
+ - POST /v1/chat/completions — streaming OpenAI-compatible
36
+ - POST /api/plan — pianifica task complesso
37
+ - POST /api/critique — valida output (critic model)
38
+ - POST /api/memory/search — ricerca semantica
39
+ - GET /api/memory/stats — statistiche memoria
40
+ - POST /api/reflect — reflection loop
41
+ - GET /api/tools — lista tool
42
+
agents/__init__.py ADDED
File without changes
agents/code_agent.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """code_agent.py — Coding Agent: scan→analyze→plan→edit→validate→reflect"""
2
+ import asyncio, json, re
3
+ from models.ollama_client import OllamaClient
4
+
5
+ SYSTEM = """Sei un coding agent esperto. Analisi codice, bugfix, refactor.
6
+ Rispondi con JSON strutturato quando richiesto."""
7
+
8
+ class CodeAgent:
9
+ def __init__(self, ollama: OllamaClient): self.ollama = ollama
10
+
11
+ async def _llm_json(self, prompt:str, system:str=SYSTEM, max_tokens:int=1024)->dict:
12
+ msgs=[{"role":"system","content":system},{"role":"user","content":prompt}]
13
+ try:
14
+ raw=await self.ollama.chat(msgs,temperature=0.2,max_tokens=max_tokens)
15
+ m=re.search(r'\{[\s\S]+\}',raw)
16
+ return json.loads(m.group()) if m else {}
17
+ except: return {}
18
+
19
+ async def analyze_file(self, filepath:str, content:str, goal:str)->dict:
20
+ p=f"Analizza per: {goal}\nFile: {filepath}\nContenuto (prime 3000 char):\n{content[:3000]}\n\nRispondi JSON: {{issues:[], suggestions:[], complexity:'low|medium|high', priority:1-10, safe_to_edit:bool}}"
21
+ r=await self._llm_json(p)
22
+ return r or {"issues":[],"suggestions":[],"complexity":"unknown","priority":5,"safe_to_edit":True}
23
+
24
+ async def plan_edit(self, filepath:str, content:str, goal:str)->dict:
25
+ p=f"Pianifica modifiche per: {goal}\nFile: {filepath}\n{content[:2500]}\n\nJSON: {{steps:[{{description,type:'insert|replace|delete',old_code,new_code,risk:'low|medium|high'}}], requires_tests:bool, breaking_change:bool}}"
26
+ r=await self._llm_json(p,max_tokens=2048)
27
+ return r or {"steps":[],"requires_tests":False,"breaking_change":False,"_fallback":True}
28
+
29
+ async def generate_fix(self, filepath:str, content:str, issue:str)->str:
30
+ msgs=[{"role":"system","content":SYSTEM},
31
+ {"role":"user","content":f"Correggi: {issue}\nFile: {filepath}\n{content[:4000]}\n\nRestituisci SOLO il codice corretto."}]
32
+ return await self.ollama.chat(msgs,temperature=0.15,max_tokens=4096)
33
+
34
+ async def validate_edit(self, original:str, edited:str, goal:str)->dict:
35
+ p=f"Valida modifica per: {goal}\nPRIMA:{original[:1500]}\nDOPO:{edited[:1500]}\nJSON:{{valid:bool,achieves_goal:bool,regressions:[],confidence:0-1}}"
36
+ r=await self._llm_json(p,max_tokens=512)
37
+ return r or {"valid":True,"achieves_goal":True,"regressions":[],"confidence":0.7,"_fallback":True}
38
+
39
+ async def full_session(self, goal:str, files:list[dict])->dict:
40
+ session={"goal":goal,"analyzed":[],"edits":[]}
41
+ for f in files[:4]:
42
+ analysis=await self.analyze_file(f["path"],f["content"],goal)
43
+ if (analysis.get("priority") or 0)>3:
44
+ plan=await self.plan_edit(f["path"],f["content"],goal)
45
+ session["analyzed"].append({"path":f["path"],"analysis":analysis,"plan":plan})
46
+ if plan.get("steps"):
47
+ edited=await self.generate_fix(f["path"],f["content"],goal)
48
+ val=await self.validate_edit(f["content"],edited,goal)
49
+ session["edits"].append({"path":f["path"],"edited":edited,"validation":val})
50
+ session["summary"]=f"Analizzati {len(session['analyzed'])} file, {len(session['edits'])} modifiche."
51
+ return session
agents/critic.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ critic.py — Critic Model
3
+ Secondo passaggio: verifica output, trova errori, suggerisce miglioramenti.
4
+ """
5
+ import json, re
6
+ from models.ollama_client import OllamaClient
7
+
8
+ CRITIC_SYSTEM = """Sei un critico AI. Valuta l'output dato e rispondi SOLO con JSON:
9
+ {
10
+ "quality": 0-10,
11
+ "issues": ["lista problemi trovati"],
12
+ "suggestions": ["lista miglioramenti"],
13
+ "is_complete": true/false,
14
+ "needs_retry": true/false,
15
+ "confidence": 0.0-1.0
16
+ }"""
17
+
18
+ class Critic:
19
+ def __init__(self, ollama: OllamaClient):
20
+ self.ollama = ollama
21
+
22
+ async def evaluate(self, task: str, output: str, model: str | None = None) -> dict:
23
+ messages = [
24
+ {"role": "system", "content": CRITIC_SYSTEM},
25
+ {"role": "user", "content": f"Task originale: {task}\n\nOutput da valutare:\n{output[:2000]}"},
26
+ ]
27
+ try:
28
+ raw = await self.ollama.chat(messages, model=model, temperature=0.2, max_tokens=512)
29
+ json_match = re.search(r'\{[\s\S]+\}', raw)
30
+ if json_match:
31
+ result = json.loads(json_match.group())
32
+ result["_evaluated"] = True
33
+ return result
34
+ except Exception:
35
+ pass
36
+
37
+ # Fallback euristico
38
+ quality = 5
39
+ issues = []
40
+ if len(output) < 50:
41
+ quality -= 3; issues.append("Output troppo breve")
42
+ if "errore" in output.lower() or "error" in output.lower():
43
+ quality -= 2; issues.append("Potenziali errori nell'output")
44
+ if len(output) > 100:
45
+ quality += 2
46
+
47
+ return {
48
+ "quality": max(0, min(10, quality)),
49
+ "issues": issues,
50
+ "suggestions": ["Verifica la completezza della risposta"],
51
+ "is_complete": len(output) > 100,
52
+ "needs_retry": quality < 4,
53
+ "confidence": 0.5,
54
+ "_fallback": True,
55
+ }
agents/executor.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ executor.py — Tool Executor con retry e timeout.
3
+ """
4
+ import asyncio
5
+ from models.ollama_client import OllamaClient
6
+ from memory.manager import MemoryManager
7
+ from tools.registry import TOOL_REGISTRY
8
+
9
+ class Executor:
10
+ def __init__(self, ollama: OllamaClient, memory: MemoryManager, max_retries: int = 2):
11
+ self.ollama = ollama
12
+ self.memory = memory
13
+ self.max_retries = max_retries
14
+
15
+ async def run_tool(self, tool_name: str, inputs: dict, timeout: float = 30.0) -> dict:
16
+ tool = TOOL_REGISTRY.get(tool_name)
17
+ if not tool:
18
+ return {"success": False, "error": f"Tool '{tool_name}' non trovato", "output": None}
19
+
20
+ # Validate required inputs
21
+ missing = [r for r in tool.get("required_inputs", []) if r not in inputs]
22
+ if missing:
23
+ return {"success": False, "error": f"Input mancanti: {missing}", "output": None}
24
+
25
+ for attempt in range(self.max_retries + 1):
26
+ try:
27
+ fn = tool.get("_fn")
28
+ if fn is None:
29
+ return {"success": False, "error": "Tool non ha funzione di esecuzione", "output": None}
30
+ result = await asyncio.wait_for(fn(**inputs), timeout=timeout)
31
+ await self.memory.save_episode("tool", f"{tool_name}: {str(inputs)[:100]}", str(result)[:500], True)
32
+ return {"success": True, "tool": tool_name, "output": result, "attempt": attempt + 1}
33
+ except asyncio.TimeoutError:
34
+ if attempt == self.max_retries:
35
+ err = f"Timeout dopo {timeout}s"
36
+ await self.memory.save_episode("tool", f"{tool_name}: {str(inputs)[:100]}", err, False)
37
+ await self.memory.reflect(f"{tool_name} timeout", err, False, err)
38
+ return {"success": False, "error": err, "output": None}
39
+ await asyncio.sleep(1.0 * (attempt + 1))
40
+ except Exception as e:
41
+ if attempt == self.max_retries:
42
+ err = str(e)
43
+ await self.memory.save_episode("tool", f"{tool_name}", err, False)
44
+ return {"success": False, "error": err, "output": None}
45
+ await asyncio.sleep(0.5)
46
+
47
+ return {"success": False, "error": "Max retries raggiunti", "output": None}
agents/planner.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ planner.py — Task Planner
3
+ Decompone obiettivi complessi in subtask con tool selection.
4
+ """
5
+ import json, re
6
+ from models.ollama_client import OllamaClient
7
+
8
+ PLANNER_SYSTEM = """Sei un planner AI. Dato un obiettivo, decomponilo in subtask concreti.
9
+
10
+ Rispondi SOLO con JSON valido nel formato:
11
+ {
12
+ "goal": "obiettivo originale",
13
+ "complexity": "low|medium|high",
14
+ "subtasks": [
15
+ {
16
+ "id": 1,
17
+ "description": "cosa fare",
18
+ "tool": "web_search|code|read_page|memory|direct_response",
19
+ "requires": [],
20
+ "risk": "low|medium|high"
21
+ }
22
+ ],
23
+ "estimated_steps": 3
24
+ }"""
25
+
26
+ class Planner:
27
+ def __init__(self, ollama: OllamaClient):
28
+ self.ollama = ollama
29
+
30
+ async def create_plan(self, goal: str, context: list | None = None, model: str | None = None) -> dict:
31
+ messages = [
32
+ {"role": "system", "content": PLANNER_SYSTEM},
33
+ {"role": "user", "content": f"Obiettivo: {goal}"}
34
+ ]
35
+ if context:
36
+ ctx_str = "\n".join(m.get("content", "")[:100] for m in context[-3:])
37
+ messages[1]["content"] += f"\n\nContesto recente:\n{ctx_str}"
38
+
39
+ try:
40
+ raw = await self.ollama.chat(messages, model=model, temperature=0.3, max_tokens=1024)
41
+ # Extract JSON from response
42
+ json_match = re.search(r'\{[\s\S]+\}', raw)
43
+ if json_match:
44
+ plan = json.loads(json_match.group())
45
+ plan["_raw"] = raw[:200]
46
+ return plan
47
+ except Exception as e:
48
+ pass
49
+
50
+ # Fallback: simple plan
51
+ return {
52
+ "goal": goal,
53
+ "complexity": "medium",
54
+ "subtasks": [{"id": 1, "description": goal, "tool": "direct_response", "requires": [], "risk": "low"}],
55
+ "estimated_steps": 1,
56
+ "_fallback": True,
57
+ }
agents/reasoning_core.py ADDED
@@ -0,0 +1,219 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ reasoning_core.py — MobileMaxAgent Implementation
3
+ Cervello di livello massimo: Project Understanding + Strategy Engine + Auto-Debug Loop.
4
+ """
5
+ from dataclasses import dataclass, field
6
+ from typing import List, Dict, Any, Optional
7
+ import json, re
8
+
9
+
10
+ @dataclass
11
+ class ReasoningResult:
12
+ action: str # "plan" | "fix" | "continue" | "stop" | "analyze" | "strategy"
13
+ steps: List[str]
14
+ patch: Optional[str] = None
15
+ reason: str = ""
16
+ confidence: float = 0.5
17
+
18
+
19
+ @dataclass
20
+ class ReasoningState:
21
+ goal: str
22
+ context: str = ""
23
+ last_result: str = ""
24
+ errors: List[str] = field(default_factory=list)
25
+ completed_steps: List[str] = field(default_factory=list)
26
+ loop_count: int = 0
27
+ world_model: Optional[str] = None
28
+ strategy: Optional[str] = None
29
+
30
+
31
+ class ReasoningCore:
32
+ """
33
+ MobileMaxAgent — Evoluzione del ReasoningCore.
34
+ Gestisce l'intero ciclo di vita del progetto:
35
+ 1. Analyze (Project Understanding)
36
+ 2. Strategy (Global Decision Making)
37
+ 3. Patch (Multi-file implementation)
38
+ 4. Run & Debug (Auto-repair loop)
39
+ """
40
+ MAX_LOOPS = 15
41
+ MIN_CONFIDENCE = 0.4
42
+
43
+ def __init__(self, ollama_client, planner=None, critic=None, executor=None):
44
+ self.llm = ollama_client
45
+ self.planner = planner
46
+ self.critic = critic
47
+ self.executor = executor
48
+
49
+ # ── 1. Project Understanding ────────────────────────────────────────────────
50
+ async def analyze_project(self, repo_context: str) -> str:
51
+ prompt = f"""Analyze full software system.
52
+ Return:
53
+ - architecture map
54
+ - dependencies
55
+ - risk zones
56
+ - entry points
57
+ CONTEXT:
58
+ {repo_context}
59
+ """
60
+ return await self.llm.chat([{"role": "user", "content": prompt}], temperature=0.2)
61
+
62
+ # ── 2. Global Strategy (Devin Core) ─────────────────────────────────────────
63
+ async def develop_strategy(self, state: ReasoningState) -> str:
64
+ prompt = f"""You are an autonomous software engineer.
65
+ WORLD MODEL:
66
+ {state.world_model}
67
+ STATE:
68
+ - goal: {state.goal}
69
+ - errors: {state.errors}
70
+ - completed: {state.completed_steps}
71
+ Decide:
72
+ - what to change
73
+ - why
74
+ - impact
75
+ - risk level
76
+ """
77
+ return await self.llm.chat([{"role": "user", "content": prompt}], temperature=0.3)
78
+
79
+ # ── 3. Error Intelligence ───────────────────────────────────────────────────
80
+ async def analyze_error(self, error: str) -> str:
81
+ prompt = f"""Map error to codebase.
82
+ ERROR:
83
+ {error}
84
+ Return:
85
+ - file
86
+ - root cause
87
+ - fix strategy
88
+ """
89
+ return await self.llm.chat([{"role": "user", "content": prompt}], temperature=0.1)
90
+
91
+ # ── Prompt builder ──────────────────────────────────────────────────────────
92
+ def _build_prompt(self, state: ReasoningState) -> str:
93
+ errors_str = "\n".join(state.errors[-3:]) if state.errors else "nessuno"
94
+ steps_str = "\n".join(f"- {s}" for s in state.completed_steps[-5:]) if state.completed_steps else "nessuno"
95
+
96
+ return f"""Sei MobileMaxAgent, un sistema di ingegneria software autonoma.
97
+ Analizza lo stato e decidi l'azione successiva.
98
+
99
+ STATO:
100
+ - goal: {state.goal}
101
+ - world_model: {'Presente' if state.world_model else 'Mancante'}
102
+ - strategy: {'Definita' if state.strategy else 'Da definire'}
103
+ - last_result: {state.last_result[:300] if state.last_result else 'vuoto'}
104
+ - errors: {errors_str}
105
+ - loop_count: {state.loop_count}/{self.MAX_LOOPS}
106
+
107
+ Rispondi SOLO con JSON valido:
108
+ {{
109
+ "action": "analyze | strategy | plan | fix | continue | stop",
110
+ "steps": ["prossimo passo tecnico"],
111
+ "patch": "eventuale diff o codice",
112
+ "reason": "perché questa azione?",
113
+ "confidence": 0.0-1.0
114
+ }}
115
+
116
+ Regole:
117
+ 1. Se manca world_model -> "analyze"
118
+ 2. Se manca strategy -> "strategy"
119
+ 3. Se strategy c'è ma serve piano -> "plan"
120
+ 4. Se ci sono errori -> "fix"
121
+ 5. Se tutto ok -> "continue" o "stop" se finito.
122
+ """
123
+
124
+ def _parse(self, raw: str) -> ReasoningResult:
125
+ try:
126
+ m = re.search(r'\{[\s\S]+\}', raw)
127
+ data = json.loads(m.group()) if m else {}
128
+ except Exception:
129
+ return ReasoningResult(action="continue", steps=[], reason="Parsing error fallback", confidence=0.2)
130
+
131
+ return ReasoningResult(
132
+ action=data.get("action", "continue"),
133
+ steps=data.get("steps", []),
134
+ patch=data.get("patch"),
135
+ reason=data.get("reason", ""),
136
+ confidence=float(data.get("confidence", 0.5))
137
+ )
138
+
139
+ async def decide(self, state: ReasoningState) -> ReasoningResult:
140
+ if state.loop_count >= self.MAX_LOOPS:
141
+ return ReasoningResult(action="stop", steps=[], reason="Max loops reached", confidence=1.0)
142
+
143
+ prompt = self._build_prompt(state)
144
+ try:
145
+ raw = await self.llm.chat([{"role": "user", "content": prompt}], temperature=0.2)
146
+ return self._parse(raw)
147
+ except Exception as e:
148
+ return ReasoningResult(action="continue", steps=[], reason=f"LLM error: {e}", confidence=0.3)
149
+
150
+ async def run_loop(self, goal: str, context: str = "", on_step=None) -> Dict[str, Any]:
151
+ state = ReasoningState(goal=goal, context=context)
152
+ results = []
153
+
154
+ while state.loop_count < self.MAX_LOOPS:
155
+ decision = await self.decide(state)
156
+
157
+ if on_step:
158
+ await on_step({
159
+ "loop": state.loop_count,
160
+ "action": decision.action,
161
+ "reason": decision.reason,
162
+ "confidence": decision.confidence
163
+ })
164
+
165
+ if decision.action == "stop":
166
+ break
167
+
168
+ elif decision.action == "analyze":
169
+ state.world_model = await self.analyze_project(context or goal)
170
+ results.append({"action": "analyze", "output": "World model built"})
171
+
172
+ elif decision.action == "strategy":
173
+ state.strategy = await self.develop_strategy(state)
174
+ results.append({"action": "strategy", "output": state.strategy})
175
+
176
+ elif decision.action == "plan" and self.planner:
177
+ plan = await self.planner.create_plan(goal, context=state.strategy)
178
+ state.completed_steps.append("Piano creato")
179
+ state.last_result = "Piano generato"
180
+ results.append({"action": "plan", "result": plan})
181
+
182
+ elif decision.action == "fix":
183
+ if decision.patch:
184
+ # Se c'è una patch, l'executor la applica
185
+ if self.executor:
186
+ res = await self.executor.run_tool("file_editor", {"path": "patch.diff", "content": decision.patch})
187
+ state.last_result = str(res.get("output", ""))
188
+ state.errors = []
189
+ results.append({"action": "fix", "patch": "Applicata"})
190
+ else:
191
+ error_analysis = await self.analyze_error(str(state.errors))
192
+ state.last_result = error_analysis
193
+ results.append({"action": "error_analysis", "output": error_analysis})
194
+
195
+ elif decision.action == "continue":
196
+ if self.executor and decision.steps:
197
+ res = await self.executor.run_tool("direct_response", {"input": decision.steps[0]})
198
+ state.last_result = str(res.get("output", ""))
199
+ state.completed_steps.append(decision.steps[0])
200
+ results.append({"action": "continue", "steps": decision.steps})
201
+
202
+ # Auto-debug check con Critic
203
+ if self.critic and state.last_result and decision.action != "analyze":
204
+ critique = await self.critic.evaluate(goal, state.last_result)
205
+ if critique.get("needs_retry"):
206
+ state.errors.extend(critique.get("issues", []))
207
+
208
+ state.loop_count += 1
209
+
210
+ return {
211
+ "goal": goal,
212
+ "loops": state.loop_count,
213
+ "success": len(state.errors) == 0,
214
+ "results": results,
215
+ "final_state": {
216
+ "has_world_model": state.world_model is not None,
217
+ "has_strategy": state.strategy is not None
218
+ }
219
+ }
agents/unified_loop.py ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ unified_loop.py — Unified Agent Loop
3
+
4
+ Loop agente unico per backend cloud/HF Spaces. Usa smolagents quando installato e
5
+ configurato; in caso contrario mantiene un fallback deterministico su Planner,
6
+ ReasoningCore, Executor e Critic già presenti nel backend.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import os
12
+ from dataclasses import dataclass, field
13
+ from typing import Any, Awaitable, Callable
14
+
15
+
16
+ StepCallback = Callable[[dict[str, Any]], Awaitable[None] | None]
17
+
18
+
19
+ @dataclass
20
+ class UnifiedLoopState:
21
+ goal: str
22
+ context: str = ""
23
+ max_steps: int = 8
24
+ steps: list[dict[str, Any]] = field(default_factory=list)
25
+ errors: list[str] = field(default_factory=list)
26
+
27
+
28
+ class UnifiedAgentLoop:
29
+ """Smolagents-first loop with safe fallback for environments without it."""
30
+
31
+ def __init__(self, llm_client: Any, planner: Any = None, executor: Any = None, critic: Any = None, memory: Any = None):
32
+ self.llm = llm_client
33
+ self.planner = planner
34
+ self.executor = executor
35
+ self.critic = critic
36
+ self.memory = memory
37
+ self._smol_agent = None
38
+
39
+ def _build_prompt(self, state: UnifiedLoopState) -> str:
40
+ memory_hint = ""
41
+ if self.memory:
42
+ memory_hint = "\nUsa la memoria disponibile per evitare ripetizioni e preservare preferenze utente."
43
+ return f"""Sei un agente operativo unificato.
44
+ Obiettivo: {state.goal}
45
+ Contesto: {state.context or 'nessuno'}
46
+ {memory_hint}
47
+
48
+ Produci una risposta o un piano operativo conciso. Se servono tool, indica passi verificabili.
49
+ """
50
+
51
+ def _load_smol_agent(self) -> Any | None:
52
+ if self._smol_agent is not None:
53
+ return self._smol_agent
54
+ try:
55
+ from smolagents import CodeAgent, LiteLLMModel # type: ignore
56
+
57
+ model_id = os.getenv("SMOLAGENTS_MODEL") or os.getenv("LLM_MODEL") or "gpt-4o-mini"
58
+ api_key = os.getenv("OPENAI_API_KEY") or os.getenv("GROQ_API_KEY") or os.getenv("HF_TOKEN") or os.getenv("HUGGINGFACE_API_KEY")
59
+ if not api_key:
60
+ return None
61
+ model = LiteLLMModel(model_id=model_id, api_key=api_key)
62
+ self._smol_agent = CodeAgent(tools=[], model=model, max_steps=int(os.getenv("UNIFIED_LOOP_MAX_STEPS", "8")))
63
+ return self._smol_agent
64
+ except Exception:
65
+ return None
66
+
67
+ async def _run_smolagents(self, state: UnifiedLoopState, on_step: StepCallback | None) -> dict[str, Any] | None:
68
+ agent = self._load_smol_agent()
69
+ if agent is None:
70
+ return None
71
+
72
+ prompt = self._build_prompt(state)
73
+ if on_step:
74
+ await _maybe_await(on_step({"loop": 0, "action": "smolagents", "status": "started"}))
75
+ try:
76
+ result = await asyncio.to_thread(agent.run, prompt)
77
+ output = str(result)
78
+ state.steps.append({"action": "smolagents", "output": output})
79
+ if self.memory:
80
+ await self.memory.save_episode("unified_loop", state.goal, output[:1000], True, tags=["smolagents"])
81
+ if on_step:
82
+ await _maybe_await(on_step({"loop": 1, "action": "smolagents", "status": "done"}))
83
+ return {"success": True, "engine": "smolagents", "goal": state.goal, "steps": state.steps, "output": output}
84
+ except Exception as exc:
85
+ state.errors.append(str(exc))
86
+ if on_step:
87
+ await _maybe_await(on_step({"loop": 1, "action": "smolagents", "status": "error", "error": str(exc)}))
88
+ return None
89
+
90
+ async def _run_fallback(self, state: UnifiedLoopState, on_step: StepCallback | None) -> dict[str, Any]:
91
+ outputs: list[str] = []
92
+
93
+ if self.memory:
94
+ memory_ctx = await self.memory.get_context(state.goal)
95
+ if memory_ctx:
96
+ state.context = f"{state.context}\n\nMEMORIA:\n{memory_ctx}".strip()
97
+
98
+ if self.planner:
99
+ if on_step:
100
+ await _maybe_await(on_step({"loop": 0, "action": "plan", "status": "started"}))
101
+ plan = await self.planner.create_plan(state.goal, context=[{"role": "system", "content": state.context}])
102
+ state.steps.append({"action": "plan", "result": plan})
103
+ outputs.append(str(plan))
104
+
105
+ prompt = self._build_prompt(state)
106
+ if on_step:
107
+ await _maybe_await(on_step({"loop": 1, "action": "llm", "status": "started"}))
108
+ answer = await self.llm.chat([{"role": "user", "content": prompt}], temperature=0.2, max_tokens=2048)
109
+ state.steps.append({"action": "llm", "output": answer})
110
+ outputs.append(answer)
111
+
112
+ if self.critic:
113
+ critique = await self.critic.evaluate(state.goal, answer)
114
+ state.steps.append({"action": "critic", "result": critique})
115
+ if critique.get("needs_retry"):
116
+ state.errors.extend([str(x) for x in critique.get("issues", [])])
117
+
118
+ success = len(state.errors) == 0
119
+ final_output = "\n\n".join(outputs).strip()
120
+ if self.memory:
121
+ await self.memory.save_episode("unified_loop", state.goal, final_output[:1000], success, tags=["fallback"])
122
+ if on_step:
123
+ await _maybe_await(on_step({"loop": 2, "action": "fallback", "status": "done", "success": success}))
124
+ return {"success": success, "engine": "fallback", "goal": state.goal, "steps": state.steps, "errors": state.errors, "output": final_output}
125
+
126
+ async def run(self, goal: str, context: str = "", max_steps: int = 8, on_step: StepCallback | None = None) -> dict[str, Any]:
127
+ state = UnifiedLoopState(goal=goal, context=context, max_steps=max_steps)
128
+ smol_result = await self._run_smolagents(state, on_step)
129
+ if smol_result:
130
+ return smol_result
131
+ return await self._run_fallback(state, on_step)
132
+
133
+
134
+ async def _maybe_await(value: Any) -> None:
135
+ if hasattr(value, "__await__"):
136
+ await value
api/__init__.py ADDED
File without changes
api/coding.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """api/coding.py — Coding agent endpoints"""
2
+ from fastapi import APIRouter
3
+ from pydantic import BaseModel
4
+ from typing import Optional,List
5
+ from agents.code_agent import CodeAgent
6
+
7
+ router=APIRouter(prefix="/code",tags=["coding"])
8
+ _agent=None
9
+ def get_agent()->CodeAgent:
10
+ global _agent
11
+ if _agent is None: from main import ollama; _agent=CodeAgent(ollama)
12
+ return _agent
13
+
14
+ class AnalyzeReq(BaseModel): filepath:str; content:str; goal:str
15
+ class SessionReq(BaseModel): goal:str; files:List[dict]; model:Optional[str]=None
16
+
17
+ @router.post("/analyze")
18
+ async def analyze(req:AnalyzeReq):
19
+ return await get_agent().analyze_file(req.filepath,req.content,req.goal)
20
+
21
+ @router.post("/session")
22
+ async def session(req:SessionReq):
23
+ return await get_agent().full_session(req.goal,req.files)
api/web.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """api/web.py — Web search + fetch endpoints"""
2
+ from fastapi import APIRouter
3
+ from pydantic import BaseModel
4
+ from typing import Optional
5
+ from tools.web_search import web_search
6
+ from tools.web_fetch import fetch_page
7
+ from tools.ranking import rank_results, format_for_llm
8
+ from tools.content_cleaner import clean_and_structure
9
+
10
+ router=APIRouter(prefix="/web",tags=["web"])
11
+
12
+ class SearchReq(BaseModel):
13
+ query:str; focus:Optional[str]="general"; max_results:Optional[int]=6; format:Optional[str]="json"
14
+
15
+ class FetchReq(BaseModel):
16
+ url:str; query:Optional[str]=None; max_chars:Optional[int]=5000
17
+
18
+ @router.post("/search")
19
+ async def search(req:SearchReq):
20
+ raw=await web_search(req.query,req.focus or "general",req.max_results or 6)
21
+ ranked=rank_results(raw.get("results",[]),req.query,top_k=req.max_results or 6)
22
+ if req.format=="text": return {"text":format_for_llm(ranked,req.query),"query":req.query}
23
+ return {**raw,"results":ranked}
24
+
25
+ @router.post("/fetch")
26
+ async def fetch(req:FetchReq):
27
+ raw=await fetch_page(req.url,max_chars=req.max_chars or 5000)
28
+ if req.query and raw.get("content"):
29
+ cleaned=clean_and_structure(raw["content"],req.query)
30
+ raw["content"]=cleaned["content"]; raw["words"]=cleaned["words"]
31
+ return raw
architecture.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ architecture.py — Free distributed no-PC architecture profile
3
+
4
+ The selected zero-cost architecture is deliberately split into control,
5
+ execution, tools and memory layers:
6
+
7
+ - iPhone/mobile browser: control surface only.
8
+ - GitHub Actions: event-triggered Agent Kernel and lightweight execution.
9
+ - Hugging Face Spaces: backend tools/API runtime, not a heavy persistent VM.
10
+ - LiteLLM/OpenAI-compatible provider routing: free remote model pool.
11
+ - GitHub repository: primary memory, code, task history and run reports.
12
+
13
+ This keeps the system usable without Replit, without a local PC and without
14
+ paid infrastructure while making the real limits explicit.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ from dataclasses import dataclass, asdict
20
+ from typing import Any
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class Capability:
25
+ name: str
26
+ status: str
27
+ reason: str
28
+
29
+
30
+ FREE_REMOTE_PLAN = {
31
+ "name": "github_actions_hf_spaces_free_agent",
32
+ "label": "GitHub Actions + HF Spaces Free Agent Kernel",
33
+ "mode": "total_free_no_local_pc",
34
+ "control_surface": "iPhone via GitHub Actions workflow_dispatch or web frontend",
35
+ "primary_runtime": "GitHub Actions for agent runs; Hugging Face Spaces for backend tools/API",
36
+ "tool_runtime": "Hugging Face Spaces Docker free tier",
37
+ "memory_primary": "GitHub repository under .agent/ plus code/history",
38
+ "memory_secondary": "SQLite/local backend memory when the Space is available",
39
+ "principle": "Distributed backend-first agent kernel with safe-by-default GitHub memory, provider fallback and mobile review.",
40
+ "model_priority": ["openrouter", "gemini", "groq", "huggingface", "openai_compatible"],
41
+ "execution": "GitHub Actions for bounded jobs; backend allowlisted tools only; browser sandbox disabled by default",
42
+ }
43
+
44
+
45
+ def _env_enabled(name: str, default: bool = False) -> bool:
46
+ raw = os.getenv(name)
47
+ if raw is None:
48
+ return default
49
+ return raw.strip().lower() in {"1", "true", "yes", "on"}
50
+
51
+
52
+ def get_architecture_profile() -> dict[str, Any]:
53
+ """Return runtime capabilities for the free no-PC deployment plan."""
54
+ providers = {
55
+ "openrouter": bool(os.getenv("OPENROUTER_API_KEY")),
56
+ "gemini": bool(os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY")),
57
+ "groq": bool(os.getenv("GROQ_API_KEY")),
58
+ "huggingface": bool(os.getenv("HF_TOKEN") or os.getenv("HUGGINGFACE_API_KEY") or os.getenv("HUGGINGFACE_TOKEN")),
59
+ "openai_compatible": bool(os.getenv("OPENAI_API_KEY")),
60
+ }
61
+
62
+ github_actions_configured = bool(os.getenv("GITHUB_ACTIONS") or os.getenv("GITHUB_REPOSITORY"))
63
+ github_token_configured = bool(os.getenv("GITHUB_TOKEN") or os.getenv("GH_TOKEN"))
64
+
65
+ capabilities = [
66
+ Capability("mobile_control", "enabled", "GitHub Actions workflow_dispatch and the frontend can be used from iPhone."),
67
+ Capability("github_actions_agent_kernel", "enabled", "Bounded agent runs execute in GitHub Actions and persist reports under .agent/."),
68
+ Capability("github_memory", "enabled", "State, task history and run reports are committed to the repository for durable zero-cost memory."),
69
+ Capability("hf_spaces_tools", "enabled", "The FastAPI backend remains the lightweight tools/API runtime for the free Space."),
70
+ Capability("litellm_routing", "enabled", "OpenAI-compatible provider order supports OpenRouter, Gemini, Groq, Hugging Face and optional OpenAI-compatible endpoints."),
71
+ Capability("sqlite_memory", "secondary", "Backend SQLite/local memory remains useful but is not the primary no-PC source of truth."),
72
+ Capability("qdrant", "optional", "Use only if an external free Qdrant endpoint is configured; not required for the baseline."),
73
+ Capability("openhands", "external_optional", "Full OpenHands remains too heavy for the free baseline and should not run inside the free Space."),
74
+ Capability("docker_sandbox", "disabled_on_free_space", "Nested Docker is intentionally disabled on the free plan."),
75
+ Capability("browser_sandbox", "disabled_by_default", "Client-side code execution is not a real isolated workspace and is not part of the main path."),
76
+ ]
77
+
78
+ return {
79
+ "plan": FREE_REMOTE_PLAN,
80
+ "providers": providers,
81
+ "capabilities": [asdict(c) for c in capabilities],
82
+ "limits": {
83
+ "requires_local_pc": False,
84
+ "requires_paid_vm": False,
85
+ "runs_heavy_local_models": False,
86
+ "supports_nested_docker": False,
87
+ "long_running_24_7_agent": False,
88
+ "bounded_actions_runtime": True,
89
+ },
90
+ "github_actions": {
91
+ "workflow": ".github/workflows/agent-kernel.yml",
92
+ "workflow_template": "docs/workflows/agent-kernel.yml",
93
+ "activation_note": "Copy the template to .github/workflows/agent-kernel.yml with GitHub UI or a token that has workflow permission.",
94
+ "script": "scripts/agent_kernel.py",
95
+ "memory_dir": ".agent/",
96
+ "configured_in_current_runtime": github_actions_configured,
97
+ "token_available_in_current_runtime": github_token_configured,
98
+ "trigger_modes": ["workflow_dispatch", "issue_label_agent-run"],
99
+ },
100
+ "feature_flags": {
101
+ "browser_sandbox_enabled": _env_enabled("VITE_ENABLE_BROWSER_SANDBOX", False),
102
+ "browser_llm_enabled": _env_enabled("VITE_ENABLE_BROWSER_LLM", False),
103
+ "qdrant_enabled": bool(os.getenv("QDRANT_URL")),
104
+ },
105
+ }
components.json ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema.json",
3
+ "style": "new-york",
4
+ "rsc": false,
5
+ "tsx": true,
6
+ "tailwind": {
7
+ "config": "",
8
+ "css": "src/index.css",
9
+ "baseColor": "neutral",
10
+ "cssVariables": true,
11
+ "prefix": ""
12
+ },
13
+ "aliases": {
14
+ "components": "@/components",
15
+ "utils": "@/lib/utils",
16
+ "ui": "@/components/ui",
17
+ "lib": "@/lib",
18
+ "hooks": "@/hooks"
19
+ }
20
+ }
index.html ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="it">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1" />
6
+ <title>Agente AI Pro</title>
7
+ <meta name="description" content="Agente AI — assistente intelligente con strumenti autonomi: ricerca web, meteo, notizie, codice, memoria e molto altro." />
8
+ <meta name="theme-color" content="#0f0f1a" />
9
+ <meta property="og:title" content="Agente AI Pro" />
10
+ <meta property="og:description" content="Assistente AI autonomo con 14 strumenti integrati" />
11
+ <meta property="og:type" content="website" />
12
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
13
+ <meta name="description" content="Agente AI — assistente autonomo con web search, memoria, esecuzione codice e più provider AI." />
14
+ <meta name="theme-color" content="#0f0f1a" />
15
+ <meta property="og:title" content="Agente AI" />
16
+ <meta property="og:description" content="AI agent autonomo con web search, memoria e multi-AI Pro Mode." />
17
+ <meta property="og:type" content="website" />
18
+ <meta name="twitter:card" content="summary" />
19
+ <meta name="twitter:title" content="Agente AI" />
20
+ <meta name="apple-mobile-web-app-capable" content="yes" />
21
+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
22
+ <link rel="preconnect" href="https://fonts.googleapis.com">
23
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
24
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
25
+
26
+ <!--
27
+ Register the COI Service Worker so that SharedArrayBuffer is available
28
+ on GitHub Pages (which doesn't allow custom COOP/COEP headers).
29
+ Required for WebLLM / WebGPU to work in-browser.
30
+ -->
31
+ <script>
32
+ if ("serviceWorker" in navigator) {
33
+ // Handle both root domain and subfolder (GitHub Pages)
34
+ const isGH = window.location.hostname.includes("github.io");
35
+ const pathParts = window.location.pathname.split("/").filter(Boolean);
36
+ const base = (isGH && pathParts.length > 0) ? "/" + pathParts[0] : "";
37
+
38
+
39
+ navigator.serviceWorker
40
+ .register(swPath)
41
+ .then(function (reg) {
42
+ // If the page is not yet cross-origin-isolated, reload once
43
+ // so the service worker can add the necessary headers.
44
+ if (!window.crossOriginIsolated) {
45
+ if (sessionStorage.getItem("__coi_reloaded__") === "1") {
46
+ console.warn("[COI] Already reloaded once; giving up to avoid loop. WebLLM disabled.");
47
+ } else {
48
+ sessionStorage.setItem("__coi_reloaded__", "1");
49
+ console.log("[COI] Reloading for cross-origin isolation…");
50
+ window.location.reload();
51
+ }
52
+ } else {
53
+ sessionStorage.removeItem("__coi_reloaded__");
54
+ console.log("[COI] crossOriginIsolated =", window.crossOriginIsolated);
55
+ }
56
+ })
57
+ .catch(function (err) {
58
+ console.warn("[COI] Service worker registration failed:", err);
59
+ });
60
+ }
61
+ </script>
62
+ </head>
63
+ <body>
64
+ <div id="root"></div>
65
+ <script type="module" src="/src/main.tsx"></script>
66
+ </body>
67
+ </html>
main.py CHANGED
@@ -1,20 +1,490 @@
1
- from fastapi import FastAPI
 
 
 
2
  from fastapi.middleware.cors import CORSMiddleware
3
- import os
 
4
 
5
- app = FastAPI(title="Baida AI Backend", version="1.0.0")
6
 
 
7
  app.add_middleware(
8
  CORSMiddleware,
9
- allow_origins=["*"],
10
- allow_methods=["*"],
11
- allow_headers=["*"],
12
  )
13
 
14
- @app.get("/health")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
  async def health():
16
  return {
17
- "status": "ok",
18
- "supabase": bool(os.getenv("SUPABASE_URL")),
19
- "llm": bool(os.getenv("OPENROUTER_API_KEY") or os.getenv("GROQ_API_KEY")),
 
20
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os, sys, json, asyncio, subprocess, tempfile, shlex, time, uuid
2
+ from typing import Optional, Any
3
+ from fastapi import FastAPI, HTTPException, Body
4
+ from fastapi.staticfiles import StaticFiles
5
  from fastapi.middleware.cors import CORSMiddleware
6
+ from fastapi.responses import JSONResponse
7
+ from pydantic import BaseModel
8
 
9
+ print('BOOT: importing FastAPI...', flush=True)
10
 
11
+ app = FastAPI(title='Agente AI', version='3.1.0')
12
  app.add_middleware(
13
  CORSMiddleware,
14
+ allow_origins=['*'],
15
+ allow_methods=['*'],
16
+ allow_headers=['*'],
17
  )
18
 
19
+ # ── Supabase (optional) ───────────────────────────────────────────────────────
20
+ _sb = None
21
+ SUPABASE_URL = os.getenv('SUPABASE_URL', '')
22
+ SUPABASE_KEY = os.getenv('SUPABASE_ANON_KEY') or os.getenv('SUPABASE_KEY', '')
23
+
24
+ if SUPABASE_URL and SUPABASE_KEY:
25
+ try:
26
+ from supabase import create_client
27
+ _sb = create_client(SUPABASE_URL, SUPABASE_KEY)
28
+ print('BOOT: Supabase connected ✓', flush=True)
29
+ except Exception as e:
30
+ print(f'BOOT: Supabase init failed: {e}', flush=True)
31
+ else:
32
+ print('BOOT: Supabase not configured (SUPABASE_URL / SUPABASE_KEY missing)', flush=True)
33
+
34
+ def sb():
35
+ if not _sb:
36
+ raise HTTPException(503, detail={
37
+ 'error': 'supabase_not_configured',
38
+ 'message': 'Imposta SUPABASE_URL e SUPABASE_KEY nelle variabili Railway/HuggingFace per abilitare la persistenza.',
39
+ })
40
+ return _sb
41
+
42
+ # ── Sensitive keys mask ───────────────────────────────────────────────────────
43
+ SENSITIVE = {
44
+ 'OPENROUTER_API_KEY','OPENAI_API_KEY','GEMINI_API_KEY','GROQ_API_KEY',
45
+ 'HF_TOKEN','HUGGINGFACE_API_KEY','GH_TOKEN','GITHUB_TOKEN',
46
+ 'QDRANT_API_KEY','DATABASE_URL','SESSION_SECRET','SECRET_KEY',
47
+ 'RAILWAY_TOKEN','SUPABASE_KEY','SUPABASE_ANON_KEY',
48
+ }
49
+
50
+ # ── Pydantic models ───────────────────────────────────────────────────────────
51
+ class ConversationIn(BaseModel):
52
+ id: str
53
+ title: str = 'Nuova conversazione'
54
+ created_at: int
55
+ updated_at: int
56
+
57
+ class MessageIn(BaseModel):
58
+ id: str
59
+ conversation_id: str
60
+ role: str
61
+ content: str
62
+ created_at: int
63
+ error: Optional[bool] = False
64
+ steps: Optional[Any] = None
65
+ agent_status: Optional[str] = None
66
+
67
+ class ShellCmd(BaseModel):
68
+ command: str
69
+ timeout: int = 15
70
+
71
+ class PipInstall(BaseModel):
72
+ packages: list[str]
73
+
74
+ class MemoryEntry(BaseModel):
75
+ key: str
76
+ value: str
77
+ category: str = 'general'
78
+ createdAt: int = 0
79
+ updatedAt: int = 0
80
+
81
+ class ReasonLoopIn(BaseModel):
82
+ goal: str
83
+ context: list[dict] = []
84
+ max_steps: int = 8
85
+
86
+ # ── In-memory fallback for agent memory (when Supabase not available) ─────────
87
+ _mem_fallback: dict[str, dict] = {}
88
+
89
+ # ─────────────────────────────────────────────────────────────────────────────
90
+ # HEALTH / STATUS
91
+ # ─────────────────────────────────────────────────────────────────────────────
92
+
93
+ @app.get('/health')
94
  async def health():
95
  return {
96
+ 'status': 'ok',
97
+ 'version': '3.1.0',
98
+ 'supabase': _sb is not None,
99
+ 'backend': 'HuggingFace Spaces / Railway',
100
  }
101
+
102
+ @app.get('/api/status')
103
+ async def status():
104
+ safe_env = {k: '***' if k in SENSITIVE else v for k, v in os.environ.items()}
105
+ return {'status': 'running', 'env': safe_env, 'supabase': _sb is not None}
106
+
107
+ # ─────────────────────────────────────────────────────────────────────────────
108
+ # CONVERSATIONS
109
+ # ─────────────────────────────────────────────────────────────────────────────
110
+
111
+ @app.get('/api/conversations')
112
+ async def list_conversations():
113
+ data = sb().table('conversations').select('*').order('updated_at', desc=True).execute()
114
+ return {'conversations': data.data}
115
+
116
+ @app.post('/api/conversations')
117
+ async def upsert_conversation(conv: ConversationIn):
118
+ data = sb().table('conversations').upsert(conv.model_dump()).execute()
119
+ return {'conversation': data.data[0] if data.data else conv.model_dump()}
120
+
121
+ @app.put('/api/conversations/{conv_id}')
122
+ async def update_conversation(conv_id: str, body: dict = Body(...)):
123
+ body['id'] = conv_id
124
+ data = sb().table('conversations').upsert(body).execute()
125
+ return {'conversation': data.data[0] if data.data else body}
126
+
127
+ @app.delete('/api/conversations/{conv_id}')
128
+ async def delete_conversation(conv_id: str):
129
+ sb().table('messages').delete().eq('conversation_id', conv_id).execute()
130
+ sb().table('conversations').delete().eq('id', conv_id).execute()
131
+ return {'deleted': conv_id}
132
+
133
+ # ─────────────────────────────────────────────────────────────────────────────
134
+ # MESSAGES
135
+ # ─────────────────────────────────────────────────────────────────────────────
136
+
137
+ @app.get('/api/conversations/{conv_id}/messages')
138
+ async def list_messages(conv_id: str):
139
+ data = sb().table('messages').select('*').eq('conversation_id', conv_id).order('created_at').execute()
140
+ return {'messages': data.data}
141
+
142
+ @app.post('/api/conversations/{conv_id}/messages')
143
+ async def upsert_messages(conv_id: str, body: dict = Body(...)):
144
+ msgs = body.get('messages', [])
145
+ if not msgs:
146
+ return {'upserted': 0}
147
+ for m in msgs:
148
+ m['conversation_id'] = conv_id
149
+ if 'steps' in m and m['steps'] is not None:
150
+ m['steps'] = json.dumps(m['steps']) if not isinstance(m['steps'], str) else m['steps']
151
+ data = sb().table('messages').upsert(msgs).execute()
152
+ return {'upserted': len(data.data)}
153
+
154
+ @app.delete('/api/conversations/{conv_id}/messages')
155
+ async def clear_messages(conv_id: str):
156
+ sb().table('messages').delete().eq('conversation_id', conv_id).execute()
157
+ return {'cleared': conv_id}
158
+
159
+ # ─────────────────────────────────────────────────────────────────────────────
160
+ # AGENT MEMORY — persistente su Supabase, fallback in-memory
161
+ # ─────────────────────────────────────────────────────────────────────────────
162
+
163
+ @app.get('/api/memory/agent')
164
+ async def list_agent_memory():
165
+ if _sb:
166
+ try:
167
+ data = _sb.table('agent_memory').select('*').order('updated_at', desc=True).execute()
168
+ entries = [
169
+ {'key': r['key'], 'value': r['value'], 'category': r.get('category', 'general'),
170
+ 'createdAt': r.get('created_at', 0), 'updatedAt': r.get('updated_at', 0)}
171
+ for r in (data.data or [])
172
+ ]
173
+ return {'entries': entries}
174
+ except Exception as e:
175
+ print(f'[memory] Supabase list error: {e}', flush=True)
176
+ # Fallback in-memory
177
+ return {'entries': list(_mem_fallback.values())}
178
+
179
+ @app.get('/api/memory/agent/{key}')
180
+ async def get_agent_memory(key: str):
181
+ if _sb:
182
+ try:
183
+ data = _sb.table('agent_memory').select('*').eq('key', key).limit(1).execute()
184
+ if data.data:
185
+ return {'value': data.data[0]['value']}
186
+ except Exception as e:
187
+ print(f'[memory] Supabase get error: {e}', flush=True)
188
+ # Fallback in-memory
189
+ entry = _mem_fallback.get(key)
190
+ return {'value': entry['value'] if entry else None}
191
+
192
+ @app.post('/api/memory/agent')
193
+ async def set_agent_memory(entry: MemoryEntry):
194
+ now = int(time.time() * 1000)
195
+ row = {
196
+ 'key': entry.key,
197
+ 'value': entry.value,
198
+ 'category': entry.category,
199
+ 'created_at': entry.createdAt or now,
200
+ 'updated_at': entry.updatedAt or now,
201
+ }
202
+ if _sb:
203
+ try:
204
+ _sb.table('agent_memory').upsert(row).execute()
205
+ except Exception as e:
206
+ print(f'[memory] Supabase set error: {e}', flush=True)
207
+ # Always update in-memory fallback
208
+ _mem_fallback[entry.key] = {
209
+ 'key': entry.key, 'value': entry.value, 'category': entry.category,
210
+ 'createdAt': row['created_at'], 'updatedAt': row['updated_at'],
211
+ }
212
+ return {'ok': True, 'key': entry.key}
213
+
214
+ @app.delete('/api/memory/agent/{key}')
215
+ async def delete_agent_memory(key: str):
216
+ if _sb:
217
+ try:
218
+ _sb.table('agent_memory').delete().eq('key', key).execute()
219
+ except Exception:
220
+ pass
221
+ _mem_fallback.pop(key, None)
222
+ return {'deleted': key}
223
+
224
+ # ─────────────────────────────────────────────────────────────────────────────
225
+ # VIRTUAL FILE SYSTEM
226
+ # ─────────────────────────────────────────────────────────────────────────────
227
+
228
+ @app.get('/api/files')
229
+ async def list_files(conversation_id: Optional[str] = None):
230
+ q = sb().table('vfs_files').select('id, path, language, conversation_id, updated_at')
231
+ if conversation_id:
232
+ q = q.eq('conversation_id', conversation_id)
233
+ data = q.order('path').execute()
234
+ return {'files': data.data}
235
+
236
+ @app.get('/api/files/{file_id}')
237
+ async def get_file(file_id: str):
238
+ data = sb().table('vfs_files').select('*').eq('id', file_id).single().execute()
239
+ return {'file': data.data}
240
+
241
+ @app.post('/api/files')
242
+ async def save_file(body: dict = Body(...)):
243
+ if 'id' not in body:
244
+ body['id'] = str(uuid.uuid4())
245
+ if 'updated_at' not in body:
246
+ body['updated_at'] = int(time.time() * 1000)
247
+ if 'created_at' not in body:
248
+ body['created_at'] = body['updated_at']
249
+ data = sb().table('vfs_files').upsert(body).execute()
250
+ return {'file': data.data[0] if data.data else body}
251
+
252
+ @app.delete('/api/files/{file_id}')
253
+ async def delete_file(file_id: str):
254
+ sb().table('vfs_files').delete().eq('id', file_id).execute()
255
+ return {'deleted': file_id}
256
+
257
+ # ─────────────────────────────────────────────────────────────────────────────
258
+ # REASONING LOOP — delega al backend unificato per task complessi
259
+ # ─────────────────────────────────────────────────────────────────────────────
260
+
261
+ @app.post('/api/reason/loop')
262
+ async def reason_loop(body: ReasonLoopIn):
263
+ """
264
+ Loop di ragionamento backend per task complessi.
265
+ Usa UnifiedAgentLoop con smolagents se disponibile, altrimenti fallback deterministico.
266
+ """
267
+ try:
268
+ from agents.unified_loop import UnifiedAgentLoop
269
+ from models.ai_client import AIClient
270
+ client = AIClient()
271
+ loop = UnifiedAgentLoop(llm_client=client)
272
+ result = await loop.run(goal=body.goal, context=body.context, max_steps=body.max_steps)
273
+ return {'ok': True, 'result': result, 'source': 'backend_loop'}
274
+ except Exception as e:
275
+ print(f'[reason/loop] Error: {e}', flush=True)
276
+ return {
277
+ 'ok': False,
278
+ 'result': f'Backend reasoning non disponibile: {e}. Il loop browser continua normalmente.',
279
+ 'source': 'fallback',
280
+ }
281
+
282
+ # ─────────────────────────────────────────────────────────────────────────────
283
+ # AGENT KERNEL — dispatch GitHub Actions da iPhone
284
+ # ─────────────────────────────────────────────────────────────────────────────
285
+
286
+ @app.get('/api/agent-kernel/status')
287
+ async def agent_kernel_status():
288
+ gh_token = os.getenv('GITHUB_TOKEN') or os.getenv('GH_TOKEN', '')
289
+ return {
290
+ 'dispatch_available': bool(gh_token),
291
+ 'workflow_url': 'https://github.com/Baida98/AI/actions/workflows/agent-kernel.yml',
292
+ 'mobile_url': 'https://github.com/Baida98/AI/actions',
293
+ 'secrets_needed': ['OPENROUTER_API_KEY', 'GROQ_API_KEY', 'GEMINI_API_KEY', 'HF_TOKEN'],
294
+ 'usage': 'Vai su GitHub Actions → Agent Kernel — no PC → Run workflow → inserisci il goal',
295
+ }
296
+
297
+ @app.post('/api/agent-kernel/dispatch')
298
+ async def agent_kernel_dispatch(body: dict = Body(...)):
299
+ """Avvia GitHub Actions workflow dal backend (richiede GITHUB_TOKEN o GH_TOKEN nel backend)."""
300
+ gh_token = os.getenv('GITHUB_TOKEN') or os.getenv('GH_TOKEN', '')
301
+ if not gh_token:
302
+ raise HTTPException(503, detail={
303
+ 'error': 'no_github_token',
304
+ 'message': 'GITHUB_TOKEN non configurato nel backend. Usa GitHub Actions direttamente da mobile.',
305
+ })
306
+ goal = body.get('goal', '').strip()
307
+ mode = body.get('mode', 'plan')
308
+ if not goal:
309
+ raise HTTPException(400, detail='goal è obbligatorio')
310
+
311
+ import urllib.request, urllib.error
312
+ payload = json.dumps({
313
+ 'ref': 'main',
314
+ 'inputs': {'goal': goal, 'mode': mode, 'commit_memory': 'true'},
315
+ }).encode()
316
+ req = urllib.request.Request(
317
+ 'https://api.github.com/repos/Baida98/AI/actions/workflows/agent-kernel.yml/dispatches',
318
+ data=payload,
319
+ headers={
320
+ 'Authorization': f'Bearer {gh_token}',
321
+ 'Accept': 'application/vnd.github+json',
322
+ 'Content-Type': 'application/json',
323
+ 'X-GitHub-Api-Version': '2022-11-28',
324
+ },
325
+ method='POST',
326
+ )
327
+ try:
328
+ with urllib.request.urlopen(req, timeout=15) as resp:
329
+ return {'ok': True, 'status': resp.status, 'goal': goal, 'mode': mode}
330
+ except urllib.error.HTTPError as e:
331
+ raise HTTPException(e.code, detail=e.read().decode()[:500])
332
+
333
+ # ─────────────────────────────────────────────────────────────────────────────
334
+ # SHELL EXECUTION (sandboxed)
335
+ # ─────────────────────────────────────────────────────────────────────────────
336
+ BLOCKED_CMDS = {'rm -rf /', 'mkfs', ':(){:|:&};:', 'dd if=/dev/zero'}
337
+
338
+ @app.post('/api/execute-shell')
339
+ async def execute_shell(cmd: ShellCmd):
340
+ raw = cmd.command.strip()
341
+ for bad in BLOCKED_CMDS:
342
+ if bad in raw:
343
+ raise HTTPException(400, 'Command blocked for safety')
344
+ timeout = min(max(cmd.timeout, 1), 60)
345
+ with tempfile.TemporaryDirectory() as tmpdir:
346
+ try:
347
+ proc = await asyncio.create_subprocess_shell(
348
+ raw,
349
+ stdout=asyncio.subprocess.PIPE,
350
+ stderr=asyncio.subprocess.PIPE,
351
+ cwd=tmpdir,
352
+ env={**os.environ, 'HOME': tmpdir, 'TMPDIR': tmpdir},
353
+ )
354
+ stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
355
+ return {
356
+ 'stdout': stdout.decode('utf-8', errors='replace')[:8000],
357
+ 'stderr': stderr.decode('utf-8', errors='replace')[:4000],
358
+ 'exit_code': proc.returncode,
359
+ }
360
+ except asyncio.TimeoutError:
361
+ proc.kill()
362
+ return {'stdout': '', 'stderr': f'Timeout dopo {timeout}s', 'exit_code': -1}
363
+ except Exception as e:
364
+ return {'stdout': '', 'stderr': str(e), 'exit_code': -1}
365
+
366
+ # ─────────────────────────────────────────────────────────────────────────────
367
+ # PIP INSTALL
368
+ # ─────────────────────────────────────────────────────────────────────────────
369
+
370
+ @app.post('/api/pip-install')
371
+ async def pip_install(body: PipInstall):
372
+ pkgs = [p.strip() for p in body.packages if p.strip()]
373
+ if not pkgs:
374
+ raise HTTPException(400, 'No packages specified')
375
+ import re
376
+ safe = re.compile(r'^[a-zA-Z0-9_\-\[\]>=<\.]+$')
377
+ for p in pkgs:
378
+ if not safe.match(p):
379
+ raise HTTPException(400, f'Invalid package name: {p}')
380
+ try:
381
+ proc = await asyncio.create_subprocess_exec(
382
+ sys.executable, '-m', 'pip', 'install', '--quiet', '--user', *pkgs,
383
+ stdout=asyncio.subprocess.PIPE,
384
+ stderr=asyncio.subprocess.PIPE,
385
+ )
386
+ stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=120)
387
+ return {
388
+ 'installed': pkgs,
389
+ 'stdout': stdout.decode('utf-8', errors='replace')[:4000],
390
+ 'stderr': stderr.decode('utf-8', errors='replace')[:2000],
391
+ 'exit_code': proc.returncode,
392
+ }
393
+ except asyncio.TimeoutError:
394
+ return {'error': 'Timeout during pip install (120s)', 'exit_code': -1}
395
+ except Exception as e:
396
+ return {'error': str(e), 'exit_code': -1}
397
+
398
+ # ─────────────────────────────────────────────────────────────────────────────
399
+ # SUPABASE SCHEMA — esegui una volta nell'editor SQL di Supabase
400
+ # ─────────────────────────────────────────────────────────────────────────────
401
+ #
402
+ # CREATE TABLE IF NOT EXISTS conversations (
403
+ # id TEXT PRIMARY KEY,
404
+ # user_id TEXT NOT NULL DEFAULT 'default',
405
+ # title TEXT NOT NULL DEFAULT 'Nuova conversazione',
406
+ # created_at BIGINT NOT NULL,
407
+ # updated_at BIGINT NOT NULL
408
+ # );
409
+ #
410
+ # CREATE TABLE IF NOT EXISTS messages (
411
+ # id TEXT PRIMARY KEY,
412
+ # user_id TEXT NOT NULL DEFAULT 'default',
413
+ # conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
414
+ # role TEXT NOT NULL CHECK (role IN ('user','assistant')),
415
+ # content TEXT NOT NULL,
416
+ # created_at BIGINT NOT NULL,
417
+ # error BOOLEAN DEFAULT FALSE,
418
+ # steps JSONB,
419
+ # agent_status TEXT
420
+ # );
421
+ # CREATE INDEX IF NOT EXISTS idx_messages_conv ON messages(conversation_id);
422
+ # CREATE INDEX IF NOT EXISTS idx_conv_user ON conversations(user_id);
423
+ #
424
+ # CREATE TABLE IF NOT EXISTS vfs_files (
425
+ # id TEXT PRIMARY KEY,
426
+ # user_id TEXT NOT NULL DEFAULT 'default',
427
+ # conversation_id TEXT,
428
+ # path TEXT NOT NULL,
429
+ # content TEXT NOT NULL,
430
+ # language TEXT,
431
+ # created_at BIGINT NOT NULL,
432
+ # updated_at BIGINT NOT NULL
433
+ # );
434
+ # CREATE INDEX IF NOT EXISTS idx_vfs_conv ON vfs_files(conversation_id);
435
+ # CREATE INDEX IF NOT EXISTS idx_vfs_user ON vfs_files(user_id);
436
+ #
437
+ # CREATE TABLE IF NOT EXISTS agent_memory (
438
+ # key TEXT PRIMARY KEY,
439
+ # user_id TEXT NOT NULL DEFAULT 'default',
440
+ # value TEXT NOT NULL,
441
+ # category TEXT DEFAULT 'general',
442
+ # created_at BIGINT NOT NULL DEFAULT 0,
443
+ # updated_at BIGINT NOT NULL DEFAULT 0
444
+ # );
445
+ # CREATE INDEX IF NOT EXISTS idx_memory_user ON agent_memory(user_id);
446
+ #
447
+ # CREATE TABLE IF NOT EXISTS episodes (
448
+ # id BIGSERIAL PRIMARY KEY,
449
+ # user_id TEXT NOT NULL DEFAULT 'default',
450
+ # type TEXT NOT NULL,
451
+ # task TEXT NOT NULL,
452
+ # output TEXT,
453
+ # success BOOLEAN DEFAULT TRUE,
454
+ # ts TEXT NOT NULL,
455
+ # tags JSONB DEFAULT '[]'
456
+ # );
457
+ # CREATE INDEX IF NOT EXISTS idx_episodes_type ON episodes(type);
458
+ # CREATE INDEX IF NOT EXISTS idx_episodes_user ON episodes(user_id);
459
+ #
460
+ # CREATE TABLE IF NOT EXISTS semantic_memory (
461
+ # id TEXT PRIMARY KEY,
462
+ # content TEXT NOT NULL,
463
+ # metadata JSONB DEFAULT '{}'
464
+ # );
465
+ #
466
+ # -- Row Level Security (open per ora — nessun login richiesto)
467
+ # ALTER TABLE conversations ENABLE ROW LEVEL SECURITY;
468
+ # ALTER TABLE messages ENABLE ROW LEVEL SECURITY;
469
+ # ALTER TABLE vfs_files ENABLE ROW LEVEL SECURITY;
470
+ # ALTER TABLE agent_memory ENABLE ROW LEVEL SECURITY;
471
+ # ALTER TABLE episodes ENABLE ROW LEVEL SECURITY;
472
+ # ALTER TABLE semantic_memory ENABLE ROW LEVEL SECURITY;
473
+ # CREATE POLICY "public_all" ON conversations FOR ALL USING (true) WITH CHECK (true);
474
+ # CREATE POLICY "public_all" ON messages FOR ALL USING (true) WITH CHECK (true);
475
+ # CREATE POLICY "public_all" ON vfs_files FOR ALL USING (true) WITH CHECK (true);
476
+ # CREATE POLICY "public_all" ON agent_memory FOR ALL USING (true) WITH CHECK (true);
477
+ # CREATE POLICY "public_all" ON episodes FOR ALL USING (true) WITH CHECK (true);
478
+ # CREATE POLICY "public_all" ON semantic_memory FOR ALL USING (true) WITH CHECK (true);
479
+
480
+ # ─────────────────────────────────────────────────────────────────────────────
481
+ # FRONTEND STATIC
482
+ # ─────────────────────────────────────────────────────────────────────────────
483
+ STATIC_DIR = os.getenv('FRONTEND_DIST', '/app/backend/static')
484
+ if os.path.isdir(STATIC_DIR):
485
+ app.mount('/', StaticFiles(directory=STATIC_DIR, html=True), name='spa')
486
+ print(f'BOOT: serving frontend from {STATIC_DIR}', flush=True)
487
+ else:
488
+ print(f'BOOT: no frontend at {STATIC_DIR}', flush=True)
489
+
490
+ print('BOOT: main.py v3.1.0 ready ✓', flush=True)
memory/__init__.py ADDED
File without changes
memory/episodic.py ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ episodic.py — Episodic Memory
3
+ Task completati, errori, fix — persiste su Supabase se disponibile, altrimenti SQLite locale.
4
+ Backend server: HuggingFace Spaces (FastAPI). Database: Supabase.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json, os, sqlite3
9
+ from datetime import datetime
10
+ from pathlib import Path
11
+ from dataclasses import dataclass
12
+
13
+ DB_PATH = Path("episodic_memory.db")
14
+
15
+
16
+ @dataclass
17
+ class Episode:
18
+ id: int
19
+ type: str
20
+ task: str
21
+ output: str
22
+ success: bool
23
+ ts: str
24
+ tags: list[str]
25
+
26
+
27
+ class EpisodicMemory:
28
+ def __init__(self):
29
+ self._db: sqlite3.Connection | None = None
30
+ self._sb = None
31
+
32
+ def _try_supabase(self):
33
+ url = os.getenv("SUPABASE_URL", "")
34
+ key = os.getenv("SUPABASE_ANON_KEY") or os.getenv("SUPABASE_KEY", "")
35
+ if not url or not key:
36
+ return None
37
+ try:
38
+ from supabase import create_client
39
+ return create_client(url, key)
40
+ except Exception as e:
41
+ print(f"EpisodicMemory: Supabase non disponibile: {e}", flush=True)
42
+ return None
43
+
44
+ def init(self):
45
+ self._sb = self._try_supabase()
46
+ if self._sb:
47
+ try:
48
+ self._sb.table("episodes").select("id").limit(1).execute()
49
+ print("EpisodicMemory: Supabase ✓", flush=True)
50
+ return
51
+ except Exception as e:
52
+ print(f"EpisodicMemory: Supabase table check fallita: {e}", flush=True)
53
+ self._sb = None
54
+
55
+ # Fallback SQLite locale (dev / HF Spaces senza Supabase configurato)
56
+ self._db = sqlite3.connect(DB_PATH, check_same_thread=False)
57
+ self._db.execute("""
58
+ CREATE TABLE IF NOT EXISTS episodes (
59
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
60
+ type TEXT NOT NULL,
61
+ task TEXT NOT NULL,
62
+ output TEXT,
63
+ success INTEGER DEFAULT 1,
64
+ ts TEXT NOT NULL,
65
+ tags TEXT DEFAULT '[]'
66
+ )
67
+ """)
68
+ self._db.execute("CREATE INDEX IF NOT EXISTS idx_type ON episodes(type)")
69
+ self._db.execute("CREATE INDEX IF NOT EXISTS idx_ts ON episodes(ts)")
70
+ self._db.commit()
71
+ print("EpisodicMemory: SQLite locale (fallback) ✓", flush=True)
72
+
73
+ # ── Write ─────────────────────────────────────────────────────────────────
74
+
75
+ def add(self, type_: str, task: str, output: str, success: bool,
76
+ tags: list[str] | None = None) -> int:
77
+ ts = datetime.now().isoformat()
78
+ row = {
79
+ "type": type_, "task": task[:500], "output": output[:2000],
80
+ "success": success, "ts": ts, "tags": json.dumps(tags or []),
81
+ }
82
+ if self._sb:
83
+ try:
84
+ res = self._sb.table("episodes").insert(row).execute()
85
+ return res.data[0].get("id", -1) if res.data else -1
86
+ except Exception:
87
+ pass
88
+ if not self._db:
89
+ return -1
90
+ cur = self._db.execute(
91
+ "INSERT INTO episodes (type,task,output,success,ts,tags) VALUES (?,?,?,?,?,?)",
92
+ (type_, task[:500], output[:2000], int(success), ts, json.dumps(tags or []))
93
+ )
94
+ self._db.commit()
95
+ return cur.lastrowid
96
+
97
+ # ── Read ──────────────────────────────────────────────────────────────────
98
+
99
+ def get_recent(self, n: int = 20, type_: str | None = None) -> list[Episode]:
100
+ if self._sb:
101
+ try:
102
+ q = self._sb.table("episodes").select("*").order("id", desc=True).limit(n)
103
+ if type_:
104
+ q = q.eq("type", type_)
105
+ rows = q.execute().data or []
106
+ return [self._from_sb(r) for r in rows]
107
+ except Exception:
108
+ pass
109
+ if not self._db:
110
+ return []
111
+ if type_:
112
+ rows = self._db.execute(
113
+ "SELECT * FROM episodes WHERE type=? ORDER BY id DESC LIMIT ?", (type_, n)
114
+ ).fetchall()
115
+ else:
116
+ rows = self._db.execute(
117
+ "SELECT * FROM episodes ORDER BY id DESC LIMIT ?", (n,)
118
+ ).fetchall()
119
+ return [Episode(r[0], r[1], r[2], r[3], bool(r[4]), r[5], json.loads(r[6])) for r in rows]
120
+
121
+ def search_text(self, query: str, n: int = 5) -> list[Episode]:
122
+ if self._sb:
123
+ try:
124
+ rows = self._sb.table("episodes").select("*").ilike("task", f"%{query}%").limit(n).execute().data or []
125
+ if not rows:
126
+ rows = self._sb.table("episodes").select("*").ilike("output", f"%{query}%").limit(n).execute().data or []
127
+ return [self._from_sb(r) for r in rows]
128
+ except Exception:
129
+ pass
130
+ if not self._db:
131
+ return []
132
+ rows = self._db.execute(
133
+ "SELECT * FROM episodes WHERE task LIKE ? OR output LIKE ? ORDER BY id DESC LIMIT ?",
134
+ (f"%{query}%", f"%{query}%", n)
135
+ ).fetchall()
136
+ return [Episode(r[0], r[1], r[2], r[3], bool(r[4]), r[5], json.loads(r[6])) for r in rows]
137
+
138
+ def get_errors(self, n: int = 10) -> list[Episode]:
139
+ return self.get_recent(n, type_="error")
140
+
141
+ def stats(self) -> dict:
142
+ if self._sb:
143
+ try:
144
+ res = self._sb.table("episodes").select("id", count="exact").execute()
145
+ return {"total": res.count or 0, "backend": "supabase"}
146
+ except Exception:
147
+ pass
148
+ if not self._db:
149
+ return {"total": 0, "backend": "none"}
150
+ total = self._db.execute("SELECT COUNT(*) FROM episodes").fetchone()[0]
151
+ by_type = dict(self._db.execute("SELECT type, COUNT(*) FROM episodes GROUP BY type").fetchall())
152
+ return {"total": total, "by_type": by_type, "backend": "sqlite"}
153
+
154
+ @staticmethod
155
+ def _from_sb(r: dict) -> Episode:
156
+ return Episode(
157
+ r["id"], r["type"], r["task"], r.get("output", ""),
158
+ bool(r["success"]), r["ts"], json.loads(r.get("tags", "[]"))
159
+ )
160
+
161
+ def close(self):
162
+ if self._db:
163
+ self._db.close()
memory/hf_store.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ # hf_store.py — NON USATO
2
+ # Questo file era un errore di progettazione.
3
+ # HuggingFace Spaces = server (esegue il backend Python).
4
+ # Supabase = database (salva dati, memoria, file).
5
+ # Vedi episodic.py e semantic.py per la corretta implementazione Supabase-first.
memory/manager.py ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ manager.py — Unified Memory Manager
3
+ Coordina i 4 layer: Working, Episodic, Semantic, Reflection.
4
+ """
5
+ from .working import WorkingMemory
6
+ from .episodic import EpisodicMemory
7
+ from .semantic import SemanticMemory
8
+ from .reflection import ReflectionMemory
9
+
10
+ class MemoryManager:
11
+ def __init__(self):
12
+ self.working = WorkingMemory(max_entries=40)
13
+ self.episodic = EpisodicMemory()
14
+ self.semantic = SemanticMemory()
15
+ self.reflection = ReflectionMemory()
16
+
17
+ async def init(self):
18
+ self.episodic.init()
19
+ self.semantic.init()
20
+
21
+ async def close(self):
22
+ self.episodic.close()
23
+
24
+ async def get_context(self, query: str) -> str:
25
+ """Assembla il contesto dai 4 layer per arricchire il prompt."""
26
+ parts = []
27
+
28
+ # 1. Working memory — conversazione recente
29
+ working_ctx = self.working.get_context_string(n=6)
30
+ if working_ctx:
31
+ parts.append(working_ctx)
32
+
33
+ # 2. Semantic memory — conoscenza rilevante
34
+ if self.semantic.available:
35
+ semantic_hits = self.semantic.search(query, n_results=4)
36
+ if semantic_hits:
37
+ lines = [f"- {h['content'][:200]}" for h in semantic_hits if h["similarity"] > 0.3]
38
+ if lines:
39
+ parts.append("Conoscenza rilevante:\n" + "\n".join(lines))
40
+
41
+ # 3. Reflection lessons — errori da evitare
42
+ lessons = self.reflection.get_relevant_lessons(query, n=2)
43
+ if lessons:
44
+ lesson_lines = []
45
+ for l in lessons:
46
+ if l["type"] == "failure":
47
+ lesson_lines.append(f"- EVITA: {l['avoid'][:100]}")
48
+ else:
49
+ lesson_lines.append(f"- STRATEGIA: {l['strategy'][:100]}")
50
+ if lesson_lines:
51
+ parts.append("Lezioni passate:\n" + "\n".join(lesson_lines))
52
+
53
+ return "\n\n".join(parts) if parts else ""
54
+
55
+ async def save_exchange(self, messages: list, response: str):
56
+ """Salva uno scambio chat nella memoria."""
57
+ user_msg = next((m["content"] for m in reversed(messages) if m["role"] == "user"), "")
58
+ # Working: aggiungi utente + risposta
59
+ if user_msg:
60
+ self.working.add("user", user_msg)
61
+ self.working.add("assistant", response)
62
+ # Episodic: salva la coppia
63
+ self.episodic.add("chat", user_msg[:300], response[:500], True)
64
+ # Semantic: indicizza per similarity search futura
65
+ if self.semantic.available and user_msg and len(response) > 50:
66
+ combined = f"Q: {user_msg[:200]} A: {response[:400]}"
67
+ self.semantic.add(combined, {"type": "chat", "query": user_msg[:100]})
68
+
69
+ async def save_episode(self, type_: str, task: str, output: str, success: bool, tags: list | None = None):
70
+ self.episodic.add(type_, task, output, success, tags)
71
+ if self.semantic.available and task:
72
+ self.semantic.add(task, {"type": type_, "success": success})
73
+
74
+ async def search(self, query: str, n: int = 5, layer: str | None = None) -> list[dict]:
75
+ results = []
76
+ if layer in (None, "semantic") and self.semantic.available:
77
+ for h in self.semantic.search(query, n_results=n):
78
+ results.append({**h, "layer": "semantic"})
79
+ if layer in (None, "episodic"):
80
+ for ep in self.episodic.search_text(query, n=n):
81
+ results.append({"content": f"{ep.task} → {ep.output[:200]}", "layer": "episodic", "type": ep.type, "success": ep.success})
82
+ if layer == "reflection":
83
+ lessons = self.reflection.get_relevant_lessons(query, n=n)
84
+ results.extend([{**l, "layer": "reflection"} for l in lessons])
85
+ return results[:n]
86
+
87
+ async def reflect(self, task: str, output: str, success: bool, error: str | None = None) -> dict:
88
+ if success:
89
+ self.reflection.record_success(task, output[:300])
90
+ await self.save_episode("fix", task, output, True)
91
+ else:
92
+ self.reflection.record_failure(task, error or output[:300])
93
+ await self.save_episode("error", task, error or output[:300], False)
94
+ return {
95
+ "recorded": True,
96
+ "top_patterns": self.reflection.get_top_patterns(3),
97
+ "lessons": self.reflection.get_relevant_lessons(task, 2),
98
+ }
99
+
100
+ def stats(self) -> dict:
101
+ return {
102
+ "working": self.working.stats(),
103
+ "episodic": self.episodic.stats(),
104
+ "semantic": self.semantic.stats(),
105
+ "reflection": self.reflection.stats(),
106
+ }
107
+
108
+ async def clear(self, layer: str | None = None):
109
+ if layer in (None, "working"):
110
+ self.working.clear()
111
+ if layer in (None, "episodic"):
112
+ import sqlite3
113
+ if self.episodic._db:
114
+ self.episodic._db.execute("DELETE FROM episodes")
115
+ self.episodic._db.commit()
memory/reflection.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ reflection.py — Reflection Memory
3
+ Impara dagli errori, pattern di fallimento, strategie vincenti.
4
+ """
5
+ import json
6
+ from pathlib import Path
7
+ from datetime import datetime
8
+
9
+ REFLECTION_PATH = Path("reflection_memory.json")
10
+
11
+ class ReflectionMemory:
12
+ def __init__(self):
13
+ self._data: dict = {"failures": [], "successes": [], "patterns": {}}
14
+ self._load()
15
+
16
+ def _load(self):
17
+ if REFLECTION_PATH.exists():
18
+ try:
19
+ self._data = json.loads(REFLECTION_PATH.read_text())
20
+ except Exception:
21
+ pass
22
+
23
+ def _save(self):
24
+ REFLECTION_PATH.write_text(json.dumps(self._data, indent=2, ensure_ascii=False))
25
+
26
+ def record_failure(self, task: str, error: str, attempted_strategy: str = ""):
27
+ entry = {
28
+ "ts": datetime.now().isoformat(),
29
+ "task": task[:300],
30
+ "error": error[:500],
31
+ "strategy": attempted_strategy,
32
+ }
33
+ self._data["failures"].append(entry)
34
+ # Track pattern frequency
35
+ key = error[:80].lower()
36
+ self._data["patterns"][key] = self._data["patterns"].get(key, 0) + 1
37
+ # Cap at 200 entries
38
+ self._data["failures"] = self._data["failures"][-200:]
39
+ self._save()
40
+
41
+ def record_success(self, task: str, strategy: str):
42
+ entry = {
43
+ "ts": datetime.now().isoformat(),
44
+ "task": task[:300],
45
+ "strategy": strategy[:300],
46
+ }
47
+ self._data["successes"].append(entry)
48
+ self._data["successes"] = self._data["successes"][-100:]
49
+ self._save()
50
+
51
+ def get_relevant_lessons(self, task: str, n: int = 3) -> list[dict]:
52
+ """Find past failures/successes relevant to current task."""
53
+ task_lower = task.lower()
54
+ words = set(task_lower.split())
55
+ def score(entry):
56
+ text = (entry.get("task", "") + " " + entry.get("strategy", "") + entry.get("error", "")).lower()
57
+ return sum(1 for w in words if w in text and len(w) > 3)
58
+
59
+ failures = sorted(self._data["failures"], key=score, reverse=True)[:n]
60
+ successes = sorted(self._data["successes"], key=score, reverse=True)[:n]
61
+ lessons = []
62
+ for f in failures[:2]:
63
+ if score(f) > 0:
64
+ lessons.append({"type": "failure", "task": f["task"], "avoid": f["error"]})
65
+ for s in successes[:1]:
66
+ if score(s) > 0:
67
+ lessons.append({"type": "success", "task": s["task"], "strategy": s["strategy"]})
68
+ return lessons
69
+
70
+ def get_top_patterns(self, n: int = 5) -> list[dict]:
71
+ return sorted(
72
+ [{"pattern": k, "count": v} for k, v in self._data["patterns"].items()],
73
+ key=lambda x: x["count"], reverse=True
74
+ )[:n]
75
+
76
+ def stats(self) -> dict:
77
+ return {
78
+ "failures": len(self._data["failures"]),
79
+ "successes": len(self._data["successes"]),
80
+ "patterns": len(self._data["patterns"]),
81
+ }
memory/semantic.py ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ semantic.py — Semantic Memory
3
+ Preferenze, concetti, conoscenza persistente.
4
+ Database: Supabase (text search). Fallback: ChromaDB locale.
5
+ Backend server: HuggingFace Spaces (FastAPI).
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import os, uuid
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ CHROMA_PATH = Path("chroma_db")
14
+ COLLECTION_NAME = "agente_semantic"
15
+ EMBED_MODEL = "BAAI/bge-small-en-v1.5"
16
+
17
+ try:
18
+ import chromadb
19
+ from chromadb.utils import embedding_functions
20
+ CHROMA_AVAILABLE = True
21
+ except ImportError:
22
+ CHROMA_AVAILABLE = False
23
+
24
+
25
+ class SemanticMemory:
26
+ def __init__(self):
27
+ self._client = None
28
+ self._collection = None
29
+ self._embed_fn = None
30
+ self._sb = None
31
+ self.available = False
32
+
33
+ def _try_supabase(self):
34
+ url = os.getenv("SUPABASE_URL", "")
35
+ key = os.getenv("SUPABASE_ANON_KEY") or os.getenv("SUPABASE_KEY", "")
36
+ if not url or not key:
37
+ return None
38
+ try:
39
+ from supabase import create_client
40
+ return create_client(url, key)
41
+ except Exception:
42
+ return None
43
+
44
+ def init(self):
45
+ self._sb = self._try_supabase()
46
+ if self._sb:
47
+ try:
48
+ self._sb.table("semantic_memory").select("id").limit(1).execute()
49
+ self.available = True
50
+ print("SemanticMemory: Supabase ✓", flush=True)
51
+ return
52
+ except Exception as e:
53
+ print(f"SemanticMemory: Supabase table mancante: {e}", flush=True)
54
+ self._sb = None
55
+
56
+ # Fallback ChromaDB locale (HF Spaces senza Supabase)
57
+ if not CHROMA_AVAILABLE:
58
+ print("WARN: chromadb non installato — SemanticMemory disabilitata", flush=True)
59
+ return
60
+ try:
61
+ self._embed_fn = embedding_functions.SentenceTransformerEmbeddingFunction(
62
+ model_name=EMBED_MODEL
63
+ )
64
+ self._client = chromadb.PersistentClient(path=str(CHROMA_PATH))
65
+ self._collection = self._client.get_or_create_collection(
66
+ name=COLLECTION_NAME,
67
+ embedding_function=self._embed_fn,
68
+ metadata={"hnsw:space": "cosine"},
69
+ )
70
+ self.available = True
71
+ print("SemanticMemory: ChromaDB locale (fallback) ✓", flush=True)
72
+ except Exception as e:
73
+ print(f"WARN: SemanticMemory non disponibile: {e}", flush=True)
74
+
75
+ # ── Write ─────────────────────────────────────────────────────────────────
76
+
77
+ def add(self, text: str, metadata: dict | None = None, doc_id: str | None = None):
78
+ _id = doc_id or str(uuid.uuid4())
79
+ if self._sb:
80
+ try:
81
+ self._sb.table("semantic_memory").upsert({
82
+ "id": _id, "content": text[:2000], "metadata": metadata or {},
83
+ }).execute()
84
+ return
85
+ except Exception:
86
+ pass
87
+ if self._collection:
88
+ self._collection.upsert(
89
+ documents=[text],
90
+ metadatas=[metadata or {}],
91
+ ids=[_id],
92
+ )
93
+
94
+ # ── Read ──────────────────────────────────────────────────────────────────
95
+
96
+ def search(self, query: str, n_results: int = 5) -> list[dict]:
97
+ if self._sb:
98
+ try:
99
+ rows = (
100
+ self._sb.table("semantic_memory")
101
+ .select("*")
102
+ .ilike("content", f"%{query}%")
103
+ .limit(n_results)
104
+ .execute().data or []
105
+ )
106
+ return [{"content": r["content"], "metadata": r.get("metadata", {}),
107
+ "similarity": 0.8} for r in rows]
108
+ except Exception:
109
+ pass
110
+ if not self._collection:
111
+ return []
112
+ try:
113
+ results = self._collection.query(
114
+ query_texts=[query],
115
+ n_results=min(n_results, self._collection.count() or 1),
116
+ )
117
+ docs = results.get("documents", [[]])[0]
118
+ metas = results.get("metadatas", [[]])[0]
119
+ dists = results.get("distances", [[]])[0]
120
+ return [
121
+ {"content": doc, "metadata": meta, "similarity": round(1 - dist, 4)}
122
+ for doc, meta, dist in zip(docs, metas, dists)
123
+ ]
124
+ except Exception:
125
+ return []
126
+
127
+ def count(self) -> int:
128
+ if self._sb:
129
+ try:
130
+ return self._sb.table("semantic_memory").select("id", count="exact").execute().count or 0
131
+ except Exception:
132
+ pass
133
+ return self._collection.count() if self._collection else 0
134
+
135
+ def stats(self) -> dict:
136
+ backend = "supabase" if self._sb else ("chroma" if self._collection else "none")
137
+ return {"available": self.available, "documents": self.count(),
138
+ "model": EMBED_MODEL, "backend": backend}
memory/sync.py ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ sync.py — Memory Sync Protocol
3
+
4
+ Sincronizza in modo idempotente la memoria locale del browser con il backend.
5
+ Il protocollo accetta batch di elementi firmati da source/id/versione e salva
6
+ il contenuto nei layer episodic/semantic senza richiedere dipendenze aggiuntive.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import time
12
+ from typing import Any, Iterable, Literal
13
+
14
+ from fastapi import APIRouter
15
+ from pydantic import BaseModel, Field
16
+
17
+
18
+ MemoryLayer = Literal["working", "episodic", "semantic", "reflection"]
19
+ MemoryType = Literal["fact", "preference", "summary", "skill", "episode", "reflection"]
20
+
21
+
22
+ class SyncMemoryItem(BaseModel):
23
+ id: str = Field(..., min_length=1)
24
+ content: str = Field(..., min_length=1)
25
+ type: MemoryType = "fact"
26
+ layer: MemoryLayer = "semantic"
27
+ topic: str | None = None
28
+ weight: float = 1.0
29
+ created_at: int | None = None
30
+ updated_at: int | None = None
31
+ source: str = "browser"
32
+ metadata: dict[str, Any] = Field(default_factory=dict)
33
+
34
+
35
+ class MemorySyncPushRequest(BaseModel):
36
+ device_id: str = "browser"
37
+ since: int | None = None
38
+ items: list[SyncMemoryItem] = Field(default_factory=list)
39
+
40
+
41
+ class MemorySyncPullRequest(BaseModel):
42
+ device_id: str = "browser"
43
+ query: str | None = None
44
+ since: int | None = None
45
+ limit: int = Field(default=50, ge=1, le=200)
46
+ layer: MemoryLayer | None = None
47
+
48
+
49
+ class MemorySyncStatus(BaseModel):
50
+ ok: bool
51
+ protocol: str = "memory-sync-v1"
52
+ server_time: int
53
+ stats: dict[str, Any]
54
+
55
+
56
+ def _now_ms() -> int:
57
+ return int(time.time() * 1000)
58
+
59
+
60
+ def _fingerprint(item: SyncMemoryItem) -> str:
61
+ raw = f"{item.source}:{item.id}:{item.updated_at or item.created_at or 0}:{item.content}".encode("utf-8")
62
+ return hashlib.sha256(raw).hexdigest()[:16]
63
+
64
+
65
+ def _iter_local_items(memory: Any, layer: MemoryLayer | None, limit: int) -> Iterable[dict[str, Any]]:
66
+ """Best-effort export from available memory layers."""
67
+ if layer in (None, "working") and hasattr(memory, "working"):
68
+ entries = getattr(memory.working, "entries", []) or []
69
+ for idx, entry in enumerate(entries[-limit:]):
70
+ if isinstance(entry, dict):
71
+ content = entry.get("content") or entry.get("text") or str(entry)
72
+ role = entry.get("role", "unknown")
73
+ else:
74
+ content = getattr(entry, "content", str(entry))
75
+ role = getattr(entry, "role", "unknown")
76
+ yield {
77
+ "id": f"working-{idx}",
78
+ "content": content,
79
+ "type": "episode",
80
+ "layer": "working",
81
+ "topic": role,
82
+ "weight": 1,
83
+ "updated_at": _now_ms(),
84
+ "source": "backend",
85
+ "metadata": {"role": role},
86
+ }
87
+
88
+
89
+ def create_memory_sync_router(memory: Any) -> APIRouter:
90
+ router = APIRouter(prefix="/api/memory/sync", tags=["memory-sync"])
91
+
92
+ @router.get("/status", response_model=MemorySyncStatus)
93
+ async def sync_status() -> MemorySyncStatus:
94
+ return MemorySyncStatus(ok=True, server_time=_now_ms(), stats=memory.stats())
95
+
96
+ @router.post("/push")
97
+ async def sync_push(req: MemorySyncPushRequest) -> dict[str, Any]:
98
+ accepted: list[str] = []
99
+ skipped: list[dict[str, str]] = []
100
+ now = _now_ms()
101
+
102
+ for item in req.items:
103
+ if not item.content.strip():
104
+ skipped.append({"id": item.id, "reason": "empty-content"})
105
+ continue
106
+
107
+ item.metadata.setdefault("sync_id", item.id)
108
+ item.metadata.setdefault("device_id", req.device_id)
109
+ item.metadata.setdefault("fingerprint", _fingerprint(item))
110
+ item.metadata.setdefault("synced_at", now)
111
+ item.metadata.setdefault("source", item.source)
112
+ item.metadata.setdefault("topic", item.topic or "generale")
113
+
114
+ try:
115
+ if item.layer == "working" and hasattr(memory, "working"):
116
+ role = str(item.metadata.get("role") or item.type)
117
+ memory.working.add(role, item.content)
118
+ elif item.layer == "episodic":
119
+ await memory.save_episode(item.type, item.topic or item.id, item.content, True, tags=["sync", req.device_id])
120
+ else:
121
+ if getattr(memory.semantic, "available", False):
122
+ memory.semantic.add(item.content, {"type": item.type, **item.metadata})
123
+ else:
124
+ await memory.save_episode(item.type, item.topic or item.id, item.content, True, tags=["sync", req.device_id])
125
+ accepted.append(item.id)
126
+ except Exception as exc: # pragma: no cover - defensive endpoint boundary
127
+ skipped.append({"id": item.id, "reason": str(exc)[:180]})
128
+
129
+ return {
130
+ "ok": True,
131
+ "protocol": "memory-sync-v1",
132
+ "accepted": accepted,
133
+ "skipped": skipped,
134
+ "server_time": now,
135
+ "stats": memory.stats(),
136
+ }
137
+
138
+ @router.post("/pull")
139
+ async def sync_pull(req: MemorySyncPullRequest) -> dict[str, Any]:
140
+ query = (req.query or "").strip()
141
+ items: list[dict[str, Any]] = []
142
+
143
+ if query:
144
+ results = await memory.search(query, req.limit, layer=req.layer)
145
+ for idx, hit in enumerate(results):
146
+ content = str(hit.get("content", ""))
147
+ items.append({
148
+ "id": str(hit.get("id") or f"backend-search-{idx}"),
149
+ "content": content,
150
+ "type": str(hit.get("type") or "fact"),
151
+ "layer": str(hit.get("layer") or req.layer or "semantic"),
152
+ "topic": str(hit.get("topic") or "generale"),
153
+ "weight": float(hit.get("similarity") or 1),
154
+ "updated_at": _now_ms(),
155
+ "source": "backend",
156
+ "metadata": {k: v for k, v in hit.items() if k not in {"content"}},
157
+ })
158
+ else:
159
+ items.extend(list(_iter_local_items(memory, req.layer, req.limit)))
160
+
161
+ return {
162
+ "ok": True,
163
+ "protocol": "memory-sync-v1",
164
+ "items": items[: req.limit],
165
+ "server_time": _now_ms(),
166
+ "stats": memory.stats(),
167
+ }
168
+
169
+ return router
memory/working.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ working.py — Working Memory (short-term, in-RAM)
3
+ Ultime N conversazioni per contesto immediato.
4
+ """
5
+ from collections import deque
6
+ from dataclasses import dataclass, field
7
+ from datetime import datetime
8
+
9
+ @dataclass
10
+ class WorkingEntry:
11
+ role: str
12
+ content: str
13
+ ts: float = field(default_factory=lambda: datetime.now().timestamp())
14
+
15
+ class WorkingMemory:
16
+ def __init__(self, max_entries: int = 40):
17
+ self._buf: deque[WorkingEntry] = deque(maxlen=max_entries)
18
+
19
+ def add(self, role: str, content: str):
20
+ self._buf.append(WorkingEntry(role=role, content=content))
21
+
22
+ def get_recent(self, n: int = 10) -> list[dict]:
23
+ entries = list(self._buf)[-n:]
24
+ return [{"role": e.role, "content": e.content} for e in entries]
25
+
26
+ def get_context_string(self, n: int = 6) -> str:
27
+ recent = self.get_recent(n)
28
+ if not recent:
29
+ return ""
30
+ lines = []
31
+ for m in recent:
32
+ prefix = "Utente" if m["role"] == "user" else "AI"
33
+ lines.append(f"{prefix}: {m['content'][:200]}")
34
+ return "Conversazione recente:\n" + "\n".join(lines)
35
+
36
+ def clear(self):
37
+ self._buf.clear()
38
+
39
+ def stats(self) -> dict:
40
+ return {"entries": len(self._buf), "max": self._buf.maxlen}
models/__init__.py ADDED
File without changes
models/ai_client.py ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ai_client.py — Cloud AI Client for free remote deployments
3
+
4
+ Backend-first model router for mobile/no-PC usage. It prefers free or low-cost
5
+ OpenAI-compatible providers when configured and falls back across providers before
6
+ returning an error. The iPhone remains only a control surface; all model calls run
7
+ from the deployed backend/HF Space.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ from dataclasses import dataclass
13
+ from typing import AsyncIterator, Optional
14
+
15
+ from openai import OpenAI
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class ProviderConfig:
20
+ name: str
21
+ api_key: str
22
+ base_url: str
23
+ default_model: str
24
+ embedding_model: Optional[str] = None
25
+
26
+
27
+ class AIClient:
28
+ def __init__(self):
29
+ self.providers = self._discover_providers()
30
+ self.default_model = self.providers[0].default_model if self.providers else os.getenv("LLM_MODEL", "openrouter/auto")
31
+ self.provider_name = self.providers[0].name if self.providers else "unconfigured"
32
+ self.client = self._client_for(self.providers[0]) if self.providers else None
33
+
34
+ def _discover_providers(self) -> list[ProviderConfig]:
35
+ providers: list[ProviderConfig] = []
36
+
37
+ openrouter_key = os.getenv("OPENROUTER_API_KEY")
38
+ if openrouter_key:
39
+ providers.append(ProviderConfig(
40
+ name="openrouter",
41
+ api_key=openrouter_key,
42
+ base_url="https://openrouter.ai/api/v1",
43
+ default_model=os.getenv("OPENROUTER_MODEL", os.getenv("LLM_MODEL", "deepseek/deepseek-r1:free")),
44
+ ))
45
+
46
+ gemini_key = os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY")
47
+ if gemini_key:
48
+ providers.append(ProviderConfig(
49
+ name="gemini",
50
+ api_key=gemini_key,
51
+ base_url="https://generativelanguage.googleapis.com/v1beta/openai/",
52
+ default_model=os.getenv("GEMINI_MODEL", os.getenv("LLM_MODEL_GEMINI", "gemini-2.0-flash")),
53
+ ))
54
+
55
+ groq_key = os.getenv("GROQ_API_KEY")
56
+ if groq_key:
57
+ providers.append(ProviderConfig(
58
+ name="groq",
59
+ api_key=groq_key,
60
+ base_url="https://api.groq.com/openai/v1",
61
+ default_model=os.getenv("GROQ_MODEL", os.getenv("LLM_MODEL_GROQ", "llama-3.1-8b-instant")),
62
+ ))
63
+
64
+ hf_key = os.getenv("HF_TOKEN") or os.getenv("HUGGINGFACE_API_KEY") or os.getenv("HUGGINGFACE_TOKEN")
65
+ if hf_key:
66
+ providers.append(ProviderConfig(
67
+ name="huggingface",
68
+ api_key=hf_key,
69
+ base_url=os.getenv("HF_OPENAI_BASE_URL", "https://router.huggingface.co/v1"),
70
+ default_model=os.getenv("HF_MODEL", os.getenv("LLM_MODEL_HF", "Qwen/Qwen2.5-Coder-32B-Instruct")),
71
+ ))
72
+
73
+ openai_key = os.getenv("OPENAI_API_KEY")
74
+ if openai_key:
75
+ providers.append(ProviderConfig(
76
+ name="openai_compatible",
77
+ api_key=openai_key,
78
+ base_url=os.getenv("OPENAI_API_BASE", "https://api.openai.com/v1"),
79
+ default_model=os.getenv("OPENAI_MODEL", os.getenv("LLM_MODEL", "gpt-4o-mini")),
80
+ embedding_model=os.getenv("EMBEDDING_MODEL", "text-embedding-3-small"),
81
+ ))
82
+
83
+ return providers
84
+
85
+ def _client_for(self, provider: ProviderConfig) -> OpenAI:
86
+ return OpenAI(api_key=provider.api_key, base_url=provider.base_url)
87
+
88
+ def _model_for(self, provider: ProviderConfig, requested: Optional[str]) -> str:
89
+ return requested or provider.default_model
90
+
91
+ async def health(self) -> dict:
92
+ if not self.providers:
93
+ return {
94
+ "available": False,
95
+ "provider": "none",
96
+ "error": "No remote provider key configured. Set OPENROUTER_API_KEY, GEMINI_API_KEY, GROQ_API_KEY, HF_TOKEN or OPENAI_API_KEY.",
97
+ "models": [],
98
+ }
99
+
100
+ checks = []
101
+ for provider in self.providers:
102
+ try:
103
+ client = self._client_for(provider)
104
+ response = client.chat.completions.create(
105
+ model=provider.default_model,
106
+ messages=[{"role": "user", "content": "ping"}],
107
+ max_tokens=1,
108
+ temperature=0,
109
+ )
110
+ checks.append({"provider": provider.name, "available": True, "model": provider.default_model})
111
+ self.provider_name = provider.name
112
+ self.default_model = provider.default_model
113
+ self.client = client
114
+ return {
115
+ "available": True,
116
+ "provider": provider.name,
117
+ "models": [p.default_model for p in self.providers],
118
+ "default": provider.default_model,
119
+ "checks": checks,
120
+ }
121
+ except Exception as exc:
122
+ checks.append({"provider": provider.name, "available": False, "model": provider.default_model, "error": str(exc)})
123
+
124
+ return {
125
+ "available": False,
126
+ "provider": "configured_but_unavailable",
127
+ "models": [p.default_model for p in self.providers],
128
+ "checks": checks,
129
+ }
130
+
131
+ async def chat(self, messages: list, *, model=None, temperature=0.7, max_tokens=4096) -> str:
132
+ last_error: Exception | None = None
133
+ for provider in self.providers:
134
+ try:
135
+ client = self._client_for(provider)
136
+ response = client.chat.completions.create(
137
+ model=self._model_for(provider, model),
138
+ messages=messages,
139
+ temperature=temperature,
140
+ max_tokens=max_tokens,
141
+ stream=False,
142
+ )
143
+ self.provider_name = provider.name
144
+ self.default_model = self._model_for(provider, model)
145
+ self.client = client
146
+ return response.choices[0].message.content or ""
147
+ except Exception as exc:
148
+ last_error = exc
149
+ continue
150
+ raise RuntimeError(f"No configured model provider succeeded: {last_error}")
151
+
152
+ async def stream_chat(self, messages: list, *, model=None, temperature=0.7, max_tokens=4096) -> AsyncIterator[str]:
153
+ last_error: Exception | None = None
154
+ for provider in self.providers:
155
+ try:
156
+ client = self._client_for(provider)
157
+ response = client.chat.completions.create(
158
+ model=self._model_for(provider, model),
159
+ messages=messages,
160
+ temperature=temperature,
161
+ max_tokens=max_tokens,
162
+ stream=True,
163
+ )
164
+ self.provider_name = provider.name
165
+ self.default_model = self._model_for(provider, model)
166
+ self.client = client
167
+ for chunk in response:
168
+ if chunk.choices and chunk.choices[0].delta.content:
169
+ yield chunk.choices[0].delta.content
170
+ return
171
+ except Exception as exc:
172
+ last_error = exc
173
+ continue
174
+ raise RuntimeError(f"No configured streaming model provider succeeded: {last_error}")
175
+
176
+ async def embed(self, text: str, model: str = "text-embedding-3-small") -> list[float]:
177
+ for provider in self.providers:
178
+ embedding_model = provider.embedding_model or os.getenv("EMBEDDING_MODEL")
179
+ if not embedding_model:
180
+ continue
181
+ try:
182
+ client = self._client_for(provider)
183
+ response = client.embeddings.create(input=[text], model=embedding_model or model)
184
+ return response.data[0].embedding
185
+ except Exception:
186
+ continue
187
+ return []
models/ollama_client.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ollama_client.py — Client Ollama con auto-discovery modelli
3
+ """
4
+ import httpx
5
+ from typing import Optional, AsyncIterator
6
+
7
+ OLLAMA_BASE = "http://localhost:11434"
8
+ DEFAULT_MODEL = "qwen2.5-coder:7b"
9
+ FALLBACK_MODELS = ["gemma3:4b", "phi4-mini", "llama3.2:3b", "mistral:7b", "llama3:8b"]
10
+
11
+ class OllamaClient:
12
+ def __init__(self, base_url: str = OLLAMA_BASE):
13
+ self.base = base_url.rstrip("/")
14
+ self.default_model = DEFAULT_MODEL
15
+ self._available_models: list[str] = []
16
+
17
+ async def health(self) -> dict:
18
+ try:
19
+ async with httpx.AsyncClient(timeout=3) as c:
20
+ r = await c.get(f"{self.base}/api/tags")
21
+ models = [m["name"] for m in r.json().get("models", [])]
22
+ self._available_models = models
23
+ # Pick best default from what's installed
24
+ for m in [DEFAULT_MODEL] + FALLBACK_MODELS:
25
+ if any(m in installed for installed in models):
26
+ self.default_model = m
27
+ break
28
+ return {"available": True, "models": models, "default": self.default_model}
29
+ except Exception as e:
30
+ return {"available": False, "models": [], "error": str(e)}
31
+
32
+ def _resolve_model(self, model: Optional[str]) -> str:
33
+ if model and model in self._available_models:
34
+ return model
35
+ return self.default_model
36
+
37
+ async def chat(self, messages: list, *, model=None, temperature=0.7, max_tokens=4096) -> str:
38
+ payload = {
39
+ "model": self._resolve_model(model),
40
+ "messages": messages,
41
+ "stream": False,
42
+ "options": {"temperature": temperature, "num_predict": max_tokens},
43
+ }
44
+ async with httpx.AsyncClient(timeout=120) as c:
45
+ r = await c.post(f"{self.base}/api/chat", json=payload)
46
+ r.raise_for_status()
47
+ return r.json().get("message", {}).get("content", "")
48
+
49
+ async def stream_chat(self, messages: list, *, model=None, temperature=0.7, max_tokens=4096) -> AsyncIterator[str]:
50
+ import json as _json
51
+ payload = {
52
+ "model": self._resolve_model(model),
53
+ "messages": messages,
54
+ "stream": True,
55
+ "options": {"temperature": temperature, "num_predict": max_tokens},
56
+ }
57
+ async with httpx.AsyncClient(timeout=180) as c:
58
+ async with c.stream("POST", f"{self.base}/api/chat", json=payload) as resp:
59
+ async for line in resp.aiter_lines():
60
+ if not line:
61
+ continue
62
+ try:
63
+ data = _json.loads(line)
64
+ chunk = data.get("message", {}).get("content", "")
65
+ if chunk:
66
+ yield chunk
67
+ if data.get("done"):
68
+ break
69
+ except Exception:
70
+ continue
71
+
72
+ async def embed(self, text: str, model: str = "mxbai-embed-large") -> list[float]:
73
+ try:
74
+ async with httpx.AsyncClient(timeout=30) as c:
75
+ r = await c.post(f"{self.base}/api/embed", json={"model": model, "input": text})
76
+ r.raise_for_status()
77
+ return r.json().get("embeddings", [[]])[0]
78
+ except Exception:
79
+ return []
package.json ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "@workspace/agente-ai",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite --config vite.config.ts --host 0.0.0.0",
8
+ "build": "vite build --config vite.config.ts",
9
+ "serve": "vite preview --config vite.config.ts --host 0.0.0.0",
10
+ "typecheck": "tsc -p tsconfig.json --noEmit",
11
+ "build:pages": "vite build --config vite.gh-pages.config.ts"
12
+ },
13
+ "devDependencies": {
14
+ "@replit/vite-plugin-cartographer": "catalog:",
15
+ "@replit/vite-plugin-dev-banner": "catalog:",
16
+ "@replit/vite-plugin-runtime-error-modal": "catalog:",
17
+
18
+ "@tailwindcss/vite": "catalog:",
19
+ "@types/node": "catalog:",
20
+ "@types/react": "catalog:",
21
+ "@types/react-dom": "catalog:",
22
+ "@vitejs/plugin-react": "catalog:",
23
+
24
+ "react": "catalog:",
25
+ "react-dom": "catalog:",
26
+ "tailwindcss": "catalog:",
27
+ "vite": "catalog:",
28
+
29
+ "@types/qrcode": "^1.5.5",
30
+ "highlight.js": "^11.11.1",
31
+ "jszip": "^3.10.1",
32
+ "katex": "^0.16.21",
33
+ "mammoth": "^1.9.0",
34
+ "mermaid": "^11.4.1",
35
+ "pdfjs-dist": "^4.10.38",
36
+ "qrcode": "^1.5.4",
37
+ "react-markdown": "^10.1.0",
38
+ "rehype-highlight": "^7.0.2",
39
+ "remark-gfm": "^4.0.1",
40
+ "tw-animate-css": "^1.4.0",
41
+ "xlsx": "^0.18.5"
42
+ }
43
+ }
requirements.txt CHANGED
@@ -5,4 +5,7 @@ pydantic>=2.0.0
5
  python-multipart>=0.0.9
6
  aiofiles>=23.0.0
7
  openai>=1.0.0
8
- supabase>=2.0.0
 
 
 
 
5
  python-multipart>=0.0.9
6
  aiofiles>=23.0.0
7
  openai>=1.0.0
8
+ supabase>=2.5.0,<3.0.0
9
+ smolagents[litellm]>=1.14.0
10
+ litellm>=1.40.0
11
+ huggingface_hub>=0.24.0
start.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ import os, sys
2
+ port = int(os.environ.get('PORT', '7860'))
3
+ print(f'START: PORT={port}', flush=True)
4
+
5
+ import uvicorn
6
+ uvicorn.run('main:app', host='0.0.0.0', port=port, log_level='info', access_log=True)
tools/__init__.py ADDED
File without changes
tools/content_cleaner.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """content_cleaner.py — Pulisce e struttura il testo per il modello."""
2
+ import re
3
+ from typing import Optional
4
+ NOISE_PATTERNS=["cookie","accept all cookies","privacy policy","terms of service","all rights reserved","subscribe","follow us on","share this article"]
5
+
6
+ def remove_noise(text:str)->str:
7
+ lines=text.split("\n"); cleaned=[]
8
+ for line in lines:
9
+ ll=line.lower().strip()
10
+ if len(ll)<4: continue
11
+ if any(p in ll for p in NOISE_PATTERNS): continue
12
+ if line.count("|")>5: continue
13
+ cleaned.append(line)
14
+ return "\n".join(cleaned)
15
+
16
+ def extract_key_paragraphs(text:str,query:str,max_paragraphs:int=6)->list[str]:
17
+ q_words=set(w.lower() for w in re.split(r"\W+",query) if len(w)>3)
18
+ pars=[p.strip() for p in re.split(r"\n{2,}",text) if len(p.strip())>60]
19
+ def rel(p): pl=p.lower(); return sum(1 for w in q_words if w in pl)/max(1,len(q_words))
20
+ return sorted(pars,key=rel,reverse=True)[:max_paragraphs]
21
+
22
+ def clean_and_structure(text:str,query:Optional[str]=None,max_chars:int=4000)->dict:
23
+ text=remove_noise(text)
24
+ pars=extract_key_paragraphs(text,query) if query else [text]
25
+ structured="\n\n".join(pars)
26
+ if len(structured)>max_chars: structured=structured[:max_chars]+"\n[troncato]"
27
+ return {"content":structured,"chars":len(structured),"words":len(structured.split()),"query_used":query}
tools/diff_checker.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """diff_checker.py — Genera e valida diff tra versioni di file."""
2
+ import difflib, re
3
+ from pathlib import Path
4
+
5
+ def generate_diff(original:str,modified:str,filename:str="file")->dict:
6
+ a=original.splitlines(keepends=True); b=modified.splitlines(keepends=True)
7
+ lines=list(difflib.unified_diff(a,b,fromfile=f"a/{filename}",tofile=f"b/{filename}",lineterm=""))
8
+ diff="\n".join(lines)
9
+ added=sum(1 for l in lines if l.startswith("+") and not l.startswith("+++"))
10
+ removed=sum(1 for l in lines if l.startswith("-") and not l.startswith("---"))
11
+ return {"diff":diff,"added":added,"removed":removed,"has_changes":len(lines)>0}
12
+
13
+ def validate_python(code:str)->dict:
14
+ import ast
15
+ try: ast.parse(code); return {"valid":True,"errors":[]}
16
+ except SyntaxError as e: return {"valid":False,"errors":[f"L{e.lineno}: {e.msg}"]}
17
+
18
+ def file_diff(path_a:str,path_b:str)->dict:
19
+ try: return generate_diff(Path(path_a).read_text(encoding="utf-8"),Path(path_b).read_text(encoding="utf-8"),Path(path_a).name)
20
+ except Exception as e: return {"error":str(e),"diff":""}
tools/file_editor.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """file_editor.py — Applica modifiche a file con backup automatico."""
2
+ import shutil
3
+ from pathlib import Path
4
+ from datetime import datetime
5
+ BACKUP_DIR=Path(".agent_backups")
6
+
7
+ def _backup(filepath:str)->str:
8
+ BACKUP_DIR.mkdir(exist_ok=True); src=Path(filepath)
9
+ if not src.exists(): return ""
10
+ ts=datetime.now().strftime("%Y%m%d_%H%M%S"); bk=BACKUP_DIR/f"{src.stem}_{ts}{src.suffix}"
11
+ shutil.copy2(src,bk); return str(bk)
12
+
13
+ def apply_replace(filepath:str,old_code:str,new_code:str,backup:bool=True)->dict:
14
+ try:
15
+ p=Path(filepath)
16
+ if not p.exists(): return {"success":False,"error":"File non trovato"}
17
+ c=p.read_text(encoding="utf-8")
18
+ if old_code not in c: return {"success":False,"error":"Blocco non trovato"}
19
+ bk=_backup(filepath) if backup else ""
20
+ p.write_text(c.replace(old_code,new_code,1),encoding="utf-8")
21
+ return {"success":True,"backup":bk}
22
+ except Exception as e: return {"success":False,"error":str(e)}
23
+
24
+ def write_file(filepath:str,content:str,backup:bool=True)->dict:
25
+ try:
26
+ p=Path(filepath); bk=_backup(filepath) if backup and p.exists() else ""
27
+ p.parent.mkdir(parents=True,exist_ok=True); p.write_text(content,encoding="utf-8")
28
+ return {"success":True,"backup":bk,"path":filepath}
29
+ except Exception as e: return {"success":False,"error":str(e)}
30
+
31
+ def list_backups()->list[dict]:
32
+ if not BACKUP_DIR.exists(): return []
33
+ return [{"path":str(b),"name":b.name,"size":b.stat().st_size} for b in sorted(BACKUP_DIR.iterdir(),reverse=True)[:20]]
tools/ranking.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ranking.py — Ordina risultati web per rilevanza + qualità."""
2
+ import re
3
+ from typing import Optional
4
+
5
+ def tfidf_score(query:str,document:str)->float:
6
+ terms=[w.lower() for w in re.split(r"\W+",query) if len(w)>2]
7
+ words=re.split(r"\W+",document.lower()); n=max(1,len(words)); score=0.0
8
+ for t in terms: score+=words.count(t)/n
9
+ return score
10
+
11
+ def quality_score(result:dict)->float:
12
+ w={"StackOverflow":0.95,"HackerNews":0.80,"DDG":0.70}
13
+ base=w.get(result.get("source",""),0.60)
14
+ bonus=min(0.2,len(result.get("snippet",""))/1000)+(0.1 if result.get("answered") else 0)
15
+ return min(1.0,base+bonus)
16
+
17
+ def rank_results(results:list[dict],query:str,top_k:int=6)->list[dict]:
18
+ for r in results:
19
+ r["_r"]=tfidf_score(query,f"{r.get('title','')} {r.get('snippet','')}")
20
+ r["_q"]=quality_score(r); r["_s"]=0.6*r["_r"]+0.4*r["_q"]
21
+ ranked=sorted(results,key=lambda x:x["_s"],reverse=True)[:top_k]
22
+ for r in ranked: [r.pop(k,None) for k in ["_r","_q","_s"]]
23
+ return ranked
24
+
25
+ def format_for_llm(results:list[dict],query:str)->str:
26
+ if not results: return f"Nessun risultato per: {query}"
27
+ lines=[f"Risultati: **{query}**\n"]
28
+ for i,r in enumerate(results,1):
29
+ lines.append(f"{i}. **{r.get('title','')[:80]}** [{r.get('source','Web')}]")
30
+ if r.get("snippet"): lines.append(f" {r['snippet'][:200]}")
31
+ if r.get("url"): lines.append(f" {r['url']}"); lines.append("")
32
+ return "\n".join(lines)
tools/registry.py ADDED
@@ -0,0 +1,193 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ registry.py — Tool Registry
3
+ Ogni tool ha: goal, required_inputs, risk_level, fallbacks, _fn.
4
+ """
5
+ import httpx
6
+ import asyncio
7
+ import subprocess
8
+ import tempfile
9
+ import os
10
+ import sys
11
+
12
+ # ─── Tool functions ────────────────────────────────────────
13
+
14
+ async def _web_search(query: str, max_results: int = 5) -> dict:
15
+ headers = {"User-Agent": "Mozilla/5.0 (compatible; AgentBot/1.0)"}
16
+ results = []
17
+ # 1. DuckDuckGo Instant Answers
18
+ try:
19
+ async with httpx.AsyncClient(timeout=10, follow_redirects=True) as c:
20
+ r = await c.get(
21
+ f"https://api.duckduckgo.com/?q={httpx.QueryParams({'q': query}).get('q')}&format=json&no_html=1&skip_disambig=1",
22
+ headers=headers
23
+ )
24
+ data = r.json()
25
+ if data.get("AbstractText"):
26
+ results.append({
27
+ "title": data.get("Heading", query),
28
+ "snippet": data["AbstractText"][:400],
29
+ "url": data.get("AbstractURL", "")
30
+ })
31
+ for t in data.get("RelatedTopics", [])[:max_results]:
32
+ if isinstance(t, dict) and t.get("Text"):
33
+ results.append({
34
+ "title": t.get("Text", "")[:80],
35
+ "snippet": t.get("Text", "")[:300],
36
+ "url": t.get("FirstURL", "")
37
+ })
38
+ except Exception:
39
+ pass
40
+
41
+ # 2. Se non abbastanza risultati, prova SearXNG pubblico
42
+ if len(results) < 2:
43
+ try:
44
+ async with httpx.AsyncClient(timeout=10) as c:
45
+ r = await c.get(
46
+ f"https://searx.be/search?q={query}&format=json&categories=general",
47
+ headers=headers
48
+ )
49
+ data = r.json()
50
+ for item in data.get("results", [])[:max_results]:
51
+ results.append({
52
+ "title": item.get("title", ""),
53
+ "snippet": item.get("content", "")[:300],
54
+ "url": item.get("url", "")
55
+ })
56
+ except Exception:
57
+ pass
58
+
59
+ return {"query": query, "results": results[:max_results]}
60
+
61
+ async def _read_page(url: str) -> dict:
62
+ try:
63
+ async with httpx.AsyncClient(timeout=15, follow_redirects=True) as c:
64
+ r = await c.get(url, headers={"User-Agent": "Mozilla/5.0"})
65
+ text = r.text
66
+ # Strip HTML tags
67
+ import re
68
+ clean = re.sub(r'<[^>]+>', ' ', text)
69
+ clean = re.sub(r'\s+', ' ', clean).strip()
70
+ return {"url": url, "content": clean[:5000], "status": r.status_code}
71
+ except Exception as e:
72
+ return {"url": url, "content": "", "error": str(e)}
73
+
74
+ async def _get_weather(city: str) -> dict:
75
+ try:
76
+ async with httpx.AsyncClient(timeout=8) as c:
77
+ geo = await c.get(f"https://geocoding-api.open-meteo.com/v1/search?name={city}&count=1&language=it&format=json")
78
+ results = geo.json().get("results", [])
79
+ if not results:
80
+ return {"error": f"Città '{city}' non trovata"}
81
+ loc = results[0]
82
+ weather = await c.get(
83
+ f"https://api.open-meteo.com/v1/forecast?latitude={loc['latitude']}&longitude={loc['longitude']}"
84
+ f"&current=temperature_2m,weather_code,wind_speed_10m&timezone=auto"
85
+ )
86
+ w = weather.json().get("current", {})
87
+ return {"city": loc["name"], "country": loc.get("country", ""), "temp_c": w.get("temperature_2m"), "wind_kmh": w.get("wind_speed_10m"), "code": w.get("weather_code")}
88
+ except Exception as e:
89
+ return {"error": str(e)}
90
+
91
+ async def _calculate(expression: str) -> dict:
92
+ try:
93
+ import ast, operator
94
+ allowed = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul, ast.Div: operator.truediv, ast.Pow: operator.pow, ast.USub: operator.neg}
95
+ def eval_node(node):
96
+ if isinstance(node, ast.Constant): return node.value
97
+ if isinstance(node, ast.BinOp): return allowed[type(node.op)](eval_node(node.left), eval_node(node.right))
98
+ if isinstance(node, ast.UnaryOp): return allowed[type(node.op)](eval_node(node.operand))
99
+ raise ValueError("Operazione non supportata")
100
+ tree = ast.parse(expression, mode='eval')
101
+ result = eval_node(tree.body)
102
+ return {"expression": expression, "result": result}
103
+ except Exception as e:
104
+ return {"expression": expression, "error": str(e)}
105
+
106
+ async def _run_python(code: str) -> dict:
107
+ """Esegue codice Python reale sul server in sandbox con timeout."""
108
+ # Sandbox: blocca import pericolosi
109
+ forbidden = ["os.system", "subprocess", "shutil.rmtree", "__import__('os')", "open('/etc"]
110
+ for f in forbidden:
111
+ if f in code:
112
+ return {"error": f"Operazione non permessa: {f}", "stdout": "", "returncode": -1}
113
+
114
+ with tempfile.NamedTemporaryFile(suffix=".py", delete=False, mode="w", encoding="utf-8") as fp:
115
+ fp.write(code)
116
+ path = fp.name
117
+ try:
118
+ proc = await asyncio.create_subprocess_exec(
119
+ sys.executable, path,
120
+ stdout=asyncio.subprocess.PIPE,
121
+ stderr=asyncio.subprocess.PIPE,
122
+ )
123
+ try:
124
+ stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=15.0)
125
+ except asyncio.TimeoutError:
126
+ proc.kill()
127
+ return {"error": "Timeout 15s superato", "stdout": "", "returncode": -1}
128
+ return {
129
+ "stdout": stdout.decode("utf-8", errors="replace")[:3000],
130
+ "stderr": stderr.decode("utf-8", errors="replace")[:500],
131
+ "returncode": proc.returncode,
132
+ }
133
+ finally:
134
+ try:
135
+ os.unlink(path)
136
+ except Exception:
137
+ pass
138
+
139
+
140
+ # ─── Registry ─────────────────────────────────────────────
141
+
142
+ TOOL_REGISTRY: dict[str, dict] = {
143
+ "web_search": {
144
+ "name": "web_search",
145
+ "goal": "Cerca informazioni aggiornate sul web",
146
+ "description": "Esegue ricerca DuckDuckGo e restituisce risultati rilevanti",
147
+ "required_inputs": ["query"],
148
+ "optional_inputs": {"max_results": 5},
149
+ "risk_level": "low",
150
+ "fallbacks": ["direct_response"],
151
+ "_fn": _web_search,
152
+ },
153
+ "read_page": {
154
+ "name": "read_page",
155
+ "goal": "Legge il contenuto di una pagina web",
156
+ "description": "Scarica e pulisce il testo di una URL",
157
+ "required_inputs": ["url"],
158
+ "optional_inputs": {},
159
+ "risk_level": "low",
160
+ "fallbacks": ["web_search"],
161
+ "_fn": _read_page,
162
+ },
163
+ "get_weather": {
164
+ "name": "get_weather",
165
+ "goal": "Ottieni meteo attuale per una città",
166
+ "description": "Usa Open-Meteo (gratuito, no API key) per meteo in tempo reale",
167
+ "required_inputs": ["city"],
168
+ "optional_inputs": {},
169
+ "risk_level": "low",
170
+ "fallbacks": [],
171
+ "_fn": _get_weather,
172
+ },
173
+ "calculate": {
174
+ "name": "calculate",
175
+ "goal": "Calcola espressioni matematiche in modo sicuro",
176
+ "description": "Valuta espressioni matematiche senza exec() — solo operatori sicuri",
177
+ "required_inputs": ["expression"],
178
+ "optional_inputs": {},
179
+ "risk_level": "low",
180
+ "fallbacks": [],
181
+ "_fn": _calculate,
182
+ },
183
+ "run_python": {
184
+ "name": "run_python",
185
+ "goal": "Esegui codice Python reale sul server",
186
+ "description": "Esegue Python vero: calcoli, data processing, file I/O, librerie stdlib. Timeout 15s.",
187
+ "required_inputs": ["code"],
188
+ "optional_inputs": {},
189
+ "risk_level": "medium",
190
+ "fallbacks": [],
191
+ "_fn": _run_python,
192
+ },
193
+ }
tools/repo_reader.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """repo_reader.py — Legge struttura e file di un repository locale."""
2
+ from pathlib import Path
3
+ SKIP={'.git','node_modules','__pycache__','.venv','dist','build'}
4
+ TEXT={'.py','.ts','.tsx','.js','.jsx','.html','.css','.md','.json','.yaml','.yml','.toml','.txt','.sh'}
5
+
6
+ def read_tree(root:str,max_files:int=80)->dict:
7
+ rp=Path(root)
8
+ if not rp.exists(): return {"error":f"Path non trovato: {root}","files":[]}
9
+ files=[]
10
+ for p in sorted(rp.rglob("*")):
11
+ if len(files)>=max_files: break
12
+ if any(s in p.parts for s in SKIP): continue
13
+ if p.is_file():
14
+ rel=str(p.relative_to(rp)); sz=p.stat().st_size
15
+ files.append({"path":rel,"size":sz,"type":"text" if p.suffix.lower() in TEXT else "binary","ext":p.suffix})
16
+ return {"root":root,"total_files":len(files),"files":files}
17
+
18
+ def read_file(root:str,filepath:str)->dict:
19
+ full=Path(root)/filepath
20
+ if not full.exists(): return {"error":f"Non trovato: {filepath}","content":""}
21
+ if full.stat().st_size>50000: return {"error":"File troppo grande","content":""}
22
+ if full.suffix.lower() not in TEXT and full.suffix!="": return {"error":"File binario","content":""}
23
+ try:
24
+ c=full.read_text(encoding="utf-8",errors="replace")
25
+ return {"path":filepath,"content":c,"lines":c.count("\n")+1,"size":len(c)}
26
+ except Exception as e: return {"error":str(e),"content":""}
27
+
28
+ def read_files_batch(root:str,paths:list[str])->list[dict]:
29
+ return [read_file(root,p) for p in paths[:10]]
tools/web_fetch.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """web_fetch.py — Scarica + pulisce una pagina web. NON restituisce HTML grezzo."""
2
+ import httpx, re
3
+ from urllib.parse import urlparse
4
+ USER_AGENT="Mozilla/5.0 (compatible; AgenteAI/2.0)"
5
+ MAX_CONTENT=6000
6
+
7
+ def _clean_html(html:str)->str:
8
+ html=re.sub(r"<(script|style|nav|footer|header|noscript|aside)[^>]*>.*?</\1>"," ",html,flags=re.S|re.I)
9
+ text=re.sub(r"<[^>]+>"," ",html)
10
+ text=re.sub(r"[ \t]+"," ",text)
11
+ text=re.sub(r"\n{3,}","\n\n",text)
12
+ return text.strip()
13
+
14
+ def _extract_meta(html:str)->dict:
15
+ t=re.search(r"<title[^>]*>([^<]+)</title>",html,re.I)
16
+ d=re.search(r'<meta[^>]+name=["\'\']description["\'\'][^>]+content=["\'\']([^"\'\']+ )["\'\']',html,re.I)
17
+ return {"title":t.group(1).strip() if t else "","description":d.group(1).strip() if d else ""}
18
+
19
+ async def fetch_page(url:str, max_chars:int=MAX_CONTENT)->dict:
20
+ try:
21
+ parsed=urlparse(url)
22
+ if parsed.scheme not in ("http","https"): return {"url":url,"error":"Schema non supportato","content":""}
23
+ async with httpx.AsyncClient(timeout=15,follow_redirects=True,headers={"User-Agent":USER_AGENT}) as c:
24
+ r=await c.get(url)
25
+ html=r.text; meta=_extract_meta(html); text=_clean_html(html)
26
+ if len(text)>max_chars: text=text[:max_chars]+"\n[...troncato...]"
27
+ return {"url":url,"title":meta["title"],"description":meta["description"],"content":text,"status":r.status_code,"chars":len(text)}
28
+ except httpx.TimeoutException: return {"url":url,"error":"Timeout","content":""}
29
+ except Exception as e: return {"url":url,"error":str(e),"content":""}
tools/web_search.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """web_search.py — Pipeline: search → rank → cite"""
2
+ import httpx, re, asyncio
3
+ from urllib.parse import quote_plus
4
+
5
+ MAX_RESULTS = 8
6
+
7
+ async def search_ddg(query: str, max_results: int = MAX_RESULTS) -> list[dict]:
8
+ try:
9
+ async with httpx.AsyncClient(timeout=8, follow_redirects=True) as c:
10
+ r = await c.get("https://api.duckduckgo.com/",
11
+ params={"q": query, "format": "json", "no_html": "1", "skip_disambig": "1"})
12
+ d = r.json(); results = []
13
+ if d.get("AbstractText"):
14
+ results.append({"source":"DDG","title":d.get("Heading",query),"snippet":d["AbstractText"][:400],"url":d.get("AbstractURL",""),"score":1.0})
15
+ for t in d.get("RelatedTopics",[])[:max_results]:
16
+ if isinstance(t,dict) and t.get("Text") and t.get("FirstURL"):
17
+ results.append({"source":"DDG","title":t["Text"][:80],"snippet":t["Text"][:250],"url":t["FirstURL"],"score":0.7})
18
+ return results[:max_results]
19
+ except Exception as e:
20
+ return [{"source":"DDG","error":str(e),"title":"","snippet":"","url":"","score":0}]
21
+
22
+ async def search_hackernews(query: str, max_results: int = 5) -> list[dict]:
23
+ try:
24
+ async with httpx.AsyncClient(timeout=8) as c:
25
+ r = await c.get("https://hn.algolia.com/api/v1/search",
26
+ params={"query":query,"hitsPerPage":max_results,"tags":"story"})
27
+ return [{"source":"HackerNews","title":h.get("title","")[:100],
28
+ "snippet":(h.get("story_text") or "")[:300],
29
+ "url":h.get("url") or f"https://news.ycombinator.com/item?id={h.get('objectID','')}",
30
+ "score":min(1.0,(h.get("points",0) or 0)/500)} for h in r.json().get("hits",[]) if h.get("title")]
31
+ except: return []
32
+
33
+ async def search_stackoverflow(query: str, max_results: int = 4) -> list[dict]:
34
+ import re as _re
35
+ try:
36
+ async with httpx.AsyncClient(timeout=8) as c:
37
+ r = await c.get("https://api.stackexchange.com/2.3/search/advanced",
38
+ params={"q":query,"site":"stackoverflow","pagesize":max_results,"order":"desc","sort":"relevance","filter":"withbody"})
39
+ return [{"source":"StackOverflow","title":it.get("title","")[:100],
40
+ "snippet":_re.sub(r"<[^>]+>"," ",it.get("body",""))[:250],
41
+ "url":it.get("link",""),"score":min(1.0,(it.get("score",0) or 0)/100),
42
+ "answered":it.get("is_answered",False)} for it in r.json().get("items",[])]
43
+ except: return []
44
+
45
+ async def web_search(query:str, focus:str="general", max_results:int=6) -> dict:
46
+ tasks = [search_ddg(query, max_results)]
47
+ if focus in ("technical","code"): tasks.append(search_stackoverflow(query,4))
48
+ if focus in ("news","general","technical"): tasks.append(search_hackernews(query,4))
49
+ all_results=[]
50
+ for batch in await asyncio.gather(*tasks, return_exceptions=True):
51
+ if isinstance(batch,list): all_results.extend(batch)
52
+ seen,ranked=set(),[]
53
+ for r in sorted(all_results,key=lambda x:x.get("score",0),reverse=True):
54
+ url=r.get("url","")
55
+ if url and url not in seen: seen.add(url); ranked.append(r)
56
+ elif not url: ranked.append(r)
57
+ ranked=ranked[:max_results]
58
+ return {"query":query,"focus":focus,"total":len(ranked),"results":ranked,"sources":list({r["source"] for r in ranked})}
tsconfig.json ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "include": ["src/**/*"],
4
+ "exclude": [
5
+ "node_modules", "build", "dist", "**/*.test.ts",
6
+ "src/components/ui",
7
+ "src/hooks/use-toast.ts",
8
+ "src/pages/not-found.tsx",
9
+ "src/lib/utils.ts"
10
+ ],
11
+ "compilerOptions": {
12
+ "noEmit": true,
13
+ "jsx": "preserve",
14
+ "lib": ["esnext", "dom", "dom.iterable"],
15
+ "resolveJsonModule": true,
16
+ "allowImportingTsExtensions": true,
17
+ "moduleResolution": "bundler",
18
+ "types": ["node", "vite/client"],
19
+ "paths": {
20
+ "@/*": ["./src/*"]
21
+ }
22
+ }
23
+ }
vite.config.ts ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from "vite";
2
+ import path from "node:path";
3
+
4
+ export default defineConfig({
5
+ root: ".",
6
+
7
+ server: {
8
+ host: "0.0.0.0",
9
+ port: 5174,
10
+ strictPort: false,
11
+ open: false,
12
+ cors: true,
13
+
14
+ fs: {
15
+ strict: false,
16
+ allow: [
17
+ path.resolve(__dirname),
18
+ ],
19
+ },
20
+
21
+ proxy: {
22
+ "/api": {
23
+ target: "http://localhost:8000",
24
+ changeOrigin: true,
25
+ secure: false,
26
+ },
27
+
28
+ "/ws": {
29
+ target: "ws://localhost:8000",
30
+ ws: true,
31
+ changeOrigin: true,
32
+ },
33
+ },
34
+ },
35
+
36
+ preview: {
37
+ host: "0.0.0.0",
38
+ port: 4174,
39
+ },
40
+
41
+ resolve: {
42
+ alias: {
43
+ "@": path.resolve(__dirname, "./src"),
44
+ "@backend": path.resolve(__dirname, "./"),
45
+ },
46
+ },
47
+
48
+ build: {
49
+ target: "es2022",
50
+
51
+ outDir: "dist",
52
+
53
+ emptyOutDir: true,
54
+
55
+ sourcemap: false,
56
+
57
+ minify: "esbuild",
58
+
59
+ chunkSizeWarningLimit: 1200,
60
+
61
+ rollupOptions: {
62
+ output: {
63
+ manualChunks: {
64
+ vendor: [
65
+ "react",
66
+ "react-dom",
67
+ ],
68
+ },
69
+ },
70
+ },
71
+ },
72
+
73
+ optimizeDeps: {
74
+ exclude: [
75
+ "@xenova/transformers",
76
+ "@mlc-ai/web-llm",
77
+ ],
78
+ },
79
+
80
+ define: {
81
+ __DEV__: JSON.stringify(process.env.NODE_ENV !== "production"),
82
+ },
83
+ });