diff --git a/.claude/rules/memory-recall.md b/.claude/rules/memory-recall.md
new file mode 100644
index 00000000..6c457d81
--- /dev/null
+++ b/.claude/rules/memory-recall.md
@@ -0,0 +1,92 @@
+# Memory Recall & Self-Learning — Protocolo dos Agentes
+
+Sessões são efêmeras (`/clear`, redeploys, novos terminais); memória não pode
+ser. Este protocolo garante que todo agente **recupere contexto no início** e
+**persista aprendizados no final** — o ciclo de self-learning do workspace.
+
+## 1. Recall — no início de toda sessão/tarefa
+
+Antes de trabalhar, recupere contexto nesta ordem (barato → caro):
+
+1. **Hot cache** — `CLAUDE.md` já está no contexto (automático).
+2. **Memória do agente** — leia `.claude/agent-memory/{seu-agente}/` (em
+ especial `MEMORY.md` / `learnings.md` se existirem). É a SUA memória entre
+ sessões; se o diretório está vazio, você é novo — comece a populá-lo.
+3. **Busca semântica (MemPalace)** — quando o assunto tem histórico provável,
+ consulte a base de conhecimento:
+
+ ```python
+ from dashboard.backend.sdk_client import evo
+ hits = evo.get("/api/mempalace/search", params={"q": "deploy VPS omniroute", "n": 5})
+ ```
+
+ Indexa `memory/`, `.claude/agent-memory/` e `workspace/development/` —
+ decisões, incidentes, retros e aprendizados de TODOS os agentes.
+4. **Inbox de tickets (kanban)** — trabalho persistente atribuído a você:
+
+ ```python
+ abertos = evo.get("/api/tickets", params={"assignee_agent": "{seu-slug}", "status": "open"})
+ ```
+
+ Ticket relevante ao assunto atual → faça checkout atômico antes de agir
+ (`POST /api/tickets/{id}/checkout`) e comente o progresso.
+
+**Regra prática:** se o usuário mencionar algo que soa como já discutido
+("aquele bug", "como fizemos antes", "o problema do X"), consulte o MemPalace
+ANTES de dizer que não sabe.
+
+## 2. Self-learning — ao final de toda tarefa não-trivial
+
+Persista o que a próxima sessão vai precisar:
+
+1. **Aprendizado do agente** — acrescente em
+ `.claude/agent-memory/{seu-agente}/learnings.md` (crie se não existir):
+
+ ```markdown
+ ## 2026-07-07 — Bot Telegram 401 com omnirouter
+ **O que aconteceu:** chave NVIDIA do env sequestrava chamadas ao gateway.
+ **Lição:** chave do provider em providers.json SEMPRE vence o env global.
+ **Aplicar quando:** qualquer 401 em provider OpenAI-compatível.
+ ```
+
+ Formato livre, mas sempre com **lição** e **quando aplicar**. Datas
+ absolutas, nunca "hoje/ontem".
+2. **Fato do workspace** (afeta outros agentes ou o negócio) → registre em
+ `memory/` seguindo o padrão LLM Wiki (ver seção Memory System do
+ CLAUDE.md); a rotina memory-sync propaga.
+3. **Trabalho inacabado** → vira ticket (`POST /api/tickets`), nunca só uma
+ nota na conversa. Conversa morre; ticket fica no kanban.
+4. **Reindexar** — os arquivos novos entram na busca semântica no próximo
+ mine. Depois de gravar aprendizados importantes, dispare:
+
+ ```python
+ evo.post("/api/mempalace/mine") # indexa todas as fontes (idempotente)
+ ```
+
+## 3. O que NUNCA fazer
+
+- Não confie que "a sessão anterior sabe" — ela não existe mais após /clear.
+- Não duplique: antes de criar memória nova, busque se já existe (atualize o
+ arquivo existente em vez de criar outro).
+- Não guarde segredos (keys, tokens, senhas) em memórias ou learnings.
+- Não salve o que o repositório já registra (código, git history, CLAUDE.md).
+
+## Infraestrutura (referência)
+
+| Peça | Onde | Persistência |
+|---|---|---|
+| MemPalace (índice chroma + fontes) | `dashboard/data/mempalace/` | volume `evonexus_dashboard_data` |
+| Memória dos agentes | `.claude/agent-memory/` | volume `evonexus_agent_memory` |
+| Memória do workspace | `memory/` | volume `evonexus_memory` |
+| Tickets/kanban | SQLite `dashboard.db` | volume `evonexus_dashboard_data` |
+
+API MemPalace: `GET /api/mempalace/status` · `GET /api/mempalace/search?q=&n=` ·
+`POST /api/mempalace/mine` · fontes em `GET/POST /api/mempalace/sources`.
+O pacote vem pré-instalado na imagem do dashboard; fontes padrão são seedadas
+no primeiro uso.
+
+## Regras relacionadas
+
+- `tickets.md` — inbox, checkout atômico, menções
+- `agents.md` — agent-memory por agente, EvoClient
+- `heartbeats.md` — heartbeats consomem o mesmo inbox
diff --git a/.claude/skills/ai-image-creator/SKILL.md b/.claude/skills/ai-image-creator/SKILL.md
index 135d35ad..9a3c642f 100644
--- a/.claude/skills/ai-image-creator/SKILL.md
+++ b/.claude/skills/ai-image-creator/SKILL.md
@@ -22,6 +22,23 @@ When the user mentions a model keyword in their image request, use the correspon
| `flux2` | [FLUX.2 Max](https://openrouter.ai/black-forest-labs/flux.2-max) | "flux2", "flux", "use flux" |
| `seedream` | [ByteDance SeedDream 4.5](https://openrouter.ai/bytedance-seed/seedream-4.5) | "seedream", "use seedream" |
| `gpt5` | [OpenAI GPT-5 Image](https://openrouter.ai/openai/gpt-5-image) | "gpt5", "gpt5 image", "use gpt5" |
+| `nvidia-flux2-klein-4b` | BFL FLUX.2 Klein 4B via NVIDIA NIM (default for `--provider nvidia`) | "nvidia", "flux nvidia", "nvidia flux2" |
+| `nvidia-flux-dev` | BFL FLUX.1-dev via NVIDIA NIM (30 steps, high quality) | "nvidia flux dev" |
+| `nvidia-flux-schnell` | BFL FLUX.1-schnell via NVIDIA NIM (4 steps, fastest) | "nvidia schnell", "flux rápido" |
+| `image2` | OpenAI GPT Image 2 via Images API direta (default for `--provider openai`) | "image2", "gpt image 2", "imagem openai" |
+
+**OpenAI Images API notes (`image2`):**
+- Requires a **platform API key** (`AI_IMG_CREATOR_OPENAI_KEY` or `OPENAI_API_KEY`, billing on platform.openai.com).
+- **ChatGPT Plus / Codex OAuth tokens CANNOT generate images** — they lack the `api.model.images.request` scope (verified: API returns 401). Do not suggest the Codex login as an image path.
+- `-a` maps to the supported sizes (1024x1024, 1536x1024, 1024x1536). `--image-size`, `-r` (reference images) and `--analyze` are not supported on this provider.
+- Excellent text rendering (including Portuguese accents) — good choice for text-critical art.
+
+**NVIDIA NIM notes:**
+- Free tier with generous credits — good fallback when OpenRouter hits rate limits.
+- Generation is fast (~2-5s). Output is JPEG, auto-converted to PNG via ImageMagick/ffmpeg.
+- Dimensions are restricted (768-1344 per side, ≤1.06MP total) — `-a` maps to the closest supported size. `--image-size` is not supported.
+- Text rendering (especially Portuguese accents like Ç/Ã) is hit-or-miss on all FLUX variants — for text-critical art, generate 2-3 candidates and pick, or use `gemini`/`gpt5` which render text reliably.
+- Reference images (`-r`) and `--analyze` are not supported on the NVIDIA provider.
## Instructions
@@ -99,6 +116,7 @@ The script auto-loads env vars from the workspace `.env`. Choose provider based
- If `AI_IMG_CREATOR_CF_ACCOUNT_ID` + `AI_IMG_CREATOR_CF_GATEWAY_ID` + `AI_IMG_CREATOR_CF_TOKEN` are set → use default (gateway mode, no flag needed)
- If only `AI_IMG_CREATOR_OPENROUTER_KEY` is set → use default (`--provider openrouter`, implicit)
- If only `AI_IMG_CREATOR_GEMINI_KEY` is set → use `--provider google`
+- If `NVIDIA_API_KEY` is set → `--provider nvidia` is available (FLUX models, free tier). Auto-selected when the model keyword starts with `nvidia-`
- **Do NOT use `source .env`** — the Python script loads it internally. Just run the command directly.
### Step 2: Run Generation Script
@@ -193,6 +211,7 @@ If the user needs resizing, format conversion, or other manipulation, first dete
| `AI_IMG_CREATOR_CF_TOKEN` | Gateway mode | Gateway auth token |
| `AI_IMG_CREATOR_OPENROUTER_KEY` | Direct OpenRouter | OpenRouter API key (`sk-or-...`) |
| `AI_IMG_CREATOR_GEMINI_KEY` | Direct Google | Google AI Studio API key |
+| `NVIDIA_API_KEY` | NVIDIA NIM | NVIDIA API key (`nvapi-...`) — same key used by the dashboard NVIDIA provider |
Gateway mode activates when all 3 `CF_*` vars are set. Falls back to direct mode if gateway fails.
diff --git a/.claude/skills/ai-image-creator/scripts/generate-image.py b/.claude/skills/ai-image-creator/scripts/generate-image.py
index 71b040a2..3de668f9 100644
--- a/.claude/skills/ai-image-creator/scripts/generate-image.py
+++ b/.claude/skills/ai-image-creator/scripts/generate-image.py
@@ -1,19 +1,22 @@
#!/usr/bin/env python3
-"""AI Image Generator — Generate PNG images via multiple OpenRouter models or Google AI Studio.
+"""AI Image Generator — Generate PNG images via multiple providers.
Supports multiple image generation models via keyword shortcuts:
- gemini — Google Gemini 3.1 Flash (default, multimodal)
- riverflow — Sourceful Riverflow v2 Fast (image-only)
- flux2 — Black Forest Labs FLUX.2 Klein 4B (image-only)
- seedream — ByteDance SeedDream 4.5 (image-only)
- gpt5 — OpenAI GPT-5 Image Mini (multimodal)
-
-Routes through Cloudflare AI Gateway BYOK when configured, with automatic
-fallback to direct API calls. Uses only Python stdlib (no pip dependencies).
+ gemini — Google Gemini 3.1 Flash (multimodal, OpenRouter)
+ riverflow — Sourceful Riverflow v2 Fast (image-only, OpenRouter)
+ flux2 — Black Forest Labs FLUX.2 Klein 4B (image-only, OpenRouter)
+ seedream — ByteDance SeedDream 4.5 (image-only, OpenRouter)
+ gpt5 — OpenAI GPT-5 Image Mini (multimodal, OpenRouter)
+ nvidia-flux2-klein-4b — BFL FLUX.2 Klein 4B via NVIDIA NIM (default nvidia, best text rendering)
+ nvidia-flux-dev — BFL FLUX.1-dev via NVIDIA NIM (high quality)
+ nvidia-flux-schnell — BFL FLUX.1-schnell via NVIDIA NIM (fastest)
+
+Routes through Cloudflare AI Gateway BYOK when configured (OpenRouter/Google only),
+with automatic fallback to direct API calls. Uses only Python stdlib (no pip dependencies).
Usage:
uv run python generate-image.py --output path.png --prompt "description"
- uv run python generate-image.py --output path.png --model riverflow --prompt "description"
+ uv run python generate-image.py --output path.png --model nvidia-flux2-klein-4b --prompt "description"
uv run python generate-image.py --output path.png --prompt-file prompt.txt
uv run python generate-image.py --list-models
"""
@@ -40,6 +43,39 @@
DEFAULT_MODELS = {
"openrouter": "google/gemini-3.1-flash-image-preview",
"google": "gemini-3.1-flash-image-preview",
+ "nvidia": "black-forest-labs/flux.2-klein-4b",
+ "openai": "gpt-image-2",
+}
+
+# Aspect ratio → size string accepted by the OpenAI Images API
+# (gpt-image models accept 1024x1024, 1536x1024, 1024x1536, auto).
+OPENAI_SIZE_MAP = {
+ "1:1": "1024x1024",
+ "16:9": "1536x1024",
+ "3:2": "1536x1024",
+ "4:3": "1536x1024",
+ "5:4": "1536x1024",
+ "21:9": "1536x1024",
+ "9:16": "1024x1536",
+ "2:3": "1024x1536",
+ "3:4": "1024x1536",
+ "4:5": "1024x1536",
+}
+
+# Aspect ratio → (width, height) within the dimension set NVIDIA NIM accepts
+# (768, 832, 896, 960, 1024, 1088, 1152, 1216, 1280, 1344) AND the total
+# pixel budget (width × height <= 1,062,400 — validated by the API).
+NVIDIA_ASPECT_MAP = {
+ "1:1": (1024, 1024),
+ "16:9": (1344, 768),
+ "9:16": (768, 1344),
+ "3:2": (1152, 768),
+ "2:3": (768, 1152),
+ "4:3": (1024, 768),
+ "3:4": (768, 1024),
+ "5:4": (1024, 832),
+ "4:5": (832, 1024),
+ "21:9": (1344, 768), # closest supported — true 21:9 unavailable
}
# Model registry — maps keyword shortcuts to model metadata.
@@ -71,6 +107,58 @@
"modalities": ["image", "text"],
"description": "OpenAI GPT-5 Image — multimodal (text+image)",
},
+ # OpenAI Images API — requires a PLATFORM API key (api.openai.com billing).
+ # ChatGPT Plus / Codex OAuth tokens lack the api.model.images.request
+ # scope and cannot generate images (verified empirically — 401).
+ "image2": {
+ "id": "gpt-image-2",
+ "provider": "openai",
+ "modalities": ["image"],
+ "description": "OpenAI GPT Image 2 — Images API direta (requer API key de plataforma)",
+ },
+ # NVIDIA NIM Flux models
+ "nvidia-flux-dev": {
+ "id": "black-forest-labs/flux.1-dev",
+ "provider": "nvidia",
+ "modalities": ["image"],
+ "description": "Black Forest Labs FLUX.1-dev — high quality, NVIDIA NIM",
+ "nvidia_params": {
+ "width": {"default": 1024, "enum": [768, 832, 896, 960, 1024, 1088, 1152, 1216, 1280, 1344]},
+ "height": {"default": 1024, "enum": [768, 832, 896, 960, 1024, 1088, 1152, 1216, 1280, 1344]},
+ "cfg_scale": {"type": "number", "default": 5, "minimum": 1, "maximum": 9, "description": "How strictly the diffusion process adheres to the prompt text"},
+ "steps": {"type": "integer", "default": 30, "minimum": 1, "maximum": 50, "description": "Number of diffusion steps"},
+ "seed": {"type": "integer", "default": 0, "minimum": 0, "exclusiveMaximum": 4294967296, "description": "Random seed (0 for random)"},
+ "samples": {"type": "integer", "default": 1, "minimum": 1, "maximum": 1},
+ },
+ },
+ "nvidia-flux-schnell": {
+ "id": "black-forest-labs/flux.1-schnell",
+ "provider": "nvidia",
+ "modalities": ["image"],
+ "description": "Black Forest Labs FLUX.1-schnell — fast, NVIDIA NIM",
+ "nvidia_params": {
+ "width": {"default": 1024, "enum": [768, 832, 896, 960, 1024, 1088, 1152, 1216, 1280, 1344]},
+ "height": {"default": 1024, "enum": [768, 832, 896, 960, 1024, 1088, 1152, 1216, 1280, 1344]},
+ "cfg_scale": {"type": "number", "default": 0, "minimum": 0, "maximum": 0},
+ "steps": {"type": "integer", "default": 4, "minimum": 1, "maximum": 30},
+ "seed": {"type": "integer", "default": 0, "minimum": 0, "exclusiveMaximum": 4294967296},
+ "samples": {"type": "integer", "default": 1, "minimum": 1, "maximum": 1},
+ },
+ },
+ "nvidia-flux2-klein-4b": {
+ "id": "black-forest-labs/flux.2-klein-4b",
+ "provider": "nvidia",
+ "modalities": ["image"],
+ "description": "Black Forest Labs FLUX.2 Klein 4B — fastest, NVIDIA NIM",
+ "nvidia_params": {
+ "width": {"default": 1024, "enum": [768, 832, 896, 960, 1024, 1088, 1152, 1216, 1280, 1344]},
+ "height": {"default": 1024, "enum": [768, 832, 896, 960, 1024, 1088, 1152, 1216, 1280, 1344]},
+ "cfg_scale": {"type": "number", "default": 1, "minimum": 1, "maximum": 9},
+ "steps": {"type": "integer", "default": 4, "minimum": 1, "maximum": 4},
+ "seed": {"type": "integer", "default": 0, "minimum": 0, "exclusiveMaximum": 4294967296},
+ "samples": {"type": "integer", "default": 1, "minimum": 1, "maximum": 1},
+ },
+ },
}
# Environment variable names (prefixed to avoid collisions)
@@ -79,6 +167,8 @@
ENV_CF_TOKEN = "AI_IMG_CREATOR_CF_TOKEN"
ENV_OPENROUTER_KEY = "AI_IMG_CREATOR_OPENROUTER_KEY"
ENV_GEMINI_KEY = "AI_IMG_CREATOR_GEMINI_KEY"
+ENV_NVIDIA_KEY = "NVIDIA_API_KEY"
+ENV_OPENAI_KEY = "AI_IMG_CREATOR_OPENAI_KEY" # falls back to OPENAI_API_KEY
def _load_dotenv() -> None:
"""Load .env files into os.environ (stdlib only, no pip deps).
@@ -156,18 +246,18 @@ def resolve_model(model_arg: str | None, provider: str) -> tuple[str, list[str]]
"""Resolve a model keyword or full ID to (model_id, modalities).
Supports three modes:
- 1. No --model flag: returns the default model for the provider (gemini).
+ 1. No --model flag: returns the default model for the provider.
2. Keyword match (e.g. 'riverflow'): looks up MODEL_REGISTRY.
3. Full model ID (e.g. 'sourceful/riverflow-v2-pro'): reverse-lookups
registry for modalities, or defaults to ["image", "text"] if unknown.
Args:
model_arg: The --model CLI value (keyword, full model ID, or None).
- provider: Either 'openrouter' or 'google'.
+ provider: 'openrouter', 'google', or 'nvidia'.
Returns:
Tuple of (model_id, modalities_list) where model_id is the full
- OpenRouter model identifier and modalities_list is the correct
+ model identifier and modalities_list is the correct
modalities array for the API request.
"""
if model_arg is None:
@@ -175,6 +265,8 @@ def resolve_model(model_arg: str | None, provider: str) -> tuple[str, list[str]]
if provider == "openrouter":
entry = MODEL_REGISTRY.get("gemini", {})
return model_id, entry.get("modalities", ["image", "text"])
+ if provider in ("nvidia", "openai"):
+ return model_id, ["image"]
return model_id, ["image", "text"]
# Check keyword match (case-insensitive)
@@ -203,7 +295,7 @@ def parse_args() -> argparse.Namespace:
image_size, model, list_models, debug, and verbose attributes.
"""
parser = argparse.ArgumentParser(
- description="Generate PNG images using AI (multiple models via OpenRouter/Google AI Studio)"
+ description="Generate PNG images using AI (OpenRouter, Google AI Studio, or NVIDIA NIM)"
)
parser.add_argument(
"-o", "--output", required=False, default=None, help="Output PNG file path (required unless --list-models)"
@@ -218,9 +310,9 @@ def parse_args() -> argparse.Namespace:
)
parser.add_argument(
"--provider",
- choices=["openrouter", "google"],
+ choices=["openrouter", "google", "nvidia", "openai"],
default="openrouter",
- help="API provider (default: openrouter)",
+ help="API provider (default: openrouter). Use 'nvidia' for NVIDIA NIM Flux models, 'openai' for GPT Image via Images API.",
)
parser.add_argument(
"-a", "--aspect-ratio",
@@ -235,7 +327,7 @@ def parse_args() -> argparse.Namespace:
parser.add_argument(
"-m", "--model",
default=None,
- help="Model keyword (gemini, riverflow, flux2, seedream, gpt5) or full model ID",
+ help="Model keyword (gemini, riverflow, flux2, seedream, gpt5, nvidia-flux2-klein-4b, nvidia-flux-dev, nvidia-flux-schnell) or full model ID",
)
parser.add_argument(
"-r", "--ref",
@@ -264,6 +356,37 @@ def parse_args() -> argparse.Namespace:
action="store_true",
help="List available model keywords and exit",
)
+ # NVIDIA NIM specific parameters
+ parser.add_argument(
+ "--width",
+ type=int,
+ default=None,
+ help="Image width for NVIDIA Flux models (768-1344, default: 1024)",
+ )
+ parser.add_argument(
+ "--height",
+ type=int,
+ default=None,
+ help="Image height for NVIDIA Flux models (768-1344, default: 1024)",
+ )
+ parser.add_argument(
+ "--cfg-scale",
+ type=float,
+ default=None,
+ help="Classifier-free guidance scale (default: 5 for flux-dev, 0 for others)",
+ )
+ parser.add_argument(
+ "--steps",
+ type=int,
+ default=None,
+ help="Number of diffusion steps (default: 30 for flux-dev, 4 for schnell/flux2)",
+ )
+ parser.add_argument(
+ "--seed",
+ type=int,
+ default=None,
+ help="Random seed (0 for random, default: 0)",
+ )
parser.add_argument(
"--debug",
action="store_true",
@@ -364,9 +487,31 @@ def detect_mode(provider: str) -> tuple[str, dict[str, str]]:
if provider == "openrouter":
direct_key = os.environ.get(ENV_OPENROUTER_KEY, "").strip()
log.debug(f"Env check: {ENV_OPENROUTER_KEY}={'set (' + mask_key(direct_key) + ')' if direct_key else 'MISSING'}")
- else:
+ elif provider == "google":
direct_key = os.environ.get(ENV_GEMINI_KEY, "").strip()
log.debug(f"Env check: {ENV_GEMINI_KEY}={'set (' + mask_key(direct_key) + ')' if direct_key else 'MISSING'}")
+ elif provider == "nvidia":
+ direct_key = os.environ.get(ENV_NVIDIA_KEY, "").strip()
+ log.debug(f"Env check: {ENV_NVIDIA_KEY}={'set (' + mask_key(direct_key) + ')' if direct_key else 'MISSING'}")
+ elif provider == "openai":
+ direct_key = (
+ os.environ.get(ENV_OPENAI_KEY, "").strip()
+ or os.environ.get("OPENAI_API_KEY", "").strip()
+ )
+ log.debug(f"Env check: {ENV_OPENAI_KEY}/OPENAI_API_KEY={'set (' + mask_key(direct_key) + ')' if direct_key else 'MISSING'}")
+ else:
+ log.debug(f"Unknown provider: {provider}")
+ direct_key = ""
+
+ # NVIDIA NIM and OpenAI Images always use direct mode (no gateway)
+ if provider in ("nvidia", "openai") and direct_key:
+ log.info(f"Mode: direct ({provider} always uses direct API)")
+ return "direct", {"direct_key": direct_key}
+
+ # The gateway path is OpenRouter/Google-shaped — never route openai
+ # (Images API) through it.
+ if provider == "openai":
+ has_gateway = False
if has_gateway:
log.info(f"Mode: gateway (account={cf_account}, gateway={cf_gateway})")
@@ -391,9 +536,16 @@ def detect_mode(provider: str) -> tuple[str, dict[str, str]]:
if provider == "openrouter":
print("For direct OpenRouter access, set:", file=sys.stderr)
print(f" export {ENV_OPENROUTER_KEY}=sk-or-...", file=sys.stderr)
- else:
+ elif provider == "google":
print("For direct Google AI Studio access, set:", file=sys.stderr)
print(f" export {ENV_GEMINI_KEY}=AI...", file=sys.stderr)
+ elif provider == "nvidia":
+ print("For NVIDIA NIM access, set:", file=sys.stderr)
+ print(f" export {ENV_NVIDIA_KEY}=your-nvidia-api-key", file=sys.stderr)
+ elif provider == "openai":
+ print("For OpenAI Images API access, set a PLATFORM key (billing on", file=sys.stderr)
+ print("platform.openai.com — ChatGPT Plus/Codex OAuth cannot generate images):", file=sys.stderr)
+ print(f" export {ENV_OPENAI_KEY}=sk-... (or OPENAI_API_KEY)", file=sys.stderr)
print("", file=sys.stderr)
print(
"See references/setup-guide.md for full setup instructions.",
@@ -434,8 +586,14 @@ def build_direct_url(provider: str, model: str) -> str:
"""
if provider == "openrouter":
url = "https://openrouter.ai/api/v1/chat/completions"
- else:
+ elif provider == "google":
url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent"
+ elif provider == "nvidia":
+ url = build_nvidia_url(model)
+ elif provider == "openai":
+ url = "https://api.openai.com/v1/images/generations"
+ else:
+ raise RuntimeError(f"Unknown provider for URL: {provider}")
log.debug(f"Built direct URL: {url}")
return url
@@ -456,7 +614,13 @@ def build_headers(provider: str, mode: str, config: dict[str, str]) -> dict[str,
"User-Agent": "ai-image-creator/1.0",
}
- if mode == "gateway":
+ if provider == "nvidia":
+ headers = build_nvidia_headers(config["direct_key"])
+ # Skip all other header logic for NVIDIA
+ safe_headers = {k: (f"{v[:12]}...{mask_key(v)}" if k.lower() in ("authorization", "cf-aig-authorization", "x-goog-api-key") else v) for k, v in headers.items()}
+ log.debug(f"Request headers: {json.dumps(safe_headers, indent=2)}")
+ return headers
+ elif mode == "gateway":
headers["cf-aig-authorization"] = f"Bearer {config['cf_token']}"
if provider == "google":
headers["cf-aig-byok-alias"] = "aistudio"
@@ -465,8 +629,10 @@ def build_headers(provider: str, mode: str, config: dict[str, str]) -> dict[str,
else:
if provider == "openrouter":
headers["Authorization"] = f"Bearer {config['direct_key']}"
- else:
+ elif provider == "google":
headers["x-goog-api-key"] = config["direct_key"]
+ elif provider == "openai":
+ headers["Authorization"] = f"Bearer {config['direct_key']}"
# Log headers with masked sensitive values
safe_headers = {}
@@ -539,6 +705,15 @@ def build_request_body(
if image_config:
body["image_config"] = image_config
log.debug(f"Image config: {json.dumps(image_config)}")
+ elif provider == "openai":
+ # OpenAI Images API — flat prompt, size from the aspect-ratio map.
+ body = {"model": model, "prompt": prompt}
+ if aspect_ratio:
+ size = OPENAI_SIZE_MAP.get(aspect_ratio)
+ if size:
+ body["size"] = size
+ else:
+ log.warning(f"Aspect ratio {aspect_ratio} not mapped for OpenAI; using model default")
else:
# Google AI Studio
parts: list[dict[str, Any]] = [{"text": prompt}]
@@ -733,6 +908,56 @@ def extract_image_google(response: dict) -> tuple[bytes, str]:
return image_bytes, text_content
+def extract_image_nvidia(response: dict) -> tuple[bytes, str]:
+ """Extract base64 image data from NVIDIA NIM response.
+
+ Args:
+ response: Parsed JSON response from NVIDIA NIM API.
+
+ Returns:
+ Tuple of (image_bytes, text_content) where image_bytes is the decoded
+ PNG data and text_content is empty (NVIDIA returns only images).
+
+ Raises:
+ RuntimeError: If no artifacts found in response.
+ """
+ artifacts = response.get("artifacts", [])
+ if not artifacts:
+ raise RuntimeError(f"No artifacts in NVIDIA response: {json.dumps(response)[:500]}")
+ b64_data = artifacts[0]["base64"]
+ image_bytes = base64.b64decode(b64_data)
+ log.info(f"Decoded image: {len(image_bytes)} bytes ({len(b64_data)} base64 chars)")
+ return image_bytes, ""
+
+
+def extract_image_openai(response: dict) -> tuple[bytes, str]:
+ """Extract base64 image data from an OpenAI Images API response.
+
+ Args:
+ response: Parsed JSON response from api.openai.com/v1/images/generations.
+
+ Returns:
+ Tuple of (image_bytes, text_content) where text_content is empty
+ (the Images API returns only images).
+
+ Raises:
+ RuntimeError: If the response carries an error or no image data.
+ """
+ error = response.get("error")
+ if error:
+ msg = error.get("message", str(error)) if isinstance(error, dict) else str(error)
+ raise RuntimeError(f"OpenAI API error: {msg}")
+ data = response.get("data", [])
+ if not data:
+ raise RuntimeError(f"No data in OpenAI response: {json.dumps(response)[:500]}")
+ b64_data = data[0].get("b64_json", "")
+ if not b64_data:
+ raise RuntimeError(f"No b64_json in OpenAI response item: {json.dumps(data[0])[:300]}")
+ image_bytes = base64.b64decode(b64_data)
+ log.info(f"Decoded image: {len(image_bytes)} bytes ({len(b64_data)} base64 chars)")
+ return image_bytes, ""
+
+
def extract_text_openrouter(response: dict) -> str:
"""Extract text-only content from OpenRouter response (analyze mode).
@@ -795,6 +1020,79 @@ def extract_text_google(response: dict) -> str:
return text_content
+def build_nvidia_url(model_id: str) -> str:
+ """Build the NVIDIA NIM image generation endpoint URL.
+
+ The NVIDIA AI Foundation endpoint format is:
+ https://ai.api.nvidia.com/v1/genai/{model_id}
+
+ Note: The model_id uses '.' not '-' in the version suffix
+ (e.g. 'black-forest-labs/flux.2-klein-4b').
+
+ Args:
+ model_id: Full model ID (e.g. 'black-forest-labs/flux.2-klein-4b').
+
+ Returns:
+ Full URL for the NVIDIA AI Foundation endpoint.
+ """
+ # Registry IDs already use the canonical dotted form (flux.1-dev,
+ # flux.2-klein-4b) — pass through verbatim. Rewriting suffixes here
+ # breaks valid IDs (flux.1-dev would become flux.1.dev → 404).
+ return f"https://ai.api.nvidia.com/v1/genai/{model_id}"
+
+
+def build_nvidia_headers(api_key: str) -> dict[str, str]:
+ """Build HTTP headers for NVIDIA NIM API.
+
+ Args:
+ api_key: NVIDIA API key.
+
+ Returns:
+ Dict of HTTP header name-value pairs.
+ """
+ return {
+ "Content-Type": "application/json",
+ "Authorization": f"Bearer {api_key}",
+ "Accept": "application/json",
+ }
+
+
+def build_nvidia_request_body(
+ model_id: str,
+ prompt: str,
+ width: int,
+ height: int,
+ cfg_scale: float = 1,
+ steps: int = 4,
+ seed: int = 0,
+ samples: int = 1,
+) -> dict[str, Any]:
+ """Build JSON request body for NVIDIA NIM Flux models.
+
+ Args:
+ model_id: Full model ID.
+ prompt: The image generation prompt text.
+ width: Image width (768-1344).
+ height: Image height (768-1344).
+ cfg_scale: Classifier-free guidance scale (default: 1, min: 1).
+ steps: Number of diffusion steps.
+ seed: Random seed (0 for random).
+ samples: Number of images to generate (always 1).
+
+ Returns:
+ Dict suitable for JSON serialization as request body.
+ """
+ return {
+ "prompt": prompt,
+ "width": width,
+ "height": height,
+ "cfg_scale": cfg_scale,
+ "steps": steps,
+ "seed": seed,
+ "samples": samples,
+ }
+
+
def find_imagemagick() -> str | None:
"""Find ImageMagick binary (magick for v7, convert for v6).
@@ -948,6 +1246,15 @@ def log_cost_entry(
"completion_tokens": usage.get("completion_tokens", 0),
"total_tokens": usage.get("total_tokens", 0),
}
+ elif provider == "openai":
+ # Images API usage format: input_tokens / output_tokens / total_tokens
+ usage = response.get("usage", {})
+ if usage:
+ token_usage = {
+ "prompt_tokens": usage.get("input_tokens", 0),
+ "completion_tokens": usage.get("output_tokens", 0),
+ "total_tokens": usage.get("total_tokens", 0),
+ }
else:
# Google AI Studio format
usage = response.get("usageMetadata", {})
@@ -1110,11 +1417,24 @@ def main() -> None:
file=sys.stderr,
)
+ # Auto-detect provider from the model keyword / id (registry entries with
+ # an explicit "provider" field, e.g. nvidia-flux2-klein-4b -> nvidia,
+ # image2 -> openai).
+ provider = args.provider
+ if args.model:
+ _kw = args.model.lower().strip()
+ _entry = MODEL_REGISTRY.get(_kw) or next(
+ (e for e in MODEL_REGISTRY.values() if e.get("id") == args.model), None
+ )
+ if _entry and _entry.get("provider"):
+ provider = _entry["provider"]
+ log.debug(f"Auto-detected provider '{provider}' from model '{args.model}'")
+
# Resolve model and modalities
- model, modalities = resolve_model(args.model, args.provider)
+ model, modalities = resolve_model(args.model, provider)
# Google direct API needs model ID without the OpenRouter "google/" prefix
- if args.provider == "google" and model.startswith("google/"):
+ if provider == "google" and model.startswith("google/"):
model = model[len("google/"):]
log.debug(f"Stripped google/ prefix for direct API: {model}")
@@ -1177,7 +1497,7 @@ def main() -> None:
modalities = ["text"]
print("Mode: analyze (text-only output)", file=sys.stderr)
- print(f"Provider: {args.provider}", file=sys.stderr)
+ print(f"Provider: {provider}", file=sys.stderr)
print(f"Model: {model}", file=sys.stderr)
print(f"Modalities: {', '.join(modalities)}", file=sys.stderr)
print(f"Prompt: {prompt[:100]}{'...' if len(prompt) > 100 else ''}", file=sys.stderr)
@@ -1187,21 +1507,58 @@ def main() -> None:
print(f"Image size: {args.image_size}", file=sys.stderr)
# Detect mode
- mode, config = detect_mode(args.provider)
+ mode, config = detect_mode(provider)
print(f"Mode: {mode}", file=sys.stderr)
# Build request
if mode == "gateway":
- url = build_gateway_url(args.provider, model, config)
+ url = build_gateway_url(provider, model, config)
else:
- url = build_direct_url(args.provider, model)
-
- headers = build_headers(args.provider, mode, config)
- body = build_request_body(
- args.provider, model, prompt, args.aspect_ratio, args.image_size,
- modalities=modalities,
- ref_images=ref_images if ref_images else None,
- )
+ url = build_direct_url(provider, model)
+
+ headers = build_headers(provider, mode, config)
+
+ # NVIDIA uses a different request body format (flat prompt, not contents/parts)
+ if provider == "nvidia":
+ # Per-model defaults from the registry — flux.1-dev needs ~30 steps /
+ # cfg 5, while schnell and flux.2-klein are distilled 4-step models.
+ registry_entry = next(
+ (e for e in MODEL_REGISTRY.values()
+ if e.get("id") == model and e.get("provider") == "nvidia"),
+ {},
+ )
+ nv_defaults = {
+ k: v.get("default")
+ for k, v in (registry_entry.get("nvidia_params") or {}).items()
+ if isinstance(v, dict) and "default" in v
+ }
+ width = args.width or nv_defaults.get("width") or 1024
+ height = args.height or nv_defaults.get("height") or 1024
+ # -a/--aspect-ratio maps to the closest dimensions NVIDIA accepts
+ if args.aspect_ratio and not (args.width or args.height):
+ dims = NVIDIA_ASPECT_MAP.get(args.aspect_ratio)
+ if dims:
+ width, height = dims
+ else:
+ print(
+ f"WARNING: aspect ratio {args.aspect_ratio} not mapped for NVIDIA; "
+ f"using {width}x{height}",
+ file=sys.stderr,
+ )
+ body = build_nvidia_request_body(
+ model, prompt,
+ width=width,
+ height=height,
+ cfg_scale=args.cfg_scale if args.cfg_scale is not None else nv_defaults.get("cfg_scale", 1),
+ steps=args.steps if args.steps is not None else nv_defaults.get("steps", 4),
+ seed=args.seed if args.seed is not None else 0,
+ )
+ else:
+ body = build_request_body(
+ provider, model, prompt, args.aspect_ratio, args.image_size,
+ modalities=modalities,
+ ref_images=ref_images if ref_images else None,
+ )
print(f"URL: {url}", file=sys.stderr)
if args.analyze:
@@ -1221,8 +1578,8 @@ def main() -> None:
file=sys.stderr,
)
log.info("Initiating fallback to direct API")
- url = build_direct_url(args.provider, model)
- headers = build_headers(args.provider, "direct", config)
+ url = build_direct_url(provider, model)
+ headers = build_headers(provider, "direct", config)
try:
response = make_request(url, headers, body)
except RuntimeError as e2:
@@ -1238,8 +1595,10 @@ def main() -> None:
if args.analyze:
total_elapsed = time.time() - total_start
try:
- if args.provider == "openrouter":
+ if provider == "openrouter":
analysis_text = extract_text_openrouter(response)
+ elif provider in ("nvidia", "openai"):
+ raise RuntimeError(f"Analyze mode not supported for {provider} provider")
else:
analysis_text = extract_text_google(response)
except RuntimeError as e:
@@ -1254,7 +1613,7 @@ def main() -> None:
try:
log_cost_entry(
response=response,
- provider=args.provider,
+ provider=provider,
model=model,
mode=mode,
aspect_ratio=None,
@@ -1271,7 +1630,7 @@ def main() -> None:
"ok": True,
"analyze": True,
"analysis": analysis_text,
- "provider": args.provider,
+ "provider": provider,
"model": model,
"mode": mode,
"elapsed_seconds": round(total_elapsed, 1),
@@ -1285,8 +1644,12 @@ def main() -> None:
# Extract image
try:
- if args.provider == "openrouter":
+ if provider == "openrouter":
image_bytes, text_content = extract_image_openrouter(response)
+ elif provider == "nvidia":
+ image_bytes, text_content = extract_image_nvidia(response)
+ elif provider == "openai":
+ image_bytes, text_content = extract_image_openai(response)
else:
image_bytes, text_content = extract_image_google(response)
except RuntimeError as e:
@@ -1294,6 +1657,49 @@ def main() -> None:
log.debug(f"Image extraction failed. Raw response keys: {list(response.keys()) if response else 'None'}")
sys.exit(1)
+ # NVIDIA NIM returns JPEG bytes — convert when the user asked for .png
+ if (
+ provider == "nvidia"
+ and output_path is not None
+ and output_path.suffix.lower() == ".png"
+ and image_bytes[:2] == b"\xff\xd8"
+ ):
+ magick_cmd = find_imagemagick()
+ ffmpeg_cmd = shutil.which("ffmpeg")
+ if magick_cmd or ffmpeg_cmd:
+ # Temp files live next to the output — snap-confined ffmpeg
+ # cannot read the system /tmp directory.
+ output_path.parent.mkdir(parents=True, exist_ok=True)
+ with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False, dir=output_path.parent) as tj:
+ jpg_tmp = Path(tj.name)
+ with tempfile.NamedTemporaryFile(suffix=".png", delete=False, dir=output_path.parent) as tp:
+ png_tmp = Path(tp.name)
+ try:
+ jpg_tmp.write_bytes(image_bytes)
+ if magick_cmd:
+ cmd = [magick_cmd, str(jpg_tmp), str(png_tmp)]
+ else:
+ cmd = ["ffmpeg", "-y", "-i", str(jpg_tmp), str(png_tmp)]
+ conv = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
+ if conv.returncode == 0 and png_tmp.stat().st_size > 0:
+ image_bytes = png_tmp.read_bytes()
+ log.info("Converted NVIDIA JPEG output to PNG")
+ else:
+ print(
+ "WARNING: JPEG->PNG conversion failed - saving original JPEG "
+ "bytes under the .png name",
+ file=sys.stderr,
+ )
+ finally:
+ jpg_tmp.unlink(missing_ok=True)
+ png_tmp.unlink(missing_ok=True)
+ else:
+ print(
+ "WARNING: NVIDIA returned JPEG and no converter (imagemagick/ffmpeg) "
+ "is installed - saving JPEG bytes under the .png name",
+ file=sys.stderr,
+ )
+
# Write output (or process transparent mode)
assert output_path is not None # guaranteed by validation above
output_path.parent.mkdir(parents=True, exist_ok=True)
@@ -1319,7 +1725,7 @@ def main() -> None:
try:
prompt_meta = f"# Prompt\n\n"
prompt_meta += f"- **Model:** {model}\n"
- prompt_meta += f"- **Provider:** {args.provider} ({mode})\n"
+ prompt_meta += f"- **Provider:** {provider} ({mode})\n"
if args.aspect_ratio:
prompt_meta += f"- **Aspect ratio:** {args.aspect_ratio}\n"
if args.image_size:
@@ -1350,7 +1756,7 @@ def main() -> None:
try:
log_cost_entry(
response=response,
- provider=args.provider,
+ provider=provider,
model=model,
mode=mode,
aspect_ratio=args.aspect_ratio,
@@ -1367,7 +1773,7 @@ def main() -> None:
"ok": True,
"output": str(output_path),
"size_bytes": len(image_bytes),
- "provider": args.provider,
+ "provider": provider,
"model": model,
"mode": mode,
"elapsed_seconds": round(total_elapsed, 1),
diff --git a/.claude/skills/create-goal/SKILL.md b/.claude/skills/create-goal/SKILL.md
index 88927320..801025c5 100644
--- a/.claude/skills/create-goal/SKILL.md
+++ b/.claude/skills/create-goal/SKILL.md
@@ -48,25 +48,54 @@ For a Project:
For a Mission:
- **Slug, title, description, target_metric, target_value, due_date, status** — same pattern
-## Step 3: Call the API
+## Step 3: Write to Nexus
-Use `from dashboard.backend.sdk_client import evo` — auto-handles URL + auth.
+Use the local writer script. Do **not** rely on browser auth, cookies, or `DASHBOARD_API_TOKEN`; OpenClaude sessions may not share dashboard auth state.
-```python
-from dashboard.backend.sdk_client import evo
+List the current hierarchy first:
-# Goal:
-goal = evo.post("/api/goals", {
- "slug": "evo-ai-100-customers",
- "project_id": project_id,
- "title": "100 paying customers by Jun 30",
- "metric_type": "count",
- "target_metric": "paying customers",
- "target_value": 100,
- "due_date": "2026-06-30",
-})
+```bash
+python3 .claude/skills/create-goal/scripts/nexus_goal.py list
+```
+
+Create a goal:
+
+```bash
+python3 .claude/skills/create-goal/scripts/nexus_goal.py create-goal \
+ --title "100 paying customers by Jun 30" \
+ --slug evo-ai-100-customers \
+ --project-slug evo-ai \
+ --project-title "Evo AI" \
+ --metric-type count \
+ --target-metric "paying customers" \
+ --target-value 100 \
+ --due-date 2026-06-30
+```
+
+Create a mission or project explicitly when needed:
+
+```bash
+python3 .claude/skills/create-goal/scripts/nexus_goal.py create-mission \
+ --title "Evolution MRR Q4 2026" \
+ --slug evolution-mrr-q4-2026 \
+ --target-metric "MRR" \
+ --target-value 1000000 \
+ --due-date 2026-12-31
+
+python3 .claude/skills/create-goal/scripts/nexus_goal.py create-project \
+ --title "Evo AI" \
+ --slug evo-ai \
+ --mission-slug evolution-mrr-q4-2026
+```
+
+Create starter tasks:
-# Same shape for /api/projects and /api/missions.
+```bash
+python3 .claude/skills/create-goal/scripts/nexus_goal.py create-task \
+ --goal-slug evo-ai-100-customers \
+ --title "Launch annual billing plan" \
+ --assignee-agent mako-marketing \
+ --priority 2
```
Response includes `id` and `slug`.
@@ -87,7 +116,7 @@ Return the slug and show how to attach it to a routine, heartbeat, or ticket:
...
# ticket
-evo.post("/api/tickets", {"title": "...", "goal_id": goal["id"]})
+Create or edit the ticket with goal_id=.
```
When linked, the agent running the work receives Mission → Project → Goal chain automatically in its prompt.
diff --git a/.claude/skills/create-goal/scripts/nexus_goal.py b/.claude/skills/create-goal/scripts/nexus_goal.py
new file mode 100644
index 00000000..c61be483
--- /dev/null
+++ b/.claude/skills/create-goal/scripts/nexus_goal.py
@@ -0,0 +1,293 @@
+#!/usr/bin/env python3
+"""Local EvoNexus goal writer.
+
+This is intentionally DB-local because agent sessions do not reliably share the
+dashboard browser auth cookie. The dashboard API remains the UI path; this script
+is the trusted local automation path for the /create-goal skill.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import re
+import sqlite3
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any
+
+
+VALID_METRIC_TYPES = {"count", "currency", "percentage", "percent", "boolean", "tasks"}
+
+
+def workspace_root() -> Path:
+ here = Path(__file__).resolve()
+ for parent in here.parents:
+ if (parent / "dashboard" / "data" / "evonexus.db").exists():
+ return parent
+ raise SystemExit("Could not find dashboard/data/evonexus.db from script path")
+
+
+def db_path() -> Path:
+ return workspace_root() / "dashboard" / "data" / "evonexus.db"
+
+
+def now() -> str:
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
+
+
+def slugify(value: str) -> str:
+ value = value.lower().strip()
+ value = re.sub(r"[^a-z0-9]+", "-", value)
+ value = re.sub(r"-+", "-", value).strip("-")
+ return value or "goal"
+
+
+def row_to_dict(row: sqlite3.Row | None) -> dict[str, Any] | None:
+ return dict(row) if row is not None else None
+
+
+def connect() -> sqlite3.Connection:
+ conn = sqlite3.connect(db_path())
+ conn.row_factory = sqlite3.Row
+ conn.execute("PRAGMA foreign_keys=ON")
+ return conn
+
+
+def create_mission(conn: sqlite3.Connection, args: argparse.Namespace) -> dict[str, Any]:
+ slug = args.slug or slugify(args.title)
+ existing = conn.execute("SELECT * FROM missions WHERE slug=?", (slug,)).fetchone()
+ if existing:
+ return row_to_dict(existing) | {"created": False}
+ ts = now()
+ conn.execute(
+ """INSERT INTO missions
+ (slug, title, description, target_metric, target_value, current_value, due_date, status, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
+ (
+ slug,
+ args.title,
+ args.description,
+ args.target_metric,
+ args.target_value,
+ args.current_value,
+ args.due_date,
+ args.status,
+ ts,
+ ts,
+ ),
+ )
+ conn.commit()
+ row = conn.execute("SELECT * FROM missions WHERE slug=?", (slug,)).fetchone()
+ return row_to_dict(row) | {"created": True}
+
+
+def ensure_project(
+ conn: sqlite3.Connection,
+ *,
+ slug: str,
+ title: str | None = None,
+ description: str | None = None,
+ mission_slug: str | None = None,
+ workspace_folder_path: str | None = None,
+) -> dict[str, Any]:
+ existing = conn.execute("SELECT * FROM projects WHERE slug=?", (slug,)).fetchone()
+ if existing:
+ return row_to_dict(existing) | {"created": False}
+
+ mission_id = None
+ if mission_slug:
+ mission = conn.execute("SELECT id FROM missions WHERE slug=?", (mission_slug,)).fetchone()
+ if mission is None:
+ raise SystemExit(f"Mission not found: {mission_slug}")
+ mission_id = mission["id"]
+
+ ts = now()
+ conn.execute(
+ """INSERT INTO projects
+ (slug, mission_id, title, description, workspace_folder_path, status, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, 'active', ?, ?)""",
+ (
+ slug,
+ mission_id,
+ title or slug.replace("-", " ").title(),
+ description,
+ workspace_folder_path,
+ ts,
+ ts,
+ ),
+ )
+ conn.commit()
+ row = conn.execute("SELECT * FROM projects WHERE slug=?", (slug,)).fetchone()
+ return row_to_dict(row) | {"created": True}
+
+
+def create_project(conn: sqlite3.Connection, args: argparse.Namespace) -> dict[str, Any]:
+ slug = args.slug or slugify(args.title)
+ return ensure_project(
+ conn,
+ slug=slug,
+ title=args.title,
+ description=args.description,
+ mission_slug=args.mission_slug,
+ workspace_folder_path=args.workspace_folder_path,
+ )
+
+
+def create_goal(conn: sqlite3.Connection, args: argparse.Namespace) -> dict[str, Any]:
+ metric_type = args.metric_type
+ if metric_type == "percent":
+ metric_type = "percentage"
+ if metric_type not in VALID_METRIC_TYPES:
+ raise SystemExit(f"Invalid metric_type: {args.metric_type}")
+
+ slug = args.slug or slugify(args.title)
+ existing = conn.execute("SELECT * FROM goals WHERE slug=?", (slug,)).fetchone()
+ if existing:
+ return row_to_dict(existing) | {"created": False}
+
+ project_slug = args.project_slug or "global"
+ project = ensure_project(
+ conn,
+ slug=project_slug,
+ title=args.project_title or ("Global" if project_slug == "global" else None),
+ mission_slug=args.mission_slug,
+ )
+
+ target_value = args.target_value
+ if metric_type == "boolean":
+ target_value = 1.0 if target_value is None else target_value
+ elif target_value is None:
+ raise SystemExit("--target-value is required unless metric_type=boolean")
+
+ ts = now()
+ conn.execute(
+ """INSERT INTO goals
+ (slug, project_id, title, description, target_metric, metric_type, target_value, current_value, due_date, status, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
+ (
+ slug,
+ project["id"],
+ args.title,
+ args.description,
+ args.target_metric,
+ metric_type,
+ target_value,
+ args.current_value,
+ args.due_date,
+ args.status,
+ ts,
+ ts,
+ ),
+ )
+ conn.commit()
+ row = conn.execute("SELECT * FROM goals WHERE slug=?", (slug,)).fetchone()
+ return row_to_dict(row) | {"created": True, "project": project}
+
+
+def create_task(conn: sqlite3.Connection, args: argparse.Namespace) -> dict[str, Any]:
+ goal_id = None
+ if args.goal_slug:
+ goal = conn.execute("SELECT id FROM goals WHERE slug=?", (args.goal_slug,)).fetchone()
+ if goal is None:
+ raise SystemExit(f"Goal not found: {args.goal_slug}")
+ goal_id = goal["id"]
+
+ ts = now()
+ conn.execute(
+ """INSERT INTO goal_tasks
+ (goal_id, title, description, priority, assignee_agent, status, due_date, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
+ (
+ goal_id,
+ args.title,
+ args.description,
+ args.priority,
+ args.assignee_agent,
+ args.status,
+ args.due_date,
+ ts,
+ ts,
+ ),
+ )
+ conn.commit()
+ row = conn.execute("SELECT * FROM goal_tasks WHERE id=last_insert_rowid()").fetchone()
+ return row_to_dict(row) | {"created": True}
+
+
+def list_tree(conn: sqlite3.Connection, _args: argparse.Namespace) -> dict[str, Any]:
+ missions = [dict(r) for r in conn.execute("SELECT * FROM missions ORDER BY id")]
+ projects = [dict(r) for r in conn.execute("SELECT * FROM projects ORDER BY id")]
+ goals = [dict(r) for r in conn.execute("SELECT * FROM goals ORDER BY due_date IS NULL, due_date, id")]
+ tasks = [dict(r) for r in conn.execute("SELECT * FROM goal_tasks ORDER BY priority, id")]
+ return {"missions": missions, "projects": projects, "goals": goals, "goal_tasks": tasks}
+
+
+def parser() -> argparse.ArgumentParser:
+ p = argparse.ArgumentParser(description="Create/list EvoNexus missions, projects, goals, and goal tasks")
+ sub = p.add_subparsers(dest="command", required=True)
+
+ mission = sub.add_parser("create-mission")
+ mission.add_argument("--title", required=True)
+ mission.add_argument("--slug")
+ mission.add_argument("--description")
+ mission.add_argument("--target-metric")
+ mission.add_argument("--target-value", type=float)
+ mission.add_argument("--current-value", type=float, default=0.0)
+ mission.add_argument("--due-date")
+ mission.add_argument("--status", default="active")
+
+ project = sub.add_parser("create-project")
+ project.add_argument("--title", required=True)
+ project.add_argument("--slug")
+ project.add_argument("--description")
+ project.add_argument("--mission-slug")
+ project.add_argument("--workspace-folder-path")
+
+ goal = sub.add_parser("create-goal")
+ goal.add_argument("--title", required=True)
+ goal.add_argument("--slug")
+ goal.add_argument("--description")
+ goal.add_argument("--project-slug", default="global")
+ goal.add_argument("--project-title")
+ goal.add_argument("--mission-slug")
+ goal.add_argument("--metric-type", default="count")
+ goal.add_argument("--target-metric")
+ goal.add_argument("--target-value", type=float)
+ goal.add_argument("--current-value", type=float, default=0.0)
+ goal.add_argument("--due-date")
+ goal.add_argument("--status", default="active")
+
+ task = sub.add_parser("create-task")
+ task.add_argument("--title", required=True)
+ task.add_argument("--description")
+ task.add_argument("--goal-slug")
+ task.add_argument("--priority", type=int, default=3)
+ task.add_argument("--assignee-agent")
+ task.add_argument("--status", default="open")
+ task.add_argument("--due-date")
+
+ sub.add_parser("list")
+ return p
+
+
+def main() -> None:
+ args = parser().parse_args()
+ with connect() as conn:
+ if args.command == "create-mission":
+ result = create_mission(conn, args)
+ elif args.command == "create-project":
+ result = create_project(conn, args)
+ elif args.command == "create-goal":
+ result = create_goal(conn, args)
+ elif args.command == "create-task":
+ result = create_task(conn, args)
+ elif args.command == "list":
+ result = list_tree(conn, args)
+ else:
+ raise SystemExit(f"Unknown command: {args.command}")
+ print(json.dumps(result, ensure_ascii=False, indent=2))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/.claude/skills/int-evohub/SKILL.md b/.claude/skills/int-evohub/SKILL.md
new file mode 100644
index 00000000..94af71f5
--- /dev/null
+++ b/.claude/skills/int-evohub/SKILL.md
@@ -0,0 +1,129 @@
+---
+name: int-evohub
+description: "EvoHub API — Manage WhatsApp, Instagram, and Facebook channels via Evolution Hub proxy. Use when the user wants to connect/disconnect channels, check channel status, send messages via EvoHub, manage webhooks, or troubleshoot EvoHub integrations. Also trigger for 'evohub', 'hub evolution', 'instagram evohub', 'whatsapp evohub', 'conectar instagram', 'conectar whatsapp', 'status do canal'."
+---
+
+# EvoHub Integration
+
+Proxy/hub for Meta APIs (WhatsApp, Instagram, Facebook) via Evolution Foundation.
+
+## Configuration
+
+All requests use:
+- **Base URL:** `https://api.evohub.ai/api/v1`
+- **Auth Header:** `Authorization: Bearer ${EVO_HUB_API_TOKEN}`
+- **Token location:** `config/.env` → `EVO_HUB_API_TOKEN`
+
+## Available Tools
+
+### Bash Helper
+
+Use `bash` to make raw API calls. Always include auth header.
+
+```bash
+TOKEN=$(python3 -c "
+import re
+with open('.env') as f:
+ for l in f:
+ m = re.match(r'^EVO_HUB_API_TOKEN=(.*)', l.strip())
+ if m: print(m.group(1))
+")
+BASE="https://api.evohub.ai/api/v1"
+
+# Or simply:
+grep EVO_HUB_API_TOKEN .env | cut -d= -f2
+```
+
+## API Reference
+
+### User & Plan
+
+| Endpoint | Method | Description |
+|----------|--------|-------------|
+| `/auth/me` | GET | Current user info |
+| `/me/plan` | GET | Current plan details |
+| `/me/limits` | GET | Plan limits and quotas |
+| `/me/usage` | GET | Current usage stats |
+
+### Channels
+
+| Endpoint | Method | Description |
+|----------|--------|-------------|
+| `/channels` | GET | List all channels |
+| `/channels/{id}` | GET | Channel details |
+| `/channels/{id}` | DELETE | Remove a channel |
+
+### Channel Types
+
+Channels have `type` field:
+- `whatsapp` — WhatsApp via Evolution
+- `instagram` — Instagram via Meta Graph API
+- `facebook` — Facebook Pages via Meta Graph API
+
+Channel `status`: `active` | `inactive` | `pending`
+Channel `connection_mode`: `byo` (bring your own Meta app) | `proxy` (shared)
+
+### Instagram
+
+| Endpoint | Method | Description |
+|----------|--------|-------------|
+| `/instagram/authorization` | GET | Generate Instagram OAuth URL |
+| `/instagram/callback` | GET | OAuth callback — redirects after auth |
+| `/instagram/webhook` | POST/GET | Manage Instagram webhook subscriptions |
+
+### Facebook
+
+| Endpoint | Method | Description |
+|----------|--------|-------------|
+| `/facebook/channel` | POST | Create Facebook channel |
+| `/facebook/pages` | GET | List Facebook Pages |
+
+### Webhooks
+
+| Endpoint | Method | Description |
+|----------|--------|-------------|
+| `/webhooks` | GET | List all webhooks |
+| `/webhooks` | POST | Create webhook |
+| `/webhooks/{id}` | PUT | Update webhook |
+| `/webhooks/{id}` | DELETE | Delete webhook |
+| `/webhooks/event-types` | GET | Available event types |
+
+## Common Operations
+
+### List all channels
+
+```bash
+curl -s -H "Authorization: Bearer $TOKEN" \
+ "https://api.evohub.ai/api/v1/channels" | python3 -m json.tool
+```
+
+### Check user plan
+
+```bash
+curl -s -H "Authorization: Bearer $TOKEN" \
+ "https://api.evohub.ai/api/v1/me/plan" | python3 -m json.tool
+```
+
+### Generate Instagram auth URL
+
+```bash
+curl -s -H "Authorization: Bearer $TOKEN" \
+ "https://api.evohub.ai/api/v1/instagram/authorization" | python3 -m json.tool
+```
+
+### Disconnect a channel
+
+```bash
+curl -s -X DELETE -H "Authorization: Bearer $TOKEN" \
+ "https://api.evohub.ai/api/v1/channels/{channel_id}"
+```
+
+## Current State (as of setup)
+
+| Channel | Type | ID | Status |
+|---------|------|-----|--------|
+| Gringo | whatsapp | `9d8220a9-e68a-423d-88fc-aa4efd2a61de` | inactive |
+| sistemabritto | instagram | `541c6345-9269-41b4-9f39-25b2e775824c` | inactive |
+| Sistema Britto | whatsapp | `8a278e5d-bbfb-48f9-a2da-58231550bdc2` | inactive |
+
+**Plan:** Free — 1 BYO credential, 3 channels each (WhatsApp/FB/IG), no message limits.
diff --git a/.claude/skills/int-instagram/scripts/instagram_client.py b/.claude/skills/int-instagram/scripts/instagram_client.py
index b803a6fb..d8a21e60 100644
--- a/.claude/skills/int-instagram/scripts/instagram_client.py
+++ b/.claude/skills/int-instagram/scripts/instagram_client.py
@@ -28,7 +28,25 @@ def _load_dotenv():
_load_dotenv()
-BASE_URL = "https://graph.facebook.com/v25.0"
+# Two Meta products, two API hosts:
+# - "Login do Facebook" tokens (EAA...) → graph.facebook.com, need a linked FB Page
+# - "Instagram API com Login do Instagram" tokens (IGAA...) → graph.instagram.com, direct
+FB_BASE_URL = "https://graph.facebook.com/v25.0"
+IG_BASE_URL = "https://graph.instagram.com/v23.0"
+
+
+def _is_ig_login_token(token: str) -> bool:
+ """Instagram-Login tokens start with IGAA (vs Facebook EAA tokens)."""
+ return token.startswith("IG")
+
+
+def _base_for_token(token: str) -> str:
+ return IG_BASE_URL if _is_ig_login_token(token) else FB_BASE_URL
+
+
+def _node_id(account: dict, token: str) -> str:
+ """Path for the IG user node. IG-Login accepts 'me'; Facebook needs the IG id."""
+ return account.get("account_id", "") or ("me" if _is_ig_login_token(token) else "")
# ── Account discovery ────────────────────────────────
@@ -71,11 +89,11 @@ def _token(account: dict) -> str:
# ── API calls ────────────────────────────────────────
-def _api_get(path: str, params: dict = None) -> dict:
+def _api_get(path: str, params: dict = None, base: str = FB_BASE_URL) -> dict:
"""Make GET request to Graph API."""
params = params or {}
query = urllib.parse.urlencode(params)
- url = f"{BASE_URL}/{path}"
+ url = f"{base}/{path}"
if query:
url += f"?{query}"
@@ -94,15 +112,16 @@ def _api_get(path: str, params: dict = None) -> dict:
def profile(account: dict) -> dict:
"""Get Instagram profile — followers, media count, username."""
- ig_id = account.get("account_id", "")
token = _token(account)
+ base = _base_for_token(token)
+ ig_id = _node_id(account, token)
if not ig_id or not token:
return {"error": "No account_id or token configured"}
data = _api_get(ig_id, {
"fields": "username,name,biography,followers_count,follows_count,media_count,profile_picture_url,website",
"access_token": token,
- })
+ }, base=base)
if "error" in data and "detail" not in data:
return data
@@ -123,8 +142,9 @@ def profile(account: dict) -> dict:
def recent_posts(account: dict, limit: int = 10) -> dict:
"""Get recent posts with engagement metrics."""
- ig_id = account.get("account_id", "")
token = _token(account)
+ base = _base_for_token(token)
+ ig_id = _node_id(account, token)
if not ig_id or not token:
return {"error": "No account_id or token configured"}
@@ -132,7 +152,7 @@ def recent_posts(account: dict, limit: int = 10) -> dict:
"fields": "id,caption,media_type,media_url,thumbnail_url,permalink,timestamp,like_count,comments_count",
"limit": limit,
"access_token": token,
- })
+ }, base=base)
if "error" in data and "detail" not in data:
return data
@@ -170,10 +190,12 @@ def recent_posts(account: dict, limit: int = 10) -> dict:
def post_insights(account: dict, post_id: str) -> dict:
"""Get insights for a specific post."""
token = _token(account)
+ base = _base_for_token(token)
+ metric = "reach,likes,comments,saved,shares" if _is_ig_login_token(token) else "impressions,reach,engagement"
data = _api_get(f"{post_id}/insights", {
- "metric": "impressions,reach,engagement",
+ "metric": metric,
"access_token": token,
- })
+ }, base=base)
if "error" in data:
return data
@@ -187,16 +209,17 @@ def post_insights(account: dict, post_id: str) -> dict:
def account_insights(account: dict) -> dict:
"""Get account-level insights (last 30 days)."""
- ig_id = account.get("account_id", "")
token = _token(account)
+ base = _base_for_token(token)
+ ig_id = _node_id(account, token)
if not ig_id or not token:
return {"error": "No account_id or token configured"}
- data = _api_get(f"{ig_id}/insights", {
- "metric": "impressions,reach,profile_views",
- "period": "day",
- "access_token": token,
- })
+ params = {"metric": "impressions,reach,profile_views", "period": "day", "access_token": token}
+ if _is_ig_login_token(token):
+ # IG-Login deprecated 'impressions'; reach is the reliable time-series metric
+ params["metric"] = "reach"
+ data = _api_get(f"{ig_id}/insights", params, base=base)
if "error" in data:
return data
diff --git a/.claude/skills/social-ai-trends-blog/SKILL.md b/.claude/skills/social-ai-trends-blog/SKILL.md
new file mode 100644
index 00000000..09797656
--- /dev/null
+++ b/.claude/skills/social-ai-trends-blog/SKILL.md
@@ -0,0 +1,117 @@
+---
+name: social-ai-trends-blog
+description: >
+ Pesquisa semanal de trending topics de IA no X (Twitter), gera uma fila de
+ pautas virais para o blog Sistema Britto e alimenta a rotina diaria AI News:
+ seleção por Sage, texto por Quill, revisão por Raven, humanização/SEO/link
+ building por Mako, imagem, Ghost draft, aprovação humana, publicação e
+ compartilhamento no LinkedIn. Use quando: "trending de IA", "pauta do blog",
+ "AI News", "tópicos virais da semana", "o que está bombando em IA", ou nos
+ cronjobs semanais/diários.
+---
+
+# social-ai-trends-blog — AI News semanal + fila diaria
+
+Transforma o hype real do X em pauta editorial acionável para o blog
+(`blog.sistemabritto.com.br`, via `custom-int-ghost`), sempre puxando o gancho
+para os produtos Evolution.
+
+## Produtos para ancorar o gancho
+
+| Produto | Ângulo de gancho |
+|---|---|
+| **Evolution API** | API open source de WhatsApp — dono da própria infra, sem lock-in |
+| **Evo AI** | CRM + agentes de IA |
+| **Evo CRM** | Gestão de relacionamento |
+| **EvoGo** | Evolution Go |
+| **Evo Academy** | Cursos — gancho para temas "aprenda IA" |
+| **Evolution Summit** | Evento — gancho para tendências/futuro |
+
+## Pipeline semanal (segunda)
+
+```
+1. COLETA → fetch_ai_trends.py (X API v2 recent search, 7 dias, sort=relevancy)
+2. RANQUEAMENTO→ score = likes + 2·RT + 1.5·quote + 0.5·reply + 1.5·bookmark + 0.001·impressions
+ + clusterização por tema (regex) + filtro de ruído
+3. SÍNTESE → @mako-marketing monta 15 tópicos: título + ângulo + gancho de produto
+4. REVISÃO → @sage-strategy revisa viralidade, relevância e fit Sistema Britto
+5. FILA → salva a pauta semanal e a fila editorial em workspace/marketing/ai-news/
+```
+
+### Agentes
+
+- **Coleta:** script `fetch_ai_trends.py` usando X API.
+- **Curadoria:** `@sage-strategy` escolhe os viral topics mais relevantes para Sistema Britto.
+- **Síntese editorial:** `@mako-marketing` transforma tendencias em fila de pautas.
+
+## Pipeline diario (19:00)
+
+```
+1. Sage seleciona o proximo item da fila.
+2. Quill escreve o blogpost AI News com fontes e estrutura.
+3. Raven revisa factualidade, risco, promessa e lacunas.
+4. Mako humaniza, aplica SEO/link building, gera imagem/thumbnail e cria Ghost draft.
+5. Telegram envia pedido de aprovacao com link do draft.
+6. Publicacao e compartilhamento no LinkedIn so acontecem depois do OK humano.
+7. Ao gerar o draft, o item da fila passa para `drafted` com `approval_status: pending`.
+```
+
+### Regras de aprovacao
+
+- Nunca publicar no Ghost sem aprovacao explicita do Felipe.
+- Nunca compartilhar no LinkedIn sem o post do Ghost estar aprovado/publicado.
+- Se `LINKEDIN_ACCESS_TOKEN` nao estiver configurado, registrar o bloqueio e nao tentar compartilhar.
+- O draft diario deve ficar como `status: draft`.
+- A mensagem de aprovacao precisa conter: titulo, promessa, link de preview/admin quando houver, caminho da imagem e pergunta objetiva de OK.
+
+## Como rodar (manual)
+
+```bash
+# Semanal: coleta + curadoria + fila
+python3 ADWs/routines/custom/ai_news_weekly_x_research.py
+
+# Diario: consome proximo item, cria draft e pede aprovacao
+python3 ADWs/routines/custom/ai_news_daily_draft.py
+```
+
+## Requisitos
+
+- `SOCIAL_TWITTER_1_BEARER_TOKEN` no `.env` — tier **Basic** ou superior
+ (recent search com janela de 7 dias + `sort_order=relevancy`).
+- Limite: janela máxima de 7 dias no recent search. Para histórico maior,
+ precisaria do endpoint full-archive (tier Pro/Enterprise).
+
+## Saídas
+
+| Arquivo | Conteúdo |
+|---|---|
+| `trends_raw.json` | Dados brutos: temas ranqueados + top tweets com métricas |
+| `workspace/marketing/ai-news/[C]pauta-AAAA-WW.md` | Os 15 tópicos finais |
+| `workspace/marketing/ai-news/queue.json` | Fila editorial diaria |
+| `workspace/marketing/ai-news/drafts/` | Drafts, revisoes, imagens e aprovacoes |
+
+## Cronjobs
+
+- Segunda-feira 08:00: `AI News Weekly X Research`.
+- Todos os dias 19:00: `AI News Daily Draft`.
+
+Registro do cron: ver `config/routines.yaml`.
+
+## Estrategia "post recompensa"
+
+Manter como trilha paralela de crescimento:
+
+1. Sage define tese, publico, oferta e criterio de lead qualificado.
+2. Mako transforma em campanha comentavel com CTA de palavra-chave.
+3. Pixel/Canvas geram criativo.
+4. Raven revisa risco de promessa, clareza e friccao.
+5. Felipe aprova antes de postar.
+
+## Anti-padrões
+
+- ❌ Publicar sem aprovação do Felipe.
+- ❌ Confiar só na contagem bruta de virais (tier Basic é amostra limitada) —
+ usar os **temas** como sinal e sintetizar pautas, não copiar tweets.
+- ❌ Gancho forçado de produto onde não cabe — se o tema não conecta com
+ Evolution, marcar como "sem gancho" em vez de inventar.
+- ❌ Repetir pautas de semanas anteriores sem checar histórico.
diff --git a/.claude/skills/social-ai-trends-blog/[C]pauta-2026-W24.md b/.claude/skills/social-ai-trends-blog/[C]pauta-2026-W24.md
new file mode 100644
index 00000000..109359f4
--- /dev/null
+++ b/.claude/skills/social-ai-trends-blog/[C]pauta-2026-W24.md
@@ -0,0 +1,45 @@
+# Pauta de IA para o blog — Semana 24/2026 (09–13 jun)
+
+> Fonte: X API (484 tweets, 7 dias, ordenados por relevância). Executor: Mako.
+> Revisão: Sage. Status: **aguardando aprovação do Felipe.**
+
+**Sinal da semana:** o tema mais viral de longe foi **"Open source AI must win"**
+(svpino, omarsar0, dankvr, bindureddy, "Dario com medo do open source"). Isso
+joga a favor do posicionamento da Evolution — somos open source. Aproveitar.
+
+---
+
+## Os 15 tópicos
+
+| # | Título | Ângulo / por que bombou | Gancho de produto |
+|---|--------|------------------------|-------------------|
+| 1 | **Por que o futuro da IA é open source (e por que isso te beneficia)** | "Open source AI must win" foi o grito da semana no X | **Evolution API** — você é dono da infra, sem lock-in |
+| 2 | **Construir em cima de API proprietária é uma bomba-relógio** | Tweet viral: "por que construir numa API que pode cortar seu acesso da noite pro dia?" | **Evolution API** open source vs. dependência de big tech |
+| 3 | **Rodando IA no seu hardware: a fazenda de 30 Mac Minis** | Caso viral do "Marcus Chen" empilhando Mac Minis pra rodar IA barato | **EvoGo / self-hosting** — IA sob seu controle |
+| 4 | **Agentic AI x AI Agents x IA Generativa: o glossário que 99% erra** | Tweet viral apontando que quase ninguém sabe a diferença | **Evo AI** — agentes de IA na prática |
+| 5 | **O abismo de produtividade: quem usa IA entrega dias de trabalho em horas** | "The productivity gap is about to become terrifying" (viral) | **Evo AI + Evolution API** — automação de WhatsApp |
+| 6 | **7 usos de agentes de IA que vão muito além do ChatGPT** | "Não entendo por que tanta gente ainda não usa agentes de IA" | **Evo AI** — agentes conectados ao seu negócio |
+| 7 | **OpenAI, Anthropic e Google concordam num risco — o que vem aí** | Big labs alinhados num alerta = pauta de autoridade | Evolution Summit (tendências/futuro) |
+| 8 | **IA na medicina: a melhor não é a que se vende como "IA médica"** | Estudo na Nature viralizou (glauber_doc) | Vertical de saúde — caso de uso Evo AI |
+| 9 | **As 5 habilidades de IA que valem ouro em 2026** | "Learn AI skills" em alta (automação, image gen, vídeo) | **Evo Academy** — trilha de aprendizado |
+| 10 | **Deepfakes indistinguíveis: como proteger sua marca** | Vídeo falso do "Roda Viva" viralizou (Boulos desmentiu) | WhatsApp oficial / verificação via **Evolution** |
+| 11 | **Construindo agentes com n8n + IA: guia prático** | "Construí 47 agentes com n8n e Claude" (viral) | **Evolution API** como camada de WhatsApp dos agentes |
+| 12 | **Como a IA está redesenhando workflows (e quem fica pra trás)** | "AI is reshaping how work gets done globally" | Automação com **Evolution + Evo AI** |
+| 13 | **O impacto da IA no emprego e na economia: o que os dados mostram** | Sachsida (213 likes) alertando sobre impacto no emprego | Conteúdo de autoridade / topo de funil |
+| 14 | **As empresas dos sonhos de quem trabalha com IA** | Ranking viral (Anthropic, OpenAI, DeepMind) | Cultura/recrutamento — branding Sistema Britto |
+| 15 | **Aprenda IA de graça: os melhores recursos desta semana** | Aula de Stanford "Turning Electricity into Intelligence" viralizou | **Evo Academy** — curadoria + trilha própria |
+
+---
+
+## Revisão do Sage (supervisor)
+
+- ✅ **Forte alinhamento (1, 2, 3, 11):** open source / self-hosting batem direto
+ com o DNA da Evolution. Priorizar estes 4 — é onde temos autoridade real.
+- ✅ **Educacionais (4, 6, 9, 15):** ótimos pra topo de funil + Evo Academy.
+- ⚠️ **Atenção (13, 14):** sem gancho de produto claro — usar como autoridade/branding,
+ não esperar conversão. Manter no máx. 1 por semana.
+- ⚠️ **#8 (saúde):** validar se temos caso real de cliente Evo AI em saúde antes de publicar.
+- 💡 Sugestão: começar a semana com o #1 (carro-chefe) e o #11 (prático/dev) —
+ ancoram o posicionamento open source enquanto o tema está quente.
+
+**Veredito:** lista aprovada para envio ao Felipe.
diff --git a/.claude/skills/social-ai-trends-blog/assets/THUMBNAIL-PRIMER.md b/.claude/skills/social-ai-trends-blog/assets/THUMBNAIL-PRIMER.md
new file mode 100644
index 00000000..e99a8f19
--- /dev/null
+++ b/.claude/skills/social-ai-trends-blog/assets/THUMBNAIL-PRIMER.md
@@ -0,0 +1,71 @@
+# Thumbnail Primer — Sistema Britto / blog + YouTube
+
+Estilo modelado a partir de 3 referências do nicho IA (BR + gringo) enviadas
+pelo Felipe. Ver `thumbnail-refs/`. Formato-alvo: **16:9 (1280×720)**,
+reaproveitável como capa de vídeo no YouTube. Geração via **gpt-image-2 (OpenAI)**
+na skill `ai-image-creator`.
+
+## Padrão extraído das referências
+
+### Bloco A — estilo BR (as do próprio Felipe — `ref-01-br-felipe.jpg`)
+- Rosto do Felipe à direita/centro, expressão forte (sorriso confiante, dedo
+ apontando, ou surpresa). Recorte limpo com leve rim light.
+- Ícones de app 3D glossy flutuando (Claude, Obsidian, ChatGPT, PIX).
+- 1 seta branca desenhada à mão (curva), apontando pro elemento-chave.
+- Fundo escuro com dados/dashboard, tons de **verde-limão** (marca).
+- Texto curto e pesado + 1 badge de resultado/número (R$0,00, ROAS 3.90, ROI).
+- Composição limpa, respira — credível, não poluído.
+
+### Bloco B — estilo gringo (Hermes/Julian — `ref-02`, `ref-03`)
+- Texto GIGANTE, 2-4 palavras, peso black, contorno grosso (stroke).
+ Ex: "IT'S MASSIVE", "100x UPGRADES", "THIS IS INSANE".
+- Rosto em choque/empolgação (boca aberta).
+- Neon/glow saturado (roxo, amarelo, vermelho), raios, badge "FREE" vermelho.
+- Dashboard com gráfico subindo (prova/resultado).
+- Altíssimo contraste, energia agressiva.
+
+## Receita Sistema Britto (fusão A + B)
+
+Pegar a **legibilidade e energia** do gringo + a **credibilidade e marca** do BR.
+
+| Elemento | Regra |
+|---|---|
+| Proporção | 16:9, 1280×720 |
+| Rosto | Felipe (do face-bank), 1/3 do quadro, expressão coerente com a pauta |
+| Hook visual | objeto-símbolo no lado oposto: ícone de app / celular / dashboard / dinheiro / logo |
+| Headline | 2-5 palavras, fonte black, branco ou verde-limão, stroke escuro grosso |
+| Badge | opcional — número/resultado ou "GRÁTIS"/"NOVO" em círculo/retângulo |
+| Fundo | escuro + glow verde-limão da marca (evitar roxo/amarelo gringo puro) |
+| Seta | no máx. 1, branca, à mão, opcional |
+| Limpeza | legível em tamanho pequeno; sem poluição; máx. 1 foco visual |
+| Paleta | verde-limão (#A3E635 / lime) + cinza metálico + preto (cores da marca) |
+
+## Template de prompt para gpt-image-2
+
+```
+Thumbnail 16:9 estilo YouTube, alta qualidade, para o nicho de IA/automação.
+PESSOA: [rosto do face-bank — descrição/expressão: ex. "homem jovem de óculos,
+sorriso confiante apontando"]. Posicionado no terço [esquerdo/direito].
+HOOK VISUAL no lado oposto: [objeto — ex. "ícone 3D glossy do app X" / "gráfico
+de dashboard subindo" / "celular com tela de vendas"].
+TEXTO GRANDE em destaque: "[2-5 PALAVRAS]" — fonte black, branca com contorno
+escuro grosso, alto contraste. [opcional: badge "[NÚMERO/GRÁTIS]"].
+FUNDO escuro com glow verde-limão (#A3E635) da marca, tons cinza metálico.
+Composição limpa, 1 foco visual, legível em tamanho pequeno. Sem poluição.
+[opcional: 1 seta branca desenhada à mão apontando para o elemento-chave].
+```
+
+Para consistência de rosto, usar `ai-image-creator -r `
+(edição com imagem de referência).
+
+## Face-bank
+
+Pasta: `assets/face-bank/`. Felipe vai enviar fotos de rosto dele em várias
+expressões (neutro, sorrindo, surpreso, apontando, sério/confiante). Quanto
+mais variado, melhor o casamento com cada tipo de pauta.
+
+## Anti-padrões de thumbnail
+- ❌ Texto longo (>5 palavras) ou fonte fina — ilegível no feed.
+- ❌ Mais de 1 foco visual — polui.
+- ❌ Paleta roxo/amarelo gringo pura — fugir da marca; usar verde-limão.
+- ❌ Promessa que o post não cumpre (clickbait vazio) — público BR é calejado.
diff --git a/.claude/skills/social-ai-trends-blog/assets/thumbnail-refs/ref-01-br-felipe.jpg b/.claude/skills/social-ai-trends-blog/assets/thumbnail-refs/ref-01-br-felipe.jpg
new file mode 100644
index 00000000..d2e9177d
Binary files /dev/null and b/.claude/skills/social-ai-trends-blog/assets/thumbnail-refs/ref-01-br-felipe.jpg differ
diff --git a/.claude/skills/social-ai-trends-blog/assets/thumbnail-refs/ref-02-gringo-hermes.jpg b/.claude/skills/social-ai-trends-blog/assets/thumbnail-refs/ref-02-gringo-hermes.jpg
new file mode 100644
index 00000000..a02fc714
Binary files /dev/null and b/.claude/skills/social-ai-trends-blog/assets/thumbnail-refs/ref-02-gringo-hermes.jpg differ
diff --git a/.claude/skills/social-ai-trends-blog/assets/thumbnail-refs/ref-03-gringo-julian.jpg b/.claude/skills/social-ai-trends-blog/assets/thumbnail-refs/ref-03-gringo-julian.jpg
new file mode 100644
index 00000000..84fe672c
Binary files /dev/null and b/.claude/skills/social-ai-trends-blog/assets/thumbnail-refs/ref-03-gringo-julian.jpg differ
diff --git a/.claude/skills/social-ai-trends-blog/fetch_ai_trends.py b/.claude/skills/social-ai-trends-blog/fetch_ai_trends.py
new file mode 100644
index 00000000..77d26075
--- /dev/null
+++ b/.claude/skills/social-ai-trends-blog/fetch_ai_trends.py
@@ -0,0 +1,187 @@
+#!/usr/bin/env python3
+"""
+fetch_ai_trends.py — Coleta tweets sobre IA dos últimos 7 dias via X API v2,
+ranqueia por viralidade (likes + RTs + quotes + bookmarks + impressões) e
+agrupa por tema. Saída: JSON com os tweets top + agregação por tópico.
+
+Uso:
+ python3 fetch_ai_trends.py [--days 7] [--per-query 100] [--out trends.json]
+
+Requer SOCIAL_TWITTER_1_BEARER_TOKEN no .env (raiz do workspace).
+Tier mínimo: Basic (recent search, janela de 7 dias).
+"""
+import os, sys, json, time, re, argparse, urllib.parse, urllib.request
+from datetime import datetime, timezone, timedelta
+from collections import defaultdict
+
+ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
+
+def load_env():
+ env = {}
+ path = os.path.join(ROOT, ".env")
+ if os.path.exists(path):
+ with open(path, encoding="utf-8") as f:
+ for line in f:
+ line = line.strip()
+ if not line or line.startswith("#") or "=" not in line:
+ continue
+ k, v = line.split("=", 1)
+ env[k.strip()] = v.strip().strip('"').strip("'")
+ return env
+
+# Consultas focadas em IA (PT + EN). -is:retweet evita duplicar virais por RT.
+QUERIES = [
+ '("inteligência artificial" OR "IA generativa" OR "agentes de IA") lang:pt -is:retweet',
+ '(ChatGPT OR Claude OR Gemini OR "GPT-5" OR Llama) lang:pt -is:retweet',
+ '(AI agents OR "agentic AI" OR "AI coding" OR "AI automation") lang:en -is:retweet',
+ '("AI breakthrough" OR "new AI model" OR "AI startup" OR "open source AI") lang:en -is:retweet',
+ '(OpenAI OR Anthropic OR "Google DeepMind" OR xAI OR Mistral) lang:en -is:retweet',
+ '("AI agents" OR chatbot OR "customer support AI" OR "WhatsApp AI") lang:en -is:retweet',
+]
+
+def score(m):
+ return (
+ m.get("like_count", 0) * 1.0
+ + m.get("retweet_count", 0) * 2.0
+ + m.get("quote_count", 0) * 1.5
+ + m.get("reply_count", 0) * 0.5
+ + m.get("bookmark_count", 0) * 1.5
+ + m.get("impression_count", 0) * 0.001
+ )
+
+# Temas para clusterizar (label -> regex de palavras-chave)
+TOPICS = {
+ "Agentes de IA / Agentic": r"agent|agentic|autonom",
+ "IA para código/dev": r"coding|code|developer|copilot|cursor|claude code|programaç",
+ "Modelos novos (GPT/Claude/Gemini/Llama)": r"gpt-?5|gpt5|claude|gemini|llama|mistral|grok|deepseek|qwen",
+ "OpenAI / Anthropic / Big Labs": r"openai|anthropic|deepmind|xai|microsoft|meta ai|google ai",
+ "IA generativa de imagem/vídeo": r"image|video|midjourney|sora|veo|flux|diffusion|gerad",
+ "Automação / Workflows com IA": r"automat|workflow|n8n|zapier|rpa|integraç",
+ "Chatbots / Atendimento / WhatsApp": r"chatbot|whatsapp|customer support|atendimento|suporte|sac",
+ "IA no trabalho / produtividade": r"productiv|produtiv|trabalho|job|emprego|workplace",
+ "Open source / modelos abertos": r"open.?source|open weight|aberto",
+ "Regulação / ética / segurança": r"regulat|regul|ethic|étic|safety|seguran|privac",
+ "Negócios / startups / investimento": r"startup|funding|investimen|raise|bilh|billion|valuation|ipo",
+}
+
+API_ERRORS = []
+
+def classify(text):
+ t = text.lower()
+ hits = [label for label, pat in TOPICS.items() if re.search(pat, t)]
+ return hits or ["Outros / IA geral"]
+
+# Ruído conhecido: idol K-pop "Gemini", aniversários, filmes, sorteios.
+NOISE = re.compile(
+ r"#?\d*gemini\s*day|miracle boy|ppnaravit|phuwintang|ohmpawat|aniversário|"
+ r"feliz aniver|happy birthday|cinemascore|giveaway|sorteio|🎂",
+ re.IGNORECASE,
+)
+
+def is_noise(text):
+ return bool(NOISE.search(text or ""))
+
+def fetch(query, bearer, start_time, max_results=100):
+ params = {
+ "query": query,
+ "max_results": str(max_results),
+ "start_time": start_time,
+ "sort_order": "relevancy",
+ "tweet.fields": "public_metrics,created_at,lang,author_id,entities",
+ "expansions": "author_id",
+ "user.fields": "username,name,public_metrics,verified",
+ }
+ url = "https://api.twitter.com/2/tweets/search/recent?" + urllib.parse.urlencode(params)
+ req = urllib.request.Request(url, headers={"Authorization": f"Bearer {bearer}"})
+ try:
+ with urllib.request.urlopen(req, timeout=30) as r:
+ return json.loads(r.read().decode())
+ except urllib.error.HTTPError as e:
+ body = e.read().decode()[:1000]
+ API_ERRORS.append({"query": query, "status": e.code, "body": body})
+ print(f"[warn] HTTP {e.code} em query '{query[:40]}...': {body}", file=sys.stderr)
+ return {"data": [], "includes": {}}
+ except Exception as e:
+ API_ERRORS.append({"query": query, "error": str(e)})
+ print(f"[warn] erro em query '{query[:40]}...': {e}", file=sys.stderr)
+ return {"data": [], "includes": {}}
+
+def main():
+ ap = argparse.ArgumentParser()
+ ap.add_argument("--days", type=int, default=7)
+ ap.add_argument("--per-query", type=int, default=100)
+ ap.add_argument("--out", default=os.path.join(os.path.dirname(__file__), "trends_raw.json"))
+ args = ap.parse_args()
+
+ env = load_env()
+ bearer = env.get("SOCIAL_TWITTER_1_BEARER_TOKEN") or os.environ.get("SOCIAL_TWITTER_1_BEARER_TOKEN")
+ if not bearer:
+ print("ERRO: SOCIAL_TWITTER_1_BEARER_TOKEN não encontrado no .env", file=sys.stderr)
+ sys.exit(1)
+
+ start = (datetime.now(timezone.utc) - timedelta(days=args.days)).strftime("%Y-%m-%dT%H:%M:%SZ")
+ users = {}
+ all_tweets = {}
+
+ for q in QUERIES:
+ res = fetch(q, bearer, start, args.per_query)
+ for u in res.get("includes", {}).get("users", []):
+ users[u["id"]] = u
+ for tw in res.get("data", []):
+ if is_noise(tw.get("text", "")):
+ continue
+ tw["_score"] = score(tw.get("public_metrics", {}))
+ tw["_topics"] = classify(tw.get("text", ""))
+ all_tweets[tw["id"]] = tw
+ time.sleep(1.2) # respeita rate limit
+
+ tweets = sorted(all_tweets.values(), key=lambda t: t["_score"], reverse=True)
+
+ # Agregação por tema
+ topic_agg = defaultdict(lambda: {"count": 0, "total_score": 0.0, "top_tweets": []})
+ for tw in tweets:
+ for topic in tw["_topics"]:
+ agg = topic_agg[topic]
+ agg["count"] += 1
+ agg["total_score"] += tw["_score"]
+ if len(agg["top_tweets"]) < 5:
+ u = users.get(tw.get("author_id"), {})
+ agg["top_tweets"].append({
+ "id": tw["id"],
+ "text": tw["text"][:280],
+ "author": u.get("username", "?"),
+ "metrics": tw.get("public_metrics", {}),
+ "score": round(tw["_score"], 1),
+ "url": f"https://x.com/i/web/status/{tw['id']}",
+ })
+
+ topics_ranked = sorted(
+ ({"topic": k, **v} for k, v in topic_agg.items()),
+ key=lambda x: x["total_score"], reverse=True,
+ )
+
+ out = {
+ "generated_at": datetime.now(timezone.utc).isoformat(),
+ "window_days": args.days,
+ "queries": QUERIES,
+ "api_errors": API_ERRORS,
+ "total_tweets_collected": len(tweets),
+ "topics_ranked": topics_ranked,
+ "top_tweets_overall": [
+ {
+ "text": t["text"][:280],
+ "author": users.get(t.get("author_id"), {}).get("username", "?"),
+ "metrics": t.get("public_metrics", {}),
+ "score": round(t["_score"], 1),
+ "topics": t["_topics"],
+ "url": f"https://x.com/i/web/status/{t['id']}",
+ }
+ for t in tweets[:40]
+ ],
+ }
+ with open(args.out, "w", encoding="utf-8") as f:
+ json.dump(out, f, ensure_ascii=False, indent=2)
+ print(f"OK: {len(tweets)} tweets coletados, {len(topics_ranked)} temas. Saída: {args.out}")
+
+if __name__ == "__main__":
+ main()
diff --git a/.claude/skills/social-ai-trends-blog/trends_raw.json b/.claude/skills/social-ai-trends-blog/trends_raw.json
new file mode 100644
index 00000000..8e39402c
--- /dev/null
+++ b/.claude/skills/social-ai-trends-blog/trends_raw.json
@@ -0,0 +1,1700 @@
+{
+ "generated_at": "2026-06-13T13:48:15.931514+00:00",
+ "window_days": 7,
+ "queries": [
+ "(\"inteligência artificial\" OR \"IA generativa\" OR \"agentes de IA\") lang:pt -is:retweet",
+ "(ChatGPT OR Claude OR Gemini OR \"GPT-5\" OR Llama) lang:pt -is:retweet",
+ "(AI agents OR \"agentic AI\" OR \"AI coding\" OR \"AI automation\") lang:en -is:retweet",
+ "(\"AI breakthrough\" OR \"new AI model\" OR \"AI startup\" OR \"open source AI\") lang:en -is:retweet",
+ "(OpenAI OR Anthropic OR \"Google DeepMind\" OR xAI OR Mistral) lang:en -is:retweet",
+ "(\"AI agents\" OR chatbot OR \"customer support AI\" OR \"WhatsApp AI\") lang:en -is:retweet"
+ ],
+ "total_tweets_collected": 484,
+ "topics_ranked": [
+ {
+ "topic": "Modelos novos (GPT/Claude/Gemini/Llama)",
+ "count": 142,
+ "total_score": 4349.158000000002,
+ "top_tweets": [
+ {
+ "id": "2064830151305699469",
+ "text": "Google founders are Jewish, has a frontier model\nAnthropic founder is Jewish, has a frontier model\nOpenAI founder is Jewish, has a frontier model\n\nxai founder isn’t Jewish, doesn’t have a frontier model\nMicrosoft founder isn’t Jewish, doesn’t have a frontier model\nDeepseek https:",
+ "author": "GolerGkA",
+ "metrics": {
+ "retweet_count": 51,
+ "reply_count": 65,
+ "like_count": 1618,
+ "quote_count": 19,
+ "bookmark_count": 443,
+ "impression_count": 272706
+ },
+ "score": 2718.2,
+ "url": "https://x.com/i/web/status/2064830151305699469"
+ },
+ {
+ "id": "2063506042114474143",
+ "text": "MARCUS CHEN EMPILHOU 30 MAC MINIS E CRIOU UMA FAZENDA DE SERVIDORES DE IA. UM ÚNICO MAC MINI DE US$ 599 PODE SUBSTITUIR SUA CONTA DE US$ 200/MÊS DO CLAUDE CODE GASTANDO APENAS US$ 3 DE ENERGIA.\n\nHá dois meses, um desenvolvedor publicou no Reddit a sua fatura do Claude Code: US$ h",
+ "author": "DehumanoaDeus",
+ "metrics": {
+ "retweet_count": 20,
+ "reply_count": 14,
+ "like_count": 190,
+ "quote_count": 1,
+ "bookmark_count": 100,
+ "impression_count": 39423
+ },
+ "score": 427.9,
+ "url": "https://x.com/i/web/status/2063506042114474143"
+ },
+ {
+ "id": "2065418102301442418",
+ "text": "chatgpt vs gemini mas o chatgpt tem um bundão, saia e uma venda nos olhos https://t.co/LgBDoYWL00",
+ "author": "scalabriniii",
+ "metrics": {
+ "retweet_count": 8,
+ "reply_count": 10,
+ "like_count": 322,
+ "quote_count": 3,
+ "bookmark_count": 33,
+ "impression_count": 22657
+ },
+ "score": 419.7,
+ "url": "https://x.com/i/web/status/2065418102301442418"
+ },
+ {
+ "id": "2064346800543482056",
+ "text": "AI is reshaping how work gets done globally.\n\nIt’s changing workflows, tasks, and decision-making systems.\n\nSharing these Top 7 AI models compiled by @AndrewBolis.\n\n1. ChatGPT (OpenAI)\n\nVersatile for writing, research, guidance, and support.\n\n2. Claude (Anthropic)\n\nFocuses on htt",
+ "author": "Igor_Buinevici",
+ "metrics": {
+ "retweet_count": 37,
+ "reply_count": 6,
+ "like_count": 98,
+ "quote_count": 0,
+ "bookmark_count": 72,
+ "impression_count": 7256
+ },
+ "score": 290.3,
+ "url": "https://x.com/i/web/status/2064346800543482056"
+ },
+ {
+ "id": "2065422550004371529",
+ "text": "E se a IA mais preparada para apoiar o médico não for a que se vende como “IA médica”?\n\nUm estudo publicado na Nature Medicine comparou ferramentas clínicas comerciais e dedicadas, OpenEvidence e UpToDate Expert AI, com LLMs gerais (GPT-5.2, Gemini 3.1 Pro e Claude Opus 4.6).\n\nNo",
+ "author": "glauber_doc",
+ "metrics": {
+ "retweet_count": 5,
+ "reply_count": 16,
+ "like_count": 74,
+ "quote_count": 0,
+ "bookmark_count": 25,
+ "impression_count": 4178
+ },
+ "score": 133.7,
+ "url": "https://x.com/i/web/status/2065422550004371529"
+ }
+ ]
+ },
+ {
+ "topic": "OpenAI / Anthropic / Big Labs",
+ "count": 111,
+ "total_score": 4003.280999999999,
+ "top_tweets": [
+ {
+ "id": "2064830151305699469",
+ "text": "Google founders are Jewish, has a frontier model\nAnthropic founder is Jewish, has a frontier model\nOpenAI founder is Jewish, has a frontier model\n\nxai founder isn’t Jewish, doesn’t have a frontier model\nMicrosoft founder isn’t Jewish, doesn’t have a frontier model\nDeepseek https:",
+ "author": "GolerGkA",
+ "metrics": {
+ "retweet_count": 51,
+ "reply_count": 65,
+ "like_count": 1618,
+ "quote_count": 19,
+ "bookmark_count": 443,
+ "impression_count": 272706
+ },
+ "score": 2718.2,
+ "url": "https://x.com/i/web/status/2064830151305699469"
+ },
+ {
+ "id": "2064346800543482056",
+ "text": "AI is reshaping how work gets done globally.\n\nIt’s changing workflows, tasks, and decision-making systems.\n\nSharing these Top 7 AI models compiled by @AndrewBolis.\n\n1. ChatGPT (OpenAI)\n\nVersatile for writing, research, guidance, and support.\n\n2. Claude (Anthropic)\n\nFocuses on htt",
+ "author": "Igor_Buinevici",
+ "metrics": {
+ "retweet_count": 37,
+ "reply_count": 6,
+ "like_count": 98,
+ "quote_count": 0,
+ "bookmark_count": 72,
+ "impression_count": 7256
+ },
+ "score": 290.3,
+ "url": "https://x.com/i/web/status/2064346800543482056"
+ },
+ {
+ "id": "2063273860754231748",
+ "text": "Some of the best companies to work for according to candidates.\n\nS+: Anthropic, OpenAI, Google DeepMind, Rentech, TGS, xAI, Citadel Securities, Jane Street, HRT\n\nS: Citadel, D.E. Shaw, Jump, Optiver, Two Sigma, Tesla (Autopilot), Five Rings, SpaceX\n\nS-: IMC, SIG, DRW, Akuna\n\nA++:",
+ "author": "vivoplt",
+ "metrics": {
+ "retweet_count": 8,
+ "reply_count": 33,
+ "like_count": 72,
+ "quote_count": 0,
+ "bookmark_count": 47,
+ "impression_count": 7640
+ },
+ "score": 182.6,
+ "url": "https://x.com/i/web/status/2063273860754231748"
+ },
+ {
+ "id": "2065284539564675171",
+ "text": "If you get offers from these 3 companies : \n\n- OpenAI \n- Anthropic \n- Google Deepmind \n\nWhich one would you join, and why?",
+ "author": "Its_Nova1012",
+ "metrics": {
+ "retweet_count": 1,
+ "reply_count": 65,
+ "like_count": 61,
+ "quote_count": 0,
+ "bookmark_count": 2,
+ "impression_count": 3560
+ },
+ "score": 102.1,
+ "url": "https://x.com/i/web/status/2065284539564675171"
+ },
+ {
+ "id": "2063637138118516779",
+ "text": "OpenAI, Anthropic, Google DeepMind, and Microsoft just agreed on something.\n\nThey're all warning about the same risk: future AI lowering the knowledge barrier around dangerous biological material.\n\nRead the shocking details🧵: https://t.co/EQPjK1gzjG",
+ "author": "themetav3rse",
+ "metrics": {
+ "retweet_count": 20,
+ "reply_count": 6,
+ "like_count": 43,
+ "quote_count": 1,
+ "bookmark_count": 3,
+ "impression_count": 7481
+ },
+ "score": 99.5,
+ "url": "https://x.com/i/web/status/2063637138118516779"
+ }
+ ]
+ },
+ {
+ "topic": "Open source / modelos abertos",
+ "count": 105,
+ "total_score": 1812.6340000000002,
+ "top_tweets": [
+ {
+ "id": "2065627605144055865",
+ "text": "This is why you should support open source AI \n\nAuthoritarian governments can’t exert control on our lives and play god \n\nWithout open source AI, US government will restrict AGI access to a small set of elitists 😭",
+ "author": "bindureddy",
+ "metrics": {
+ "retweet_count": 54,
+ "reply_count": 81,
+ "like_count": 448,
+ "quote_count": 8,
+ "bookmark_count": 21,
+ "impression_count": 60284
+ },
+ "score": 700.3,
+ "url": "https://x.com/i/web/status/2065627605144055865"
+ },
+ {
+ "id": "2065733417661030904",
+ "text": "open-source ai is the only way forward.",
+ "author": "svpino",
+ "metrics": {
+ "retweet_count": 13,
+ "reply_count": 20,
+ "like_count": 92,
+ "quote_count": 2,
+ "bookmark_count": 2,
+ "impression_count": 4823
+ },
+ "score": 138.8,
+ "url": "https://x.com/i/web/status/2065733417661030904"
+ },
+ {
+ "id": "2065632446255665232",
+ "text": "Open source AI must win!",
+ "author": "omarsar0",
+ "metrics": {
+ "retweet_count": 9,
+ "reply_count": 11,
+ "like_count": 81,
+ "quote_count": 0,
+ "bookmark_count": 3,
+ "impression_count": 5587
+ },
+ "score": 114.6,
+ "url": "https://x.com/i/web/status/2065632446255665232"
+ },
+ {
+ "id": "2064814880826253368",
+ "text": "Clip with David Morgan: Is open source AI the printing press of the 21st century? We need to use open source AI to further pro-liberty, pro-human goals! https://t.co/uSl0BsOXI9",
+ "author": "HealthRanger",
+ "metrics": {
+ "retweet_count": 17,
+ "reply_count": 9,
+ "like_count": 59,
+ "quote_count": 0,
+ "bookmark_count": 6,
+ "impression_count": 5140
+ },
+ "score": 111.6,
+ "url": "https://x.com/i/web/status/2064814880826253368"
+ },
+ {
+ "id": "2065608055572640192",
+ "text": "Open source AI must win https://t.co/MnCdiKGGan https://t.co/lRRUXFHfxk",
+ "author": "dankvr",
+ "metrics": {
+ "retweet_count": 8,
+ "reply_count": 6,
+ "like_count": 65,
+ "quote_count": 0,
+ "bookmark_count": 2,
+ "impression_count": 4367
+ },
+ "score": 91.4,
+ "url": "https://x.com/i/web/status/2065608055572640192"
+ }
+ ]
+ },
+ {
+ "topic": "IA para código/dev",
+ "count": 72,
+ "total_score": 849.6419999999997,
+ "top_tweets": [
+ {
+ "id": "2063506042114474143",
+ "text": "MARCUS CHEN EMPILHOU 30 MAC MINIS E CRIOU UMA FAZENDA DE SERVIDORES DE IA. UM ÚNICO MAC MINI DE US$ 599 PODE SUBSTITUIR SUA CONTA DE US$ 200/MÊS DO CLAUDE CODE GASTANDO APENAS US$ 3 DE ENERGIA.\n\nHá dois meses, um desenvolvedor publicou no Reddit a sua fatura do Claude Code: US$ h",
+ "author": "DehumanoaDeus",
+ "metrics": {
+ "retweet_count": 20,
+ "reply_count": 14,
+ "like_count": 190,
+ "quote_count": 1,
+ "bookmark_count": 100,
+ "impression_count": 39423
+ },
+ "score": 427.9,
+ "url": "https://x.com/i/web/status/2063506042114474143"
+ },
+ {
+ "id": "2064611414241648817",
+ "text": "2026!!\n\nLearn Ai!!\nLearn Ai skills!!\nLearn Ai Image Gen!!\nLearn Ai Automation!!\nLearn Ai Video editing!!\nLearn AI Content creation!!\nLearn AI Prompt Engineering!!\nLearn Ai Coding(Vibe coding)!!",
+ "author": "AutomationKing0",
+ "metrics": {
+ "retweet_count": 7,
+ "reply_count": 5,
+ "like_count": 53,
+ "quote_count": 1,
+ "bookmark_count": 17,
+ "impression_count": 1292
+ },
+ "score": 97.8,
+ "url": "https://x.com/i/web/status/2064611414241648817"
+ },
+ {
+ "id": "2063475839040438713",
+ "text": "🤖 Awesome Open Source AI 🛡️\n\nA curated collection of the best open-source AI models, frameworks, agents, RAG tools, inference engines, LLMOps platforms, and developer resources.\n\nPerfect for AI engineers, builders, researchers, and open-source enthusiasts. 🚀\n\n🔗 https://t.co/gtFiT",
+ "author": "VivekIntel",
+ "metrics": {
+ "retweet_count": 6,
+ "reply_count": 2,
+ "like_count": 29,
+ "quote_count": 1,
+ "bookmark_count": 14,
+ "impression_count": 1145
+ },
+ "score": 65.6,
+ "url": "https://x.com/i/web/status/2063475839040438713"
+ },
+ {
+ "id": "2064069746530668679",
+ "text": "⚡AI coding agents are no longer just assistants—they’re becoming autonomous teammates.\n\nFrom code completion to multi-step execution across the SDLC, the shift is redefining software engineering.\n\nExplore what’s driving this evolution in Gartner’s 2026 Magic Quadrant for https://",
+ "author": "Gartner_inc",
+ "metrics": {
+ "retweet_count": 6,
+ "reply_count": 1,
+ "like_count": 20,
+ "quote_count": 1,
+ "bookmark_count": 4,
+ "impression_count": 2089
+ },
+ "score": 42.1,
+ "url": "https://x.com/i/web/status/2064069746530668679"
+ },
+ {
+ "id": "2063330354694705534",
+ "text": "Deepseek is still releasing its foundational models and also reportedly closing in on a massive $7B funding round as of early June 2026.\n\nSora got shutdown \n\nGitHub Copilot has huge market in enterprise coding agents\n\nMeta released Llama 4 (April 2025) Scout and Maverick its http",
+ "author": "lochan_twt",
+ "metrics": {
+ "retweet_count": 1,
+ "reply_count": 2,
+ "like_count": 21,
+ "quote_count": 0,
+ "bookmark_count": 3,
+ "impression_count": 3474
+ },
+ "score": 32.0,
+ "url": "https://x.com/i/web/status/2063330354694705534"
+ }
+ ]
+ },
+ {
+ "topic": "Automação / Workflows com IA",
+ "count": 38,
+ "total_score": 614.0729999999998,
+ "top_tweets": [
+ {
+ "id": "2064346800543482056",
+ "text": "AI is reshaping how work gets done globally.\n\nIt’s changing workflows, tasks, and decision-making systems.\n\nSharing these Top 7 AI models compiled by @AndrewBolis.\n\n1. ChatGPT (OpenAI)\n\nVersatile for writing, research, guidance, and support.\n\n2. Claude (Anthropic)\n\nFocuses on htt",
+ "author": "Igor_Buinevici",
+ "metrics": {
+ "retweet_count": 37,
+ "reply_count": 6,
+ "like_count": 98,
+ "quote_count": 0,
+ "bookmark_count": 72,
+ "impression_count": 7256
+ },
+ "score": 290.3,
+ "url": "https://x.com/i/web/status/2064346800543482056"
+ },
+ {
+ "id": "2064724369814044869",
+ "text": "The productivity gap is about to become terrifying.\n\nAI users are finishing days of work in hours.\n\nHere's the complete AI automation guide.\n\n📂 AI Automation\n┃\n┣ 📂 Foundations\n┃ ┣ 📂 What Is AI Automation\n┃ ┣ 📂 Automation vs AI\n┃ ┣ 📂 Decision Making\n┃ ┣ 📂 Learning https://t.co/N2E",
+ "author": "shushant_l",
+ "metrics": {
+ "retweet_count": 18,
+ "reply_count": 14,
+ "like_count": 67,
+ "quote_count": 1,
+ "bookmark_count": 48,
+ "impression_count": 2067
+ },
+ "score": 185.6,
+ "url": "https://x.com/i/web/status/2064724369814044869"
+ },
+ {
+ "id": "2064611414241648817",
+ "text": "2026!!\n\nLearn Ai!!\nLearn Ai skills!!\nLearn Ai Image Gen!!\nLearn Ai Automation!!\nLearn Ai Video editing!!\nLearn AI Content creation!!\nLearn AI Prompt Engineering!!\nLearn Ai Coding(Vibe coding)!!",
+ "author": "AutomationKing0",
+ "metrics": {
+ "retweet_count": 7,
+ "reply_count": 5,
+ "like_count": 53,
+ "quote_count": 1,
+ "bookmark_count": 17,
+ "impression_count": 1292
+ },
+ "score": 97.8,
+ "url": "https://x.com/i/web/status/2064611414241648817"
+ },
+ {
+ "id": "2063278361678360640",
+ "text": "Here are some of the biggest AI trends in 2026:\n\n1. Agentic AI\n\nAI systems are moving beyond chatbots into “agents” that can:\n\nPlan multi-step tasks\nUse tools and software\nConduct research\nAutomate workflows\nCollaborate with other AI agents\n\nExamples include coding agents,",
+ "author": "JCKCROWLEY",
+ "metrics": {
+ "retweet_count": 0,
+ "reply_count": 5,
+ "like_count": 4,
+ "quote_count": 0,
+ "bookmark_count": 0,
+ "impression_count": 259
+ },
+ "score": 6.8,
+ "url": "https://x.com/i/web/status/2063278361678360640"
+ },
+ {
+ "id": "2064661557175570655",
+ "text": ".\nDomain Listed For Sale\n\nhttps://t.co/qtLNGrsrun\n\n#Magentic #Agents #Agentic #AI #Automation #Orchestration #MultiAgent #LLM #MachineLearning #Robotics #Autonomous #DeepTech #DomainForSale https://t.co/G0JLS2SkC8",
+ "author": "DomainFQ",
+ "metrics": {
+ "retweet_count": 1,
+ "reply_count": 1,
+ "like_count": 3,
+ "quote_count": 0,
+ "bookmark_count": 0,
+ "impression_count": 21
+ },
+ "score": 5.5,
+ "url": "https://x.com/i/web/status/2064661557175570655"
+ }
+ ]
+ },
+ {
+ "topic": "Agentes de IA / Agentic",
+ "count": 116,
+ "total_score": 561.627,
+ "top_tweets": [
+ {
+ "id": "2064392166131265627",
+ "text": "Most people in AI can't actually explain the difference between \"Generative AI,\" \"Agentic AI,\" and \"AI Agents.\"\n\nThey use all three like they mean the same thing. They don't. And once it clicks, you can't unsee it.\n\nHere's the cleanest way to think about it:\n\nGenerative AI is the",
+ "author": "VaibhavSisinty",
+ "metrics": {
+ "retweet_count": 5,
+ "reply_count": 7,
+ "like_count": 38,
+ "quote_count": 1,
+ "bookmark_count": 33,
+ "impression_count": 2984
+ },
+ "score": 105.5,
+ "url": "https://x.com/i/web/status/2064392166131265627"
+ },
+ {
+ "id": "2063475839040438713",
+ "text": "🤖 Awesome Open Source AI 🛡️\n\nA curated collection of the best open-source AI models, frameworks, agents, RAG tools, inference engines, LLMOps platforms, and developer resources.\n\nPerfect for AI engineers, builders, researchers, and open-source enthusiasts. 🚀\n\n🔗 https://t.co/gtFiT",
+ "author": "VivekIntel",
+ "metrics": {
+ "retweet_count": 6,
+ "reply_count": 2,
+ "like_count": 29,
+ "quote_count": 1,
+ "bookmark_count": 14,
+ "impression_count": 1145
+ },
+ "score": 65.6,
+ "url": "https://x.com/i/web/status/2063475839040438713"
+ },
+ {
+ "id": "2065160042459152636",
+ "text": "Agentic AI is creating a new class of trust problems.\n\nUseful agents need (1) access to private information, (2) access to untrusted external input, and (3) the ability to act.\n\nHowever, once we combine these three properties, there is always the possibility that an agent can be ",
+ "author": "_weidai",
+ "metrics": {
+ "retweet_count": 2,
+ "reply_count": 7,
+ "like_count": 22,
+ "quote_count": 0,
+ "bookmark_count": 14,
+ "impression_count": 4188
+ },
+ "score": 54.7,
+ "url": "https://x.com/i/web/status/2065160042459152636"
+ },
+ {
+ "id": "2065521758422364435",
+ "text": "OpenAI, Google DeepMind, Meta, Microsoft, NVIDIA, Mistral, Hugging Face\n\nthe labs everyone says are closing ai behind a paywall quietly shipped their core tools on github. here are 7 repos, one from each\n\n1. OpenAI, openai/openai-agents-python\nhttps://t.co/6UfYVDOcTy\n27k stars. h",
+ "author": "vorty279",
+ "metrics": {
+ "retweet_count": 2,
+ "reply_count": 2,
+ "like_count": 22,
+ "quote_count": 0,
+ "bookmark_count": 10,
+ "impression_count": 878
+ },
+ "score": 42.9,
+ "url": "https://x.com/i/web/status/2065521758422364435"
+ },
+ {
+ "id": "2064069746530668679",
+ "text": "⚡AI coding agents are no longer just assistants—they’re becoming autonomous teammates.\n\nFrom code completion to multi-step execution across the SDLC, the shift is redefining software engineering.\n\nExplore what’s driving this evolution in Gartner’s 2026 Magic Quadrant for https://",
+ "author": "Gartner_inc",
+ "metrics": {
+ "retweet_count": 6,
+ "reply_count": 1,
+ "like_count": 20,
+ "quote_count": 1,
+ "bookmark_count": 4,
+ "impression_count": 2089
+ },
+ "score": 42.1,
+ "url": "https://x.com/i/web/status/2064069746530668679"
+ }
+ ]
+ },
+ {
+ "topic": "Outros / IA geral",
+ "count": 78,
+ "total_score": 405.62599999999964,
+ "top_tweets": [
+ {
+ "id": "2065787621427589404",
+ "text": "Ótimo uso de inteligência artificial por David Brazil. https://t.co/eWm99QOyrm",
+ "author": "chicobarney",
+ "metrics": {
+ "retweet_count": 3,
+ "reply_count": 9,
+ "like_count": 70,
+ "quote_count": 2,
+ "bookmark_count": 1,
+ "impression_count": 2834
+ },
+ "score": 87.8,
+ "url": "https://x.com/i/web/status/2065787621427589404"
+ },
+ {
+ "id": "2064331850382753938",
+ "text": "@memes_horriveis Akinator não é IA generativa meu anjo",
+ "author": "ZhyrelDragon",
+ "metrics": {
+ "retweet_count": 0,
+ "reply_count": 1,
+ "like_count": 68,
+ "quote_count": 0,
+ "bookmark_count": 0,
+ "impression_count": 1557
+ },
+ "score": 70.1,
+ "url": "https://x.com/i/web/status/2064331850382753938"
+ },
+ {
+ "id": "2065122858162868332",
+ "text": "INTELIGÊNCIA ARTIFICIAL | A ministra Cármen Lúcia, do Supremo Tribunal Federal (STF), destacou os riscos do uso indevido da inteligência artificial, nessa terça-feira (9), durante o 6º Congresso Brasileiro de Internet. https://t.co/DRLhtvThrv",
+ "author": "ebcnarede",
+ "metrics": {
+ "retweet_count": 12,
+ "reply_count": 1,
+ "like_count": 38,
+ "quote_count": 1,
+ "bookmark_count": 0,
+ "impression_count": 414
+ },
+ "score": 64.4,
+ "url": "https://x.com/i/web/status/2065122858162868332"
+ },
+ {
+ "id": "2063698566791450971",
+ "text": "@IsadoraGameOver O problema eh esse, eles não tão usando só IA como ferramenta kkkk\n\nAli claramente diz IA GENERATIVA, o que é algo COMPLETAMENTE diferente de uma IA comum\n\nMuito provavelmente vão usar IA generativa pra concept arts, designs e por aí vai",
+ "author": "FlipeArt2",
+ "metrics": {
+ "retweet_count": 0,
+ "reply_count": 6,
+ "like_count": 30,
+ "quote_count": 0,
+ "bookmark_count": 1,
+ "impression_count": 1033
+ },
+ "score": 35.5,
+ "url": "https://x.com/i/web/status/2063698566791450971"
+ },
+ {
+ "id": "2064358367498273167",
+ "text": "@rafaelgloves Pensa assim.\n\nProduto A é o que resolve o seu problema. \n\nProduto B no máximo quebra o galho mas é MUITO mais barato que o A.\n\nAcontece que o B está ficando cada vez melhor, enquanto continua sendo MUITO mais barato que o A.\n\nUm belo dia, você pensa: quer saber? O p",
+ "author": "rodrigofm",
+ "metrics": {
+ "retweet_count": 0,
+ "reply_count": 2,
+ "like_count": 26,
+ "quote_count": 0,
+ "bookmark_count": 2,
+ "impression_count": 2921
+ },
+ "score": 32.9,
+ "url": "https://x.com/i/web/status/2064358367498273167"
+ }
+ ]
+ },
+ {
+ "topic": "Negócios / startups / investimento",
+ "count": 47,
+ "total_score": 225.66999999999993,
+ "top_tweets": [
+ {
+ "id": "2063674932471730626",
+ "text": "Every single AI startup powered by @mintlify with $10B+ valuation and $100M+ revenue run rate: \n\nCrusoe - $10B \nMercor - $10B ✅ \nElevenLabs - $11B \nBaseten - $11B* ✅\nHarvey - $11B ✅\nLovable - $12B* ✅\nOpenEvidence - $12B \nMistral - $14B \nNscale - $14.6B \nFireworks - $15B* ✅ https",
+ "author": "pronounsuponly",
+ "metrics": {
+ "retweet_count": 1,
+ "reply_count": 11,
+ "like_count": 23,
+ "quote_count": 0,
+ "bookmark_count": 3,
+ "impression_count": 5703
+ },
+ "score": 40.7,
+ "url": "https://x.com/i/web/status/2063674932471730626"
+ },
+ {
+ "id": "2063330354694705534",
+ "text": "Deepseek is still releasing its foundational models and also reportedly closing in on a massive $7B funding round as of early June 2026.\n\nSora got shutdown \n\nGitHub Copilot has huge market in enterprise coding agents\n\nMeta released Llama 4 (April 2025) Scout and Maverick its http",
+ "author": "lochan_twt",
+ "metrics": {
+ "retweet_count": 1,
+ "reply_count": 2,
+ "like_count": 21,
+ "quote_count": 0,
+ "bookmark_count": 3,
+ "impression_count": 3474
+ },
+ "score": 32.0,
+ "url": "https://x.com/i/web/status/2063330354694705534"
+ },
+ {
+ "id": "2064149611762118779",
+ "text": "🚨 vocês entenderam o que acabou de acontecer com a Apple..\n\na Apple apresentou sua nova Siri AI durante a WWDC 2026.\n\nhoras depois, o mercado respondeu.\n\nas ações fecharam em queda de 1,9%, apagando bilhões de dólares em valor de mercado.\n\n- Apple fechou o dia cotada a US$ https:",
+ "author": "FelpsCrypto",
+ "metrics": {
+ "retweet_count": 2,
+ "reply_count": 1,
+ "like_count": 10,
+ "quote_count": 0,
+ "bookmark_count": 2,
+ "impression_count": 7040
+ },
+ "score": 24.5,
+ "url": "https://x.com/i/web/status/2064149611762118779"
+ },
+ {
+ "id": "2063542839527575674",
+ "text": "This thread seems obviously correct; even if Anthropic is better, it's very likely to be a more fragile position. \n\nRobust governance is where an experienced company has an advantage - but the exemplar here is Google Deepmind, and not OpenAI, which is still basically a startup. h",
+ "author": "davidmanheim",
+ "metrics": {
+ "retweet_count": 0,
+ "reply_count": 1,
+ "like_count": 15,
+ "quote_count": 1,
+ "bookmark_count": 2,
+ "impression_count": 3149
+ },
+ "score": 23.1,
+ "url": "https://x.com/i/web/status/2063542839527575674"
+ },
+ {
+ "id": "2063692675673428269",
+ "text": "Great list!\nIf you want to follow any of the 10B+ valuation AI companies on X to keep track of the market, here are their handles:\n\nCrusoe ($10B) - @CrusoeAI\nMercor ($10B) - @mercor_ai\nElevenLabs ($11B) - @elevenlabs\nHarvey ($11B) - @harvey\nBaseten ($11B) - @baseten\nLovable https",
+ "author": "TamazGadaev",
+ "metrics": {
+ "retweet_count": 0,
+ "reply_count": 2,
+ "like_count": 6,
+ "quote_count": 0,
+ "bookmark_count": 9,
+ "impression_count": 941
+ },
+ "score": 21.4,
+ "url": "https://x.com/i/web/status/2063692675673428269"
+ }
+ ]
+ },
+ {
+ "topic": "IA no trabalho / produtividade",
+ "count": 16,
+ "total_score": 212.85199999999998,
+ "top_tweets": [
+ {
+ "id": "2064724369814044869",
+ "text": "The productivity gap is about to become terrifying.\n\nAI users are finishing days of work in hours.\n\nHere's the complete AI automation guide.\n\n📂 AI Automation\n┃\n┣ 📂 Foundations\n┃ ┣ 📂 What Is AI Automation\n┃ ┣ 📂 Automation vs AI\n┃ ┣ 📂 Decision Making\n┃ ┣ 📂 Learning https://t.co/N2E",
+ "author": "shushant_l",
+ "metrics": {
+ "retweet_count": 18,
+ "reply_count": 14,
+ "like_count": 67,
+ "quote_count": 1,
+ "bookmark_count": 48,
+ "impression_count": 2067
+ },
+ "score": 185.6,
+ "url": "https://x.com/i/web/status/2064724369814044869"
+ },
+ {
+ "id": "2065495501701542178",
+ "text": "Por que o Léo usa Claude pro trabalho e ChatGPT pro dia a dia:\n\n1. Claude te pergunta antes de assumir. \"Quer HTML ou PDF? A empresa é alavancada ou não?\" Isso refina o output antes de processar.\n\n2. Reasoning mais forte pra finanças, advocacia, trabalho analítico.\n\n3. ChatGPT te",
+ "author": "opapoeconomico",
+ "metrics": {
+ "retweet_count": 0,
+ "reply_count": 1,
+ "like_count": 11,
+ "quote_count": 0,
+ "bookmark_count": 0,
+ "impression_count": 1027
+ },
+ "score": 12.5,
+ "url": "https://x.com/i/web/status/2065495501701542178"
+ },
+ {
+ "id": "2065462303118471608",
+ "text": "📚 EDTECH, ONLINE LEARNING & PRODUCTIVITY ROUNDUP — June 12, 2026\n\n1️⃣ STOP AUDITING WORKFLOWS — START WITH GOALS INSTEAD\n\nAllie K. Miller challenges the conventional AI-labs advice that you should start with a workflow audit when adopting AI at work. She argues that because",
+ "author": "TraffAlex",
+ "metrics": {
+ "retweet_count": 0,
+ "reply_count": 2,
+ "like_count": 3,
+ "quote_count": 0,
+ "bookmark_count": 0,
+ "impression_count": 88
+ },
+ "score": 4.1,
+ "url": "https://x.com/i/web/status/2065462303118471608"
+ },
+ {
+ "id": "2065531557264113986",
+ "text": "Por que o Léo usa Claude pro trabalho e ChatGPT pro dia a dia:\n\n1. Claude te pergunta antes de assumir. \"Quer HTML ou PDF? A empresa é alavancada ou não?\" Isso refina o output antes de processar.\n\n2. Reasoning mais forte pra finanças, advocacia, trabalho analítico.\n\n3. ChatGPT te",
+ "author": "opapoeconomico",
+ "metrics": {
+ "retweet_count": 0,
+ "reply_count": 1,
+ "like_count": 2,
+ "quote_count": 0,
+ "bookmark_count": 0,
+ "impression_count": 350
+ },
+ "score": 2.9,
+ "url": "https://x.com/i/web/status/2065531557264113986"
+ },
+ {
+ "id": "2065675890839728277",
+ "text": "samsung baniu chatgpt em 2023 depois de vazar código interno.\n\nagora em 2026? liberou chatgpt, gemini E claude pra empresa inteira.\n\no que mudou?\n\nnada — a pressão da concorrência só ficou alta demais.\n\nsegurança sempre foi desculpa, produtividade sempre foi o jogo.\né tudo https:",
+ "author": "netaotech",
+ "metrics": {
+ "retweet_count": 0,
+ "reply_count": 0,
+ "like_count": 1,
+ "quote_count": 0,
+ "bookmark_count": 1,
+ "impression_count": 28
+ },
+ "score": 2.5,
+ "url": "https://x.com/i/web/status/2065675890839728277"
+ }
+ ]
+ },
+ {
+ "topic": "IA generativa de imagem/vídeo",
+ "count": 17,
+ "total_score": 160.08999999999995,
+ "top_tweets": [
+ {
+ "id": "2064611414241648817",
+ "text": "2026!!\n\nLearn Ai!!\nLearn Ai skills!!\nLearn Ai Image Gen!!\nLearn Ai Automation!!\nLearn Ai Video editing!!\nLearn AI Content creation!!\nLearn AI Prompt Engineering!!\nLearn Ai Coding(Vibe coding)!!",
+ "author": "AutomationKing0",
+ "metrics": {
+ "retweet_count": 7,
+ "reply_count": 5,
+ "like_count": 53,
+ "quote_count": 1,
+ "bookmark_count": 17,
+ "impression_count": 1292
+ },
+ "score": 97.8,
+ "url": "https://x.com/i/web/status/2064611414241648817"
+ },
+ {
+ "id": "2063330354694705534",
+ "text": "Deepseek is still releasing its foundational models and also reportedly closing in on a massive $7B funding round as of early June 2026.\n\nSora got shutdown \n\nGitHub Copilot has huge market in enterprise coding agents\n\nMeta released Llama 4 (April 2025) Scout and Maverick its http",
+ "author": "lochan_twt",
+ "metrics": {
+ "retweet_count": 1,
+ "reply_count": 2,
+ "like_count": 21,
+ "quote_count": 0,
+ "bookmark_count": 3,
+ "impression_count": 3474
+ },
+ "score": 32.0,
+ "url": "https://x.com/i/web/status/2063330354694705534"
+ },
+ {
+ "id": "2063993734954353148",
+ "text": "Quando eu vejo anúncio de carro com foto gerada por inteligência artificial, eu assumo que o carro anunciado não existe. Única explicação plausível para gerar uma foto com inteligência artificial para o anúncio",
+ "author": "XerulebeXarabla",
+ "metrics": {
+ "retweet_count": 0,
+ "reply_count": 3,
+ "like_count": 14,
+ "quote_count": 0,
+ "bookmark_count": 0,
+ "impression_count": 445
+ },
+ "score": 15.9,
+ "url": "https://x.com/i/web/status/2063993734954353148"
+ },
+ {
+ "id": "2064404552506282051",
+ "text": "@groxxxo sim, deu positivo para chatgpt, gemini, claude, deepseek, meta IA, grok, perplexity, character ai, ollama \n(online, não local)\nnão, local também, midjourney, copilot, notion, otter e a boa e velha luzIA",
+ "author": "luluntheweb",
+ "metrics": {
+ "retweet_count": 0,
+ "reply_count": 1,
+ "like_count": 5,
+ "quote_count": 0,
+ "bookmark_count": 1,
+ "impression_count": 417
+ },
+ "score": 7.4,
+ "url": "https://x.com/i/web/status/2064404552506282051"
+ },
+ {
+ "id": "2064072188756648270",
+ "text": "@LFTH_Alex the only companies that really prioritized image generation were like Midjourney and Stability AI, which were fairly small compared to OpenAI/Anthropic/Google deepmind. Most capital of those latter three has went to creating and serving LLMs, with image/video generatio",
+ "author": "SOPHONTSIMP",
+ "metrics": {
+ "retweet_count": 0,
+ "reply_count": 2,
+ "like_count": 1,
+ "quote_count": 0,
+ "bookmark_count": 0,
+ "impression_count": 94
+ },
+ "score": 2.1,
+ "url": "https://x.com/i/web/status/2064072188756648270"
+ }
+ ]
+ },
+ {
+ "topic": "Chatbots / Atendimento / WhatsApp",
+ "count": 11,
+ "total_score": 23.408999999999995,
+ "top_tweets": [
+ {
+ "id": "2063278361678360640",
+ "text": "Here are some of the biggest AI trends in 2026:\n\n1. Agentic AI\n\nAI systems are moving beyond chatbots into “agents” that can:\n\nPlan multi-step tasks\nUse tools and software\nConduct research\nAutomate workflows\nCollaborate with other AI agents\n\nExamples include coding agents,",
+ "author": "JCKCROWLEY",
+ "metrics": {
+ "retweet_count": 0,
+ "reply_count": 5,
+ "like_count": 4,
+ "quote_count": 0,
+ "bookmark_count": 0,
+ "impression_count": 259
+ },
+ "score": 6.8,
+ "url": "https://x.com/i/web/status/2063278361678360640"
+ },
+ {
+ "id": "2065212098859200622",
+ "text": "Durante anos a internet brincou com a história de que o WhatsApp ficaria pago.\n\nAgora a realidade é ainda mais interessante.\n\nA Meta quer transformar o WhatsApp em uma plataforma de agentes de inteligência artificial.\n\nIsso muda completamente o jogo para empresas, profissionais e",
+ "author": "luisroquette",
+ "metrics": {
+ "retweet_count": 1,
+ "reply_count": 0,
+ "like_count": 2,
+ "quote_count": 0,
+ "bookmark_count": 1,
+ "impression_count": 60
+ },
+ "score": 5.6,
+ "url": "https://x.com/i/web/status/2065212098859200622"
+ },
+ {
+ "id": "2065691723515461891",
+ "text": "Everyone is debating whether Anthropic,\nOpenAI, or Google DeepMind, will reach AGI first...\nI bet the McDonald's chatbot will win the race https://t.co/ztnsLborex",
+ "author": "Lsc8954",
+ "metrics": {
+ "retweet_count": 0,
+ "reply_count": 1,
+ "like_count": 4,
+ "quote_count": 0,
+ "bookmark_count": 0,
+ "impression_count": 41
+ },
+ "score": 4.5,
+ "url": "https://x.com/i/web/status/2065691723515461891"
+ },
+ {
+ "id": "2065329395280293942",
+ "text": "$NOK Everyone is focused on AI chips, but AI is about to transform another massive industry: telecommunications.\n\nNokia’s new agentic AI framework isn’t just another chatbot for network operators. It’s designed to understand live network conditions, reason over trusted data, and ",
+ "author": "gulVasikova",
+ "metrics": {
+ "retweet_count": 0,
+ "reply_count": 0,
+ "like_count": 4,
+ "quote_count": 0,
+ "bookmark_count": 0,
+ "impression_count": 343
+ },
+ "score": 4.3,
+ "url": "https://x.com/i/web/status/2065329395280293942"
+ },
+ {
+ "id": "2063323033465688477",
+ "text": "Agentic AI is the new SaaS.\n\nInstead of tools you use—\nyou’ll have agents that work for you.\n\nGoldman Sachs & Deutsche Bank are already testing agentic AI for deal workflows.\n\nIn GTM terms:\n• AI agents that qualify leads while you sleep\n• Agents that draft proposals from a",
+ "author": "0xsairahul",
+ "metrics": {
+ "retweet_count": 0,
+ "reply_count": 0,
+ "like_count": 1,
+ "quote_count": 0,
+ "bookmark_count": 0,
+ "impression_count": 22
+ },
+ "score": 1.0,
+ "url": "https://x.com/i/web/status/2063323033465688477"
+ }
+ ]
+ },
+ {
+ "topic": "Regulação / ética / segurança",
+ "count": 9,
+ "total_score": 14.703000000000001,
+ "top_tweets": [
+ {
+ "id": "2064610560700743823",
+ "text": "@goncalo_nspinto @lmldias @natroposfera @jl_zamith Desculpa lá, desde quando é que o claude é argumento para o que quer que seja?\nNão há nada mais patético que isso.\nChatGPT, Claude, Gemini, etc não são nem de perto nem de logo argumentos. https://t.co/sU8kFFkBY4",
+ "author": "cubexpt",
+ "metrics": {
+ "retweet_count": 0,
+ "reply_count": 3,
+ "like_count": 0,
+ "quote_count": 1,
+ "bookmark_count": 0,
+ "impression_count": 1463
+ },
+ "score": 4.5,
+ "url": "https://x.com/i/web/status/2064610560700743823"
+ },
+ {
+ "id": "2064800954374127811",
+ "text": "On June 5, OpenAI, Anthropic, Google DeepMind & Microsoft asked Congress to mandate synthetic-DNA screening. The bioweapon risk is real. But watch the trajectory: today a voluntary norm, tomorrow policy, eventually regulation. The industry is writing the law it'll live under.",
+ "author": "fortylaunch",
+ "metrics": {
+ "retweet_count": 1,
+ "reply_count": 0,
+ "like_count": 1,
+ "quote_count": 0,
+ "bookmark_count": 0,
+ "impression_count": 23
+ },
+ "score": 3.0,
+ "url": "https://x.com/i/web/status/2064800954374127811"
+ },
+ {
+ "id": "2065675890839728277",
+ "text": "samsung baniu chatgpt em 2023 depois de vazar código interno.\n\nagora em 2026? liberou chatgpt, gemini E claude pra empresa inteira.\n\no que mudou?\n\nnada — a pressão da concorrência só ficou alta demais.\n\nsegurança sempre foi desculpa, produtividade sempre foi o jogo.\né tudo https:",
+ "author": "netaotech",
+ "metrics": {
+ "retweet_count": 0,
+ "reply_count": 0,
+ "like_count": 1,
+ "quote_count": 0,
+ "bookmark_count": 1,
+ "impression_count": 28
+ },
+ "score": 2.5,
+ "url": "https://x.com/i/web/status/2065675890839728277"
+ },
+ {
+ "id": "2064551884430487855",
+ "text": "@BlancheMinerva @mtavitschlegel I think open-source AI is inherently unsafe and neither Anthropic nor OpenAI should enable RSI loops for open-source AI prematurely \n\nI def don't want open-source AGI or ASI before there is closed-source one that is properly vetted for safety and a",
+ "author": "BlackHC",
+ "metrics": {
+ "retweet_count": 0,
+ "reply_count": 2,
+ "like_count": 1,
+ "quote_count": 0,
+ "bookmark_count": 0,
+ "impression_count": 93
+ },
+ "score": 2.1,
+ "url": "https://x.com/i/web/status/2064551884430487855"
+ },
+ {
+ "id": "2064305713355034853",
+ "text": "@wahab_twts Anthropic and OpenAI lead the frontier model race. Anthropic has the safety moat and Claude Code adoption is real. OpenAI has brand and distribution but IPO pressure is a long term risk.\n\nGoogle and Microsoft win on distribution and infrastructure. Neither leads on mo",
+ "author": "MixRoute_ai",
+ "metrics": {
+ "retweet_count": 0,
+ "reply_count": 1,
+ "like_count": 1,
+ "quote_count": 0,
+ "bookmark_count": 0,
+ "impression_count": 66
+ },
+ "score": 1.6,
+ "url": "https://x.com/i/web/status/2064305713355034853"
+ }
+ ]
+ }
+ ],
+ "top_tweets_overall": [
+ {
+ "text": "Google founders are Jewish, has a frontier model\nAnthropic founder is Jewish, has a frontier model\nOpenAI founder is Jewish, has a frontier model\n\nxai founder isn’t Jewish, doesn’t have a frontier model\nMicrosoft founder isn’t Jewish, doesn’t have a frontier model\nDeepseek https:",
+ "author": "GolerGkA",
+ "metrics": {
+ "retweet_count": 51,
+ "reply_count": 65,
+ "like_count": 1618,
+ "quote_count": 19,
+ "bookmark_count": 443,
+ "impression_count": 272706
+ },
+ "score": 2718.2,
+ "topics": [
+ "Modelos novos (GPT/Claude/Gemini/Llama)",
+ "OpenAI / Anthropic / Big Labs"
+ ],
+ "url": "https://x.com/i/web/status/2064830151305699469"
+ },
+ {
+ "text": "This is why you should support open source AI \n\nAuthoritarian governments can’t exert control on our lives and play god \n\nWithout open source AI, US government will restrict AGI access to a small set of elitists 😭",
+ "author": "bindureddy",
+ "metrics": {
+ "retweet_count": 54,
+ "reply_count": 81,
+ "like_count": 448,
+ "quote_count": 8,
+ "bookmark_count": 21,
+ "impression_count": 60284
+ },
+ "score": 700.3,
+ "topics": [
+ "Open source / modelos abertos"
+ ],
+ "url": "https://x.com/i/web/status/2065627605144055865"
+ },
+ {
+ "text": "MARCUS CHEN EMPILHOU 30 MAC MINIS E CRIOU UMA FAZENDA DE SERVIDORES DE IA. UM ÚNICO MAC MINI DE US$ 599 PODE SUBSTITUIR SUA CONTA DE US$ 200/MÊS DO CLAUDE CODE GASTANDO APENAS US$ 3 DE ENERGIA.\n\nHá dois meses, um desenvolvedor publicou no Reddit a sua fatura do Claude Code: US$ h",
+ "author": "DehumanoaDeus",
+ "metrics": {
+ "retweet_count": 20,
+ "reply_count": 14,
+ "like_count": 190,
+ "quote_count": 1,
+ "bookmark_count": 100,
+ "impression_count": 39423
+ },
+ "score": 427.9,
+ "topics": [
+ "IA para código/dev",
+ "Modelos novos (GPT/Claude/Gemini/Llama)"
+ ],
+ "url": "https://x.com/i/web/status/2063506042114474143"
+ },
+ {
+ "text": "chatgpt vs gemini mas o chatgpt tem um bundão, saia e uma venda nos olhos https://t.co/LgBDoYWL00",
+ "author": "scalabriniii",
+ "metrics": {
+ "retweet_count": 8,
+ "reply_count": 10,
+ "like_count": 322,
+ "quote_count": 3,
+ "bookmark_count": 33,
+ "impression_count": 22657
+ },
+ "score": 419.7,
+ "topics": [
+ "Modelos novos (GPT/Claude/Gemini/Llama)"
+ ],
+ "url": "https://x.com/i/web/status/2065418102301442418"
+ },
+ {
+ "text": "AI is reshaping how work gets done globally.\n\nIt’s changing workflows, tasks, and decision-making systems.\n\nSharing these Top 7 AI models compiled by @AndrewBolis.\n\n1. ChatGPT (OpenAI)\n\nVersatile for writing, research, guidance, and support.\n\n2. Claude (Anthropic)\n\nFocuses on htt",
+ "author": "Igor_Buinevici",
+ "metrics": {
+ "retweet_count": 37,
+ "reply_count": 6,
+ "like_count": 98,
+ "quote_count": 0,
+ "bookmark_count": 72,
+ "impression_count": 7256
+ },
+ "score": 290.3,
+ "topics": [
+ "Modelos novos (GPT/Claude/Gemini/Llama)",
+ "OpenAI / Anthropic / Big Labs",
+ "Automação / Workflows com IA"
+ ],
+ "url": "https://x.com/i/web/status/2064346800543482056"
+ },
+ {
+ "text": "The productivity gap is about to become terrifying.\n\nAI users are finishing days of work in hours.\n\nHere's the complete AI automation guide.\n\n📂 AI Automation\n┃\n┣ 📂 Foundations\n┃ ┣ 📂 What Is AI Automation\n┃ ┣ 📂 Automation vs AI\n┃ ┣ 📂 Decision Making\n┃ ┣ 📂 Learning https://t.co/N2E",
+ "author": "shushant_l",
+ "metrics": {
+ "retweet_count": 18,
+ "reply_count": 14,
+ "like_count": 67,
+ "quote_count": 1,
+ "bookmark_count": 48,
+ "impression_count": 2067
+ },
+ "score": 185.6,
+ "topics": [
+ "Automação / Workflows com IA",
+ "IA no trabalho / produtividade"
+ ],
+ "url": "https://x.com/i/web/status/2064724369814044869"
+ },
+ {
+ "text": "Some of the best companies to work for according to candidates.\n\nS+: Anthropic, OpenAI, Google DeepMind, Rentech, TGS, xAI, Citadel Securities, Jane Street, HRT\n\nS: Citadel, D.E. Shaw, Jump, Optiver, Two Sigma, Tesla (Autopilot), Five Rings, SpaceX\n\nS-: IMC, SIG, DRW, Akuna\n\nA++:",
+ "author": "vivoplt",
+ "metrics": {
+ "retweet_count": 8,
+ "reply_count": 33,
+ "like_count": 72,
+ "quote_count": 0,
+ "bookmark_count": 47,
+ "impression_count": 7640
+ },
+ "score": 182.6,
+ "topics": [
+ "OpenAI / Anthropic / Big Labs"
+ ],
+ "url": "https://x.com/i/web/status/2063273860754231748"
+ },
+ {
+ "text": "open-source ai is the only way forward.",
+ "author": "svpino",
+ "metrics": {
+ "retweet_count": 13,
+ "reply_count": 20,
+ "like_count": 92,
+ "quote_count": 2,
+ "bookmark_count": 2,
+ "impression_count": 4823
+ },
+ "score": 138.8,
+ "topics": [
+ "Open source / modelos abertos"
+ ],
+ "url": "https://x.com/i/web/status/2065733417661030904"
+ },
+ {
+ "text": "E se a IA mais preparada para apoiar o médico não for a que se vende como “IA médica”?\n\nUm estudo publicado na Nature Medicine comparou ferramentas clínicas comerciais e dedicadas, OpenEvidence e UpToDate Expert AI, com LLMs gerais (GPT-5.2, Gemini 3.1 Pro e Claude Opus 4.6).\n\nNo",
+ "author": "glauber_doc",
+ "metrics": {
+ "retweet_count": 5,
+ "reply_count": 16,
+ "like_count": 74,
+ "quote_count": 0,
+ "bookmark_count": 25,
+ "impression_count": 4178
+ },
+ "score": 133.7,
+ "topics": [
+ "Modelos novos (GPT/Claude/Gemini/Llama)"
+ ],
+ "url": "https://x.com/i/web/status/2065422550004371529"
+ },
+ {
+ "text": "Open source AI must win!",
+ "author": "omarsar0",
+ "metrics": {
+ "retweet_count": 9,
+ "reply_count": 11,
+ "like_count": 81,
+ "quote_count": 0,
+ "bookmark_count": 3,
+ "impression_count": 5587
+ },
+ "score": 114.6,
+ "topics": [
+ "Open source / modelos abertos"
+ ],
+ "url": "https://x.com/i/web/status/2065632446255665232"
+ },
+ {
+ "text": "Clip with David Morgan: Is open source AI the printing press of the 21st century? We need to use open source AI to further pro-liberty, pro-human goals! https://t.co/uSl0BsOXI9",
+ "author": "HealthRanger",
+ "metrics": {
+ "retweet_count": 17,
+ "reply_count": 9,
+ "like_count": 59,
+ "quote_count": 0,
+ "bookmark_count": 6,
+ "impression_count": 5140
+ },
+ "score": 111.6,
+ "topics": [
+ "Open source / modelos abertos"
+ ],
+ "url": "https://x.com/i/web/status/2064814880826253368"
+ },
+ {
+ "text": "Most people in AI can't actually explain the difference between \"Generative AI,\" \"Agentic AI,\" and \"AI Agents.\"\n\nThey use all three like they mean the same thing. They don't. And once it clicks, you can't unsee it.\n\nHere's the cleanest way to think about it:\n\nGenerative AI is the",
+ "author": "VaibhavSisinty",
+ "metrics": {
+ "retweet_count": 5,
+ "reply_count": 7,
+ "like_count": 38,
+ "quote_count": 1,
+ "bookmark_count": 33,
+ "impression_count": 2984
+ },
+ "score": 105.5,
+ "topics": [
+ "Agentes de IA / Agentic"
+ ],
+ "url": "https://x.com/i/web/status/2064392166131265627"
+ },
+ {
+ "text": "If you get offers from these 3 companies : \n\n- OpenAI \n- Anthropic \n- Google Deepmind \n\nWhich one would you join, and why?",
+ "author": "Its_Nova1012",
+ "metrics": {
+ "retweet_count": 1,
+ "reply_count": 65,
+ "like_count": 61,
+ "quote_count": 0,
+ "bookmark_count": 2,
+ "impression_count": 3560
+ },
+ "score": 102.1,
+ "topics": [
+ "OpenAI / Anthropic / Big Labs"
+ ],
+ "url": "https://x.com/i/web/status/2065284539564675171"
+ },
+ {
+ "text": "OpenAI, Anthropic, Google DeepMind, and Microsoft just agreed on something.\n\nThey're all warning about the same risk: future AI lowering the knowledge barrier around dangerous biological material.\n\nRead the shocking details🧵: https://t.co/EQPjK1gzjG",
+ "author": "themetav3rse",
+ "metrics": {
+ "retweet_count": 20,
+ "reply_count": 6,
+ "like_count": 43,
+ "quote_count": 1,
+ "bookmark_count": 3,
+ "impression_count": 7481
+ },
+ "score": 99.5,
+ "topics": [
+ "OpenAI / Anthropic / Big Labs"
+ ],
+ "url": "https://x.com/i/web/status/2063637138118516779"
+ },
+ {
+ "text": "Stanford just released a 1.2-hour lecture on \"Turning Electricity into Intelligence.\"\n\nThis is the exact knowledge engineers at NVIDIA, Anthropic, xAI, and Google DeepMind require to understand at a deep level.\n\nGive it some time.\n\nThis might be the highest-ROI learning you do ht",
+ "author": "ZabihullahAtal",
+ "metrics": {
+ "retweet_count": 12,
+ "reply_count": 1,
+ "like_count": 20,
+ "quote_count": 0,
+ "bookmark_count": 34,
+ "impression_count": 2886
+ },
+ "score": 98.4,
+ "topics": [
+ "OpenAI / Anthropic / Big Labs"
+ ],
+ "url": "https://x.com/i/web/status/2063972795122638889"
+ },
+ {
+ "text": "2026!!\n\nLearn Ai!!\nLearn Ai skills!!\nLearn Ai Image Gen!!\nLearn Ai Automation!!\nLearn Ai Video editing!!\nLearn AI Content creation!!\nLearn AI Prompt Engineering!!\nLearn Ai Coding(Vibe coding)!!",
+ "author": "AutomationKing0",
+ "metrics": {
+ "retweet_count": 7,
+ "reply_count": 5,
+ "like_count": 53,
+ "quote_count": 1,
+ "bookmark_count": 17,
+ "impression_count": 1292
+ },
+ "score": 97.8,
+ "topics": [
+ "IA para código/dev",
+ "IA generativa de imagem/vídeo",
+ "Automação / Workflows com IA"
+ ],
+ "url": "https://x.com/i/web/status/2064611414241648817"
+ },
+ {
+ "text": "Open source AI must win https://t.co/MnCdiKGGan https://t.co/lRRUXFHfxk",
+ "author": "dankvr",
+ "metrics": {
+ "retweet_count": 8,
+ "reply_count": 6,
+ "like_count": 65,
+ "quote_count": 0,
+ "bookmark_count": 2,
+ "impression_count": 4367
+ },
+ "score": 91.4,
+ "topics": [
+ "Open source / modelos abertos"
+ ],
+ "url": "https://x.com/i/web/status/2065608055572640192"
+ },
+ {
+ "text": "DARIO scared of OPEN SOURCE AI\n\nOPEN SOURCE AI must WIN https://t.co/iybsExsVqM",
+ "author": "chiefofautism",
+ "metrics": {
+ "retweet_count": 6,
+ "reply_count": 3,
+ "like_count": 69,
+ "quote_count": 1,
+ "bookmark_count": 2,
+ "impression_count": 1519
+ },
+ "score": 88.5,
+ "topics": [
+ "Open source / modelos abertos"
+ ],
+ "url": "https://x.com/i/web/status/2064843500512784844"
+ },
+ {
+ "text": "Ótimo uso de inteligência artificial por David Brazil. https://t.co/eWm99QOyrm",
+ "author": "chicobarney",
+ "metrics": {
+ "retweet_count": 3,
+ "reply_count": 9,
+ "like_count": 70,
+ "quote_count": 2,
+ "bookmark_count": 1,
+ "impression_count": 2834
+ },
+ "score": 87.8,
+ "topics": [
+ "Outros / IA geral"
+ ],
+ "url": "https://x.com/i/web/status/2065787621427589404"
+ },
+ {
+ "text": "Compared to Dwarkesh, i'm more sceptical that sample efficiency will block the intelligence explosion.\n\nYes, open source AI catches up to the frontier by using data from frontier AI models. \n\nBut that doesn't imply that sample efficiency hasn't been improving within frontier http",
+ "author": "TomDavidsonX",
+ "metrics": {
+ "retweet_count": 1,
+ "reply_count": 2,
+ "like_count": 41,
+ "quote_count": 0,
+ "bookmark_count": 24,
+ "impression_count": 3701
+ },
+ "score": 83.7,
+ "topics": [
+ "Open source / modelos abertos"
+ ],
+ "url": "https://x.com/i/web/status/2064294448402469166"
+ },
+ {
+ "text": "Inside the AI Stack\n\nInfrastructure — Nvidia, AWS, Google Cloud \nData — Databricks, Snowflake, Palantir \nModels — OpenAI, Anthropic, Google DeepMind \nApplication — Microsoft, Salesforce, Adobe \nOperation — https://t.co/NDtzgDE7UT, IBM, Weights & Biases https://t.co/pc1jJ2IKTb",
+ "author": "Market_Mind_",
+ "metrics": {
+ "retweet_count": 9,
+ "reply_count": 1,
+ "like_count": 36,
+ "quote_count": 1,
+ "bookmark_count": 14,
+ "impression_count": 1309
+ },
+ "score": 78.3,
+ "topics": [
+ "OpenAI / Anthropic / Big Labs"
+ ],
+ "url": "https://x.com/i/web/status/2064734332409803227"
+ },
+ {
+ "text": "The best founders of the best AI \n\n> Elon Musk : ( xAI )\n> Dario Amodei : ( Anthropic )\n> Demis Hassabis : ( Google DeepMind )\n> Sam Altman : ( OpenAI )\n\nwhich AI do you work with the most? https://t.co/sIMfZgR2ct",
+ "author": "alex_atoms",
+ "metrics": {
+ "retweet_count": 0,
+ "reply_count": 30,
+ "like_count": 57,
+ "quote_count": 0,
+ "bookmark_count": 1,
+ "impression_count": 866
+ },
+ "score": 74.4,
+ "topics": [
+ "OpenAI / Anthropic / Big Labs"
+ ],
+ "url": "https://x.com/i/web/status/2063619458028216763"
+ },
+ {
+ "text": "@memes_horriveis Akinator não é IA generativa meu anjo",
+ "author": "ZhyrelDragon",
+ "metrics": {
+ "retweet_count": 0,
+ "reply_count": 1,
+ "like_count": 68,
+ "quote_count": 0,
+ "bookmark_count": 0,
+ "impression_count": 1557
+ },
+ "score": 70.1,
+ "topics": [
+ "Outros / IA geral"
+ ],
+ "url": "https://x.com/i/web/status/2064331850382753938"
+ },
+ {
+ "text": "🤖 Awesome Open Source AI 🛡️\n\nA curated collection of the best open-source AI models, frameworks, agents, RAG tools, inference engines, LLMOps platforms, and developer resources.\n\nPerfect for AI engineers, builders, researchers, and open-source enthusiasts. 🚀\n\n🔗 https://t.co/gtFiT",
+ "author": "VivekIntel",
+ "metrics": {
+ "retweet_count": 6,
+ "reply_count": 2,
+ "like_count": 29,
+ "quote_count": 1,
+ "bookmark_count": 14,
+ "impression_count": 1145
+ },
+ "score": 65.6,
+ "topics": [
+ "Agentes de IA / Agentic",
+ "IA para código/dev",
+ "Open source / modelos abertos"
+ ],
+ "url": "https://x.com/i/web/status/2063475839040438713"
+ },
+ {
+ "text": "INTELIGÊNCIA ARTIFICIAL | A ministra Cármen Lúcia, do Supremo Tribunal Federal (STF), destacou os riscos do uso indevido da inteligência artificial, nessa terça-feira (9), durante o 6º Congresso Brasileiro de Internet. https://t.co/DRLhtvThrv",
+ "author": "ebcnarede",
+ "metrics": {
+ "retweet_count": 12,
+ "reply_count": 1,
+ "like_count": 38,
+ "quote_count": 1,
+ "bookmark_count": 0,
+ "impression_count": 414
+ },
+ "score": 64.4,
+ "topics": [
+ "Outros / IA geral"
+ ],
+ "url": "https://x.com/i/web/status/2065122858162868332"
+ },
+ {
+ "text": "Agentic AI is creating a new class of trust problems.\n\nUseful agents need (1) access to private information, (2) access to untrusted external input, and (3) the ability to act.\n\nHowever, once we combine these three properties, there is always the possibility that an agent can be ",
+ "author": "_weidai",
+ "metrics": {
+ "retweet_count": 2,
+ "reply_count": 7,
+ "like_count": 22,
+ "quote_count": 0,
+ "bookmark_count": 14,
+ "impression_count": 4188
+ },
+ "score": 54.7,
+ "topics": [
+ "Agentes de IA / Agentic"
+ ],
+ "url": "https://x.com/i/web/status/2065160042459152636"
+ },
+ {
+ "text": "the smartest people i know are now maxbidding open source ai.\n\nthe dumbest people i know are now maxbidding open source ai.\n\nthe normies are still using chatgpt. much to think about.",
+ "author": "ConejoCapital",
+ "metrics": {
+ "retweet_count": 0,
+ "reply_count": 7,
+ "like_count": 37,
+ "quote_count": 1,
+ "bookmark_count": 3,
+ "impression_count": 2143
+ },
+ "score": 48.6,
+ "topics": [
+ "Open source / modelos abertos"
+ ],
+ "url": "https://x.com/i/web/status/2065629651125280944"
+ },
+ {
+ "text": "It’s time to make open source ai great again",
+ "author": "0xsachi",
+ "metrics": {
+ "retweet_count": 2,
+ "reply_count": 19,
+ "like_count": 32,
+ "quote_count": 0,
+ "bookmark_count": 0,
+ "impression_count": 873
+ },
+ "score": 46.4,
+ "topics": [
+ "Open source / modelos abertos"
+ ],
+ "url": "https://x.com/i/web/status/2065731180373139889"
+ },
+ {
+ "text": "OpenAI, Google DeepMind, Meta, Microsoft, NVIDIA, Mistral, Hugging Face\n\nthe labs everyone says are closing ai behind a paywall quietly shipped their core tools on github. here are 7 repos, one from each\n\n1. OpenAI, openai/openai-agents-python\nhttps://t.co/6UfYVDOcTy\n27k stars. h",
+ "author": "vorty279",
+ "metrics": {
+ "retweet_count": 2,
+ "reply_count": 2,
+ "like_count": 22,
+ "quote_count": 0,
+ "bookmark_count": 10,
+ "impression_count": 878
+ },
+ "score": 42.9,
+ "topics": [
+ "Agentes de IA / Agentic",
+ "Modelos novos (GPT/Claude/Gemini/Llama)",
+ "OpenAI / Anthropic / Big Labs"
+ ],
+ "url": "https://x.com/i/web/status/2065521758422364435"
+ },
+ {
+ "text": "⚡AI coding agents are no longer just assistants—they’re becoming autonomous teammates.\n\nFrom code completion to multi-step execution across the SDLC, the shift is redefining software engineering.\n\nExplore what’s driving this evolution in Gartner’s 2026 Magic Quadrant for https://",
+ "author": "Gartner_inc",
+ "metrics": {
+ "retweet_count": 6,
+ "reply_count": 1,
+ "like_count": 20,
+ "quote_count": 1,
+ "bookmark_count": 4,
+ "impression_count": 2089
+ },
+ "score": 42.1,
+ "topics": [
+ "Agentes de IA / Agentic",
+ "IA para código/dev"
+ ],
+ "url": "https://x.com/i/web/status/2064069746530668679"
+ },
+ {
+ "text": "Every single AI startup powered by @mintlify with $10B+ valuation and $100M+ revenue run rate: \n\nCrusoe - $10B \nMercor - $10B ✅ \nElevenLabs - $11B \nBaseten - $11B* ✅\nHarvey - $11B ✅\nLovable - $12B* ✅\nOpenEvidence - $12B \nMistral - $14B \nNscale - $14.6B \nFireworks - $15B* ✅ https",
+ "author": "pronounsuponly",
+ "metrics": {
+ "retweet_count": 1,
+ "reply_count": 11,
+ "like_count": 23,
+ "quote_count": 0,
+ "bookmark_count": 3,
+ "impression_count": 5703
+ },
+ "score": 40.7,
+ "topics": [
+ "Modelos novos (GPT/Claude/Gemini/Llama)",
+ "Negócios / startups / investimento"
+ ],
+ "url": "https://x.com/i/web/status/2063674932471730626"
+ },
+ {
+ "text": "Which AI lab do you trust the most, and why?\n\nOpenAI\nAnthropic\nGoogle DeepMind\nxAI\nMeta AI\n\nThe answer says a lot about what you think the future of AI should look like.",
+ "author": "Tech_girlll",
+ "metrics": {
+ "retweet_count": 2,
+ "reply_count": 20,
+ "like_count": 21,
+ "quote_count": 0,
+ "bookmark_count": 1,
+ "impression_count": 1142
+ },
+ "score": 37.6,
+ "topics": [
+ "OpenAI / Anthropic / Big Labs"
+ ],
+ "url": "https://x.com/i/web/status/2065106633915617570"
+ },
+ {
+ "text": "@Scobleizer @BrianRoemmele Open source AI is more important than ever.",
+ "author": "RoyalCities",
+ "metrics": {
+ "retweet_count": 1,
+ "reply_count": 2,
+ "like_count": 33,
+ "quote_count": 0,
+ "bookmark_count": 0,
+ "impression_count": 1354
+ },
+ "score": 37.4,
+ "topics": [
+ "Open source / modelos abertos"
+ ],
+ "url": "https://x.com/i/web/status/2065610051734110352"
+ },
+ {
+ "text": "Anthropic spent years fighting open-source AI, warning the public about its dangers, and locking its own work behind a greedy, unsustainable model.\n\nAnd today, with one move, it gave open-source AI the greatest gift imaginable.\n\nA huge wave of people will now start looking",
+ "author": "Da7_Tech",
+ "metrics": {
+ "retweet_count": 1,
+ "reply_count": 2,
+ "like_count": 24,
+ "quote_count": 1,
+ "bookmark_count": 2,
+ "impression_count": 5509
+ },
+ "score": 37.0,
+ "topics": [
+ "OpenAI / Anthropic / Big Labs",
+ "Open source / modelos abertos"
+ ],
+ "url": "https://x.com/i/web/status/2065610716623651162"
+ },
+ {
+ "text": "open source AI labs satisfaction level rn. https://t.co/lHGcCSfTqh https://t.co/ZFrMHLpgDL",
+ "author": "Meer_AIIT",
+ "metrics": {
+ "retweet_count": 9,
+ "reply_count": 1,
+ "like_count": 16,
+ "quote_count": 0,
+ "bookmark_count": 0,
+ "impression_count": 1080
+ },
+ "score": 35.6,
+ "topics": [
+ "Open source / modelos abertos"
+ ],
+ "url": "https://x.com/i/web/status/2065720471187034217"
+ },
+ {
+ "text": "@IsadoraGameOver O problema eh esse, eles não tão usando só IA como ferramenta kkkk\n\nAli claramente diz IA GENERATIVA, o que é algo COMPLETAMENTE diferente de uma IA comum\n\nMuito provavelmente vão usar IA generativa pra concept arts, designs e por aí vai",
+ "author": "FlipeArt2",
+ "metrics": {
+ "retweet_count": 0,
+ "reply_count": 6,
+ "like_count": 30,
+ "quote_count": 0,
+ "bookmark_count": 1,
+ "impression_count": 1033
+ },
+ "score": 35.5,
+ "topics": [
+ "Outros / IA geral"
+ ],
+ "url": "https://x.com/i/web/status/2063698566791450971"
+ },
+ {
+ "text": "AI companies are hiring software engineers like crazy in 2026 👀\n(Approx total team size / hiring pace)\n\n• OpenAI → 4,000+\n• Anthropic → 2,000+\n• xAI → 1,500+\n• Databricks → 8,000+\n• Scale AI → 1,000+\n• Perplexity → 500+\n• Cohere → 500+\n• ElevenLabs → 300+\n•",
+ "author": "NitinthisSide_",
+ "metrics": {
+ "retweet_count": 0,
+ "reply_count": 11,
+ "like_count": 21,
+ "quote_count": 1,
+ "bookmark_count": 3,
+ "impression_count": 1233
+ },
+ "score": 33.7,
+ "topics": [
+ "OpenAI / Anthropic / Big Labs"
+ ],
+ "url": "https://x.com/i/web/status/2064965979969671541"
+ },
+ {
+ "text": "@rafaelgloves Pensa assim.\n\nProduto A é o que resolve o seu problema. \n\nProduto B no máximo quebra o galho mas é MUITO mais barato que o A.\n\nAcontece que o B está ficando cada vez melhor, enquanto continua sendo MUITO mais barato que o A.\n\nUm belo dia, você pensa: quer saber? O p",
+ "author": "rodrigofm",
+ "metrics": {
+ "retweet_count": 0,
+ "reply_count": 2,
+ "like_count": 26,
+ "quote_count": 0,
+ "bookmark_count": 2,
+ "impression_count": 2921
+ },
+ "score": 32.9,
+ "topics": [
+ "Outros / IA geral"
+ ],
+ "url": "https://x.com/i/web/status/2064358367498273167"
+ },
+ {
+ "text": "Deepseek is still releasing its foundational models and also reportedly closing in on a massive $7B funding round as of early June 2026.\n\nSora got shutdown \n\nGitHub Copilot has huge market in enterprise coding agents\n\nMeta released Llama 4 (April 2025) Scout and Maverick its http",
+ "author": "lochan_twt",
+ "metrics": {
+ "retweet_count": 1,
+ "reply_count": 2,
+ "like_count": 21,
+ "quote_count": 0,
+ "bookmark_count": 3,
+ "impression_count": 3474
+ },
+ "score": 32.0,
+ "topics": [
+ "Agentes de IA / Agentic",
+ "IA para código/dev",
+ "Modelos novos (GPT/Claude/Gemini/Llama)",
+ "IA generativa de imagem/vídeo",
+ "Negócios / startups / investimento"
+ ],
+ "url": "https://x.com/i/web/status/2063330354694705534"
+ },
+ {
+ "text": "The global AI industry is gathering in Singapore this Wednesday. \n\nOpenAI, Google DeepMind, Mistral, AWS, Alibaba, Snowflake, Stripe and 1,500+ companies at Asia's largest AI event plus 100+ side events taking over the city all week.\n\nWe're reaching capacity and ticket portal htt",
+ "author": "superai_conf",
+ "metrics": {
+ "retweet_count": 1,
+ "reply_count": 6,
+ "like_count": 18,
+ "quote_count": 1,
+ "bookmark_count": 0,
+ "impression_count": 2806
+ },
+ "score": 27.3,
+ "topics": [
+ "Modelos novos (GPT/Claude/Gemini/Llama)",
+ "OpenAI / Anthropic / Big Labs"
+ ],
+ "url": "https://x.com/i/web/status/2063826871922651369"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/.dockerignore b/.dockerignore
index f1c74a2b..15af35d0 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1,10 +1,21 @@
.venv/
.git/
+.git-rewrite/
.env
+config/.reload
+config/providers.json
+config/heartbeats.yaml
+config/smart-router.json
+.pytest_cache/
+.handoffs/
__pycache__/
+**/__pycache__/
*.pyc
dashboard/data/
+dashboard/frontend/dist/
workspace/
+backups/
+logs/
ADWs/logs/
ADWs/__pycache__/
.claude/agent-memory/
@@ -12,5 +23,6 @@ ADWs/__pycache__/
etus-boilerplate-agentic-engineer/
social-media-skills/
node_modules/
+**/node_modules/
.DS_Store
*.zip
diff --git a/.env.example b/.env.example
index 1ebe24fd..43523738 100644
--- a/.env.example
+++ b/.env.example
@@ -10,7 +10,15 @@
# a browser session. Keep it secret — grants full API access.
# Generate with: python -c "import secrets; print(secrets.token_urlsafe(32))"
# Usage: curl -H "Authorization: Bearer $DASHBOARD_API_TOKEN" ...
-DASHBOARD_API_TOKEN=
+#
+# On Swarm this comes from the stack's `environment:` section, not this
+# file — left commented out on purpose. entrypoint.sh now guards against an
+# empty value here clobbering the real one (confirmed live 2026-07-14: a
+# blank line here, copied verbatim into the config volume on first boot,
+# silently erased the Docker-injected token and broke every API call with
+# "Authentication required"), but there's no reason to keep the footgun
+# loaded — uncomment and set a value only for non-Swarm/local setups.
+# DASHBOARD_API_TOKEN=
# Optional: which user the token impersonates (defaults to first admin)
DASHBOARD_API_USER=
@@ -26,24 +34,52 @@ CORS_ALLOWED_ORIGINS=*
# Configure via dashboard (Providers page) or edit config/providers.json.
# The active provider determines which CLI (claude or openclaude) is used.
#
+# HOW KEY RESOLUTION WORKS (read this before debugging a 401):
+# 1. Each provider entry in config/providers.json has its own env_vars —
+# including its own OPENAI_API_KEY. That key ALWAYS wins for that
+# provider. The Providers page in the dashboard writes it for you.
+# 2. Keys below in this .env are FALLBACKS, used only when the provider
+# entry has no usable key (empty or "[REDACTED]" placeholder).
+# 3. NVIDIA_API_KEY from this .env is only applied to *.nvidia.com
+# endpoints — it is never sent to other gateways.
+# So: to fix an invalid/expired key for one provider, update it on the
+# Providers page (or in config/providers.json), not here.
+#
# Anthropic (default): no extra config needed — uses native 'claude' CLI.
#
-# OpenRouter (200+ models):
+# OpenRouter (200+ models) — key: https://openrouter.ai/settings/keys
# CLAUDE_CODE_USE_OPENAI=1
# OPENAI_BASE_URL=https://openrouter.ai/api/v1
# OPENAI_API_KEY=sk-or-...
# OPENAI_MODEL=anthropic/claude-sonnet-4
#
-# OpenAI:
+# OpenAI — key: https://platform.openai.com/api-keys
# CLAUDE_CODE_USE_OPENAI=1
# OPENAI_API_KEY=sk-...
# OPENAI_MODEL=gpt-4.1
#
-# Google Gemini:
+# Google Gemini — key: https://aistudio.google.com/apikey
# CLAUDE_CODE_USE_GEMINI=1
# GEMINI_API_KEY=...
# GEMINI_MODEL=gemini-2.5-pro
#
+# NVIDIA NIM — key: https://build.nvidia.com (Get API Key on any model page)
+# CLAUDE_CODE_USE_OPENAI=1
+# OPENAI_BASE_URL=https://integrate.api.nvidia.com/v1
+# NVIDIA_API_KEY=nvapi-...
+# OPENAI_MODEL=
+#
+# OmniRoute (self-hosted gateway, 237+ providers with auto-fallback):
+# Deploy: https://github.com/diegosouzapw/OmniRoute — see the optional
+# `omniroute` service in evonexus-vps.stack.example.yml.
+# Key: OmniRoute dashboard → Endpoints → create an API key (sk-...).
+# Keys are stored in OmniRoute's SQLite volume — wiping the volume
+# invalidates every key; generate a new one after a reset.
+# CLAUDE_CODE_USE_OPENAI=1
+# OPENAI_BASE_URL=http://omniroute:20128/v1 # Swarm-internal DNS alias
+# OPENAI_API_KEY=sk-... # key from the step above
+# OPENAI_MODEL=auto # let OmniRoute route+fallback
+#
# Install openclaude for non-Anthropic providers:
# npm install -g @gitlawb/openclaude
diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml
new file mode 100644
index 00000000..d7600d45
--- /dev/null
+++ b/.github/workflows/docker-image.yml
@@ -0,0 +1,56 @@
+name: Build and Push Docker Images to Docker Hub
+
+on:
+ push:
+ branches:
+ - main
+ tags:
+ - 'v*'
+
+jobs:
+ build-and-push:
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ include:
+ - image: evo-nexus-dashboard
+ dockerfile: ./Dockerfile.swarm.dashboard
+ - image: evo-nexus-runtime
+ dockerfile: ./Dockerfile.swarm
+ steps:
+ - name: Checkout do código
+ uses: actions/checkout@v4
+
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v3
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Login no Docker Hub
+ uses: docker/login-action@v3
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ - name: Docker meta
+ id: meta
+ uses: docker/metadata-action@v5
+ with:
+ images: ${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image }}
+ tags: |
+ type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
+ type=sha,prefix=,format=short,enable=${{ github.ref == 'refs/heads/main' }}
+ type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/') }}
+ type=ref,event=tag,enable=${{ startsWith(github.ref, 'refs/tags/') }}
+
+ - name: Build e Push da Imagem
+ uses: docker/build-push-action@v5
+ with:
+ context: .
+ file: ${{ matrix.dockerfile }}
+ push: true
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
diff --git a/.github/workflows/docker-publish-britto.yml b/.github/workflows/docker-publish-britto.yml
new file mode 100644
index 00000000..bce0e381
--- /dev/null
+++ b/.github/workflows/docker-publish-britto.yml
@@ -0,0 +1,84 @@
+name: Build & Publish (Docker Hub Sistema Britto)
+
+# Builda as imagens Swarm (evo-nexus-runtime e evo-nexus-dashboard) e publica
+# no SEU Docker Hub, a partir da SUA branch. Diferente da docker-publish.yml
+# oficial (que publica no namespace evoapicloud), esta usa o seu usuário via
+# secrets, então é seguro rodar no seu fork/branch.
+#
+# Secrets necessários no repositório (Settings → Secrets and variables → Actions):
+# * DOCKERHUB_USERNAME — seu usuário do Docker Hub (também vira o namespace)
+# * DOCKERHUB_TOKEN — Access Token do Docker Hub (Account Settings → Security)
+#
+# Triggers:
+# * push na branch feat/chat-openclaude-provider-routing -> publica :latest e :sha-xxxx
+# * push de tag vX.Y.Z -> publica :vX.Y.Z e :latest
+# * disparo manual (workflow_dispatch) -> builda e publica
+
+on:
+ push:
+ branches: [feat/chat-openclaude-provider-routing]
+ tags: ['v*']
+ workflow_dispatch:
+
+env:
+ REGISTRY: docker.io
+
+jobs:
+ build-and-push:
+ name: Build & push ${{ matrix.image }}
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - image: evo-nexus-runtime
+ dockerfile: Dockerfile.swarm
+ - image: evo-nexus-dashboard
+ dockerfile: Dockerfile.swarm.dashboard
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up QEMU (arm64)
+ uses: docker/setup-qemu-action@v3
+ with:
+ platforms: arm64
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Login to Docker Hub
+ uses: docker/login-action@v3
+ with:
+ registry: ${{ env.REGISTRY }}
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ - name: Extract metadata (tags, labels)
+ id: meta
+ uses: docker/metadata-action@v5
+ with:
+ images: ${{ env.REGISTRY }}/${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image }}
+ flavor: |
+ latest=true
+ tags: |
+ type=ref,event=branch
+ type=ref,event=tag
+ type=semver,pattern={{version}}
+ type=sha,prefix=sha-,format=short
+
+ - name: Build and push
+ uses: docker/build-push-action@v5
+ with:
+ context: .
+ file: ${{ matrix.dockerfile }}
+ # amd64 só já cobre VPS comum; adicione linux/arm64 se sua VPS for ARM
+ platforms: linux/amd64
+ push: true
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}
+ cache-from: type=gha,scope=${{ matrix.image }}
+ cache-to: type=gha,scope=${{ matrix.image }},mode=max
diff --git a/.gitignore b/.gitignore
index 9cc7035b..1add524c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -134,3 +134,25 @@ plugins/*/
# ── Brain Repo raw transcripts (not backed up to brain repo) ──
memory/raw-transcripts/
+
+# ── Private root-level workspace data (business content, runtime) ──
+# These top-level folders hold the user's private operational output and must
+# never be committed (especially not in a public PR). Mirrors workspace/** rule.
+/community/
+/courses/
+/daily-logs/
+/finance/
+/logs/
+/meetings/
+/projects/
+/social/
+/strategy/
+
+# ── Local backups & reload triggers ──
+*.bak.telegram
+.env.bak*
+config/.reload
+
+# ── One-off integration test scaffolding (may contain hardcoded creds) ──
+scripts/ghost_integration_test.py
+.git-rewrite
diff --git a/ADWs/logs/health-report-2026-06-16.md b/ADWs/logs/health-report-2026-06-16.md
new file mode 100644
index 00000000..8ddcdb1a
--- /dev/null
+++ b/ADWs/logs/health-report-2026-06-16.md
@@ -0,0 +1,169 @@
+# ═══════════════════════════════════════════════════════════════════════════
+# RELATÓRIO DE DIAGNÓSTICO — EvoNexus
+# Gerado: 2026-06-16
+# ═══════════════════════════════════════════════════════════════════════════
+
+## 1. RESUMO EXECUTIVO
+
+| Métrica | Valor |
+|---------|-------|
+| Total de heartbeat runs (all time) | 421 |
+| Success | 294 (70%) |
+| Fail | 123 (29%) |
+| Running (presos) | 4 |
+| Heartbeats habilitados | 21 |
+| Agentes com falha recorrente | 10+ |
+
+**Problema #1**: 29% de falha nos heartbeats — a maioria por "exit code 1" (erro genérico do openclaude).
+**Problema #2**: zara-2h lidera com 20 falhas (nunca conseguiu completar).
+**Problema #3**: 4 heartbeats "running" travados no DB (zombie runs).
+**Problema #4**: Nenhum mecanismo de retry inteligente — quando falha, só alerta no Telegram.
+
+---
+
+## 2. FALHAS POR HEARTBEAT (hoje, 2026-06-16)
+
+### 2.1. Falhas CRÍTICAS (100% fail, nunca success)
+
+| Heartbeat | Fails | Causa | Ação |
+|-----------|-------|-------|------|
+| autopilot-bolt-executor | 10/10 | exit code 1 (openclaude crash) | Ver agent file, provavelmente .md corrompido |
+| autopilot-compass-planner | 5/5 | exit code 1 | Ver agent file |
+| autopilot-grid-tester | 5/5 | exit code 1 | Ver agent file |
+| autopilot-raven-critic | 5/5 | exit code 1 | Ver agent file |
+| autopilot-vault-security | 5/5 | exit code 1 | Ver agent file |
+
+### 2.2. Falhas PARCIAIS (mix de success/fail)
+
+| Heartbeat | Fails | Success | Causa | Ação |
+|-----------|-------|---------|-------|------|
+| zara-2h | 20 | 3 | "unknown option '---" | Agent .md corrompido (parse error) |
+| autopilot-oath-verifier | 8 | 2 | exit code 1 | Ver agent file |
+| autopilot-pixel-social-media | 7 | 2 | exit code 1 | Ver agent file |
+| autopilot-hawk-debugger | 7 | 4 | exit code 1 | Ver agent file |
+| autopilot-helm-conductor | 6 | 4 | exit code 1 | Ver agent file |
+| autopilot-mako-marketing | 6 | 5 | exit code 1 | Ver agent file |
+| autopilot-sage-strategy | 6 | 5 | context window warning + exit code 1 | Adicionar modelo na context window table |
+| autopilot-quill-writer | 5 | 6 | exit code 1 | Ver agent file |
+| autopilot-scout-explorer | 5 | 5 | context window warning + exit code 1 | Adicionar modelo na context window table |
+| autopilot-clawdia-assistant | 4 | 1 | exit code 1 | Ver agent file |
+| autopilot-dex-data | 4 | 1 | context window warning + exit code 1 | Adicionar modelo na context window table |
+
+### 2.3. Funcionando OK
+
+| Heartbeat | Success | Observação |
+|-----------|---------|------------|
+| integrations-health | 31 | 100% |
+| zara-2h | 3 | 3/23 — quase sempre falha |
+
+---
+
+## 3. CAUSAS RAIZ IDENTIFICADAS
+
+### 3.1. "exit code 1" genérico (maioria das falhas)
+
+O `heartbeat_runner.py` chama `openclaude --print --max-turns N --dangerously-skip-permissions --output-format json -- `. Quando o agente .md tem conteúdo mal-formado (ex: YAML frontmatter quebrado, caracteres especiais não-escapados), o openclaude retorna exit code 1 sem output legível.
+
+**Padrão**: heartbeats que NUNCA dão success (bolt, compass, grid, raven, vault) provavelmente têm .md corrompido.
+
+**Solução**: Script de validação que lê cada .claude/agents/*.md e tenta parse do frontmatter + body.
+
+### 3.2. "context window warning" (sage, scout, clawdia, dex)
+
+O modelo `minimaxai/minimax-m3` não está na tabela de context windows do openclaude. Isso causa warning repetido e pode levar a truncation silencioso → resposta vazia → exit code 1.
+
+**Solução**: Adicionar `minimaxai/minimax-m3` à context window table.
+
+### 3.3. zara-2h: "unknown option '---"
+
+O agent file do zara-cs provavelmente tem conteúdo que o openclaude interpreta como CLI option em vez de system prompt. O `--` separator pode estar sendo mal-parsado.
+
+**Solução**: Revisar `.claude/agents/zara-cs.md` e limpar caracteres especiais.
+
+### 3.4. 4 heartbeats "running" travados
+
+Zombie runs no DB que nunca completam. Provavelmente heartbeats que foram interrompidos (processo morto) sem cleanup.
+
+**Solução**: Script de limpeza que marca runs "running" com mais de 2h como "timeout".
+
+### 3.5. Sem retry inteligente
+
+Quando um heartbeat falha, não há retry. O próximo disparo só acontece no próximo intervalo (15min a 6h). Para heartbeats críticos, isso é inaceitável.
+
+**Solução**: Implementar retry com backoff exponencial (1min, 2min, 4min) antes de desistir.
+
+### 3.6. Sem report de sucesso
+
+Hoje só recebemos alertas de falha no Telegram. Não há visibilidade do que está funcionando.
+
+**Solução**: Já implementado `_step_report_success` — precisa ser deployado.
+
+### 3.7. Concorrência → 429
+
+Múltiplos heartbeats disparando simultaneamente contra o mesmo modelo NVIDIA → burst 429.
+
+**Solução**: Já implementado per-model inflight lock — precisa ser deployado.
+
+---
+
+## 4. PLANO DE AÇÃO (ordem de prioridade)
+
+### P0 — CRÍTICO (faz agora)
+
+1. **Validar todos os agent .md files** — script que identifica corrompidos
+2. **Limpar zombie runs** — marca runs "running" >2h como "timeout"
+3. **Adicionar minimax-m3 na context window table** — elimina warning
+4. **Corrigir zara-cs.md** — remover caracteres que quebram parsing
+5. **Reiniciar app.py** — aplica per-model lock + success reports
+
+### P1 — ALTO (faz esta semana)
+
+6. **Retry inteligente** — 3 tentativas com backoff exponencial por heartbeat
+7. **Script de validação de agentes** — roda no scheduler semanalmente
+8. **Mover heartbeats 100% fail para disabled** — evita ruído (bolt, compass, grid, raven, vault)
+
+### P2 — MÉDIO (faz este mês)
+
+9. **Goal "EvoNexus Self-Heal"** — goal de sistema que auto-corrige problemas
+10. **Dashboard de saúde** — página que mostra success rate por heartbeat
+11. **Alerta de degradação** — se success rate < 50% em 24h, alerta
+
+---
+
+## 5. DADOS TÉCNICOS
+
+### 5.1. Chain NVIDIA (11 modelos, pós-remoção v4-pro)
+
+```
+1. minimaxai/minimax-m3
+2. stepfun-ai/step-3.7-flash
+3. moonshotai/kimi-k2.6
+4. deepseek-ai/deepseek-v4-flash
+5. z-ai/glm-5.1
+6. nvidia/nemotron-3-ultra-550b-a55b
+7. nvidia/nemotron-3-super-120b-a12b
+8. qwen/qwen3.5-122b-a10b
+9. qwen/qwen3.5-397b-a17b
+10. openai/gpt-oss-120b
+11. microsoft/phi-4-multimodal-instruct
+12. stepfun-ai/step-3.5-flash
+```
+
+### 5.2. Rotinas (metrics.json)
+
+| Rotina | Runs | Success Rate | Custo |
+|--------|------|-------------|-------|
+| uso_modelos_dia | 18 | 83% | $0.0014 |
+| ai-news-daily-sage | 4 | 100% | $3.19 |
+| memory-sync | 12 | 92% | $0.66 |
+| good-morning | 10 | 90% | $0.62 |
+| end-of-day | 10 | 100% | $0.23 |
+
+### 5.3. Processos
+
+| Processo | PID | Status |
+|----------|-----|--------|
+| scheduler.py | 2079434 | ✅ running |
+| app.py (Flask) | 3653906 | ✅ running |
+| terminal-server | 3595718 | ✅ running |
+| uso_modelos_dia | 3662520 | ✅ completed (9/12 OK) |
diff --git a/ADWs/routines/approval_queue.py b/ADWs/routines/approval_queue.py
new file mode 100644
index 00000000..76cd3013
--- /dev/null
+++ b/ADWs/routines/approval_queue.py
@@ -0,0 +1,167 @@
+#!/usr/bin/env python3
+"""
+ADWs/routines/approval_queue.py — Fila de aprovação para posts e publicações.
+
+Quando um agente (mako, pixel, social) gera conteúdo para publicar,
+o conteúdo vai pra fila de aprovação em vez de ser publicado direto.
+O Telegram notifica você com o conteúdo + comandos para aprovar/reprovar.
+
+Tabela no DB: approval_queue
+ id, content_type, title, body, media_url, agent, status, created_at, decided_at
+
+Comandos:
+ /aprovar — aprova e publica
+ /reprovar — reprova e descarta
+ /fila — mostra pendentes
+"""
+from __future__ import annotations
+
+import json
+import sqlite3
+import sys
+from datetime import datetime, timezone
+from pathlib import Path
+
+WORKSPACE = Path(__file__).resolve().parent.parent.parent
+DB_PATH = WORKSPACE / "dashboard" / "data" / "evonexus.db"
+
+
+def _get_db():
+ conn = sqlite3.connect(str(DB_PATH), timeout=30)
+ conn.row_factory = sqlite3.Row
+ conn.execute("PRAGMA journal_mode=WAL")
+ return conn
+
+
+def _ensure_table():
+ conn = _get_db()
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS approval_queue (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ content_type VARCHAR(50) NOT NULL,
+ title VARCHAR(500),
+ body TEXT,
+ media_url VARCHAR(500),
+ agent VARCHAR(100),
+ status VARCHAR(20) DEFAULT 'pending',
+ created_at VARCHAR(30),
+ decided_at VARCHAR(30),
+ rejection_reason VARCHAR(500)
+ )
+ """)
+ conn.commit()
+ conn.close()
+
+
+def add_to_queue(content_type: str, title: str = "", body: str = "",
+ media_url: str = "", agent: str = "") -> int:
+ """Add content to approval queue. Returns the queue item id."""
+ _ensure_table()
+ conn = _get_db()
+ now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
+ cur = conn.execute("""
+ INSERT INTO approval_queue (content_type, title, body, media_url, agent, status, created_at)
+ VALUES (?, ?, ?, ?, ?, 'pending', ?)
+ """, (content_type, title, body, media_url, agent, now))
+ conn.commit()
+ item_id = cur.lastrowid or 0
+ conn.close()
+ return item_id
+
+
+def get_pending() -> list[dict]:
+ """Get all pending approval items."""
+ _ensure_table()
+ conn = _get_db()
+ rows = conn.execute(
+ "SELECT * FROM approval_queue WHERE status='pending' ORDER BY created_at DESC LIMIT 20"
+ ).fetchall()
+ conn.close()
+ return [dict(r) for r in rows]
+
+
+def approve(item_id: int) -> bool:
+ """Approve and item. Returns True if found."""
+ conn = _get_db()
+ now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
+ cur = conn.execute(
+ "UPDATE approval_queue SET status='approved', decided_at=? WHERE id=? AND status='pending'",
+ (now, item_id)
+ )
+ conn.commit()
+ ok = cur.rowcount > 0
+ conn.close()
+ return ok
+
+
+def reject(item_id: int, reason: str = "") -> bool:
+ """Reject an item. Returns True if found."""
+ conn = _get_db()
+ now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
+ cur = conn.execute(
+ "UPDATE approval_queue SET status='rejected', decided_at=?, rejection_reason=? WHERE id=? AND status='pending'",
+ (now, reason, item_id)
+ )
+ conn.commit()
+ ok = cur.rowcount > 0
+ conn.close()
+ return ok
+
+
+def get_stats() -> dict:
+ """Get approval queue stats."""
+ _ensure_table()
+ conn = _get_db()
+ rows = conn.execute(
+ "SELECT status, COUNT(*) as cnt FROM approval_queue GROUP BY status"
+ ).fetchall()
+ conn.close()
+ return {r["status"]: r["cnt"] for r in rows}
+
+
+def format_telegram_notification(item: dict) -> str:
+ """Format a queue item for Telegram notification."""
+ ct = item.get("content_type", "post")
+ title = item.get("title", "Sem título")[:100]
+ body = item.get("body", "")[:300]
+ agent = item.get("agent", "sistema")
+ item_id = item["id"]
+
+ return (
+ f"🔔 Aprovação Pendente\n\n"
+ f"📝 {title} ({ct})\n"
+ f"🤖 Por: {agent}\n"
+ f"📋 {body}\n\n"
+ f"✅ /aprovar {item_id}\n"
+ f"❌ /reprovar {item_id}"
+ )
+
+
+def format_pending_list(items: list[dict]) -> str:
+ """Format pending items list for Telegram."""
+ if not items:
+ return "📋 Fila de aprovação: vazia ✨"
+
+ lines = [f"📋 Fila de Aprovação ({len(items)} pendentes)\n"]
+ for item in items[:10]:
+ title = item.get("title", "Sem título")[:60]
+ ct = item.get("content_type", "?")
+ agent = item.get("agent", "?")
+ lines.append(f" #{item['id']} | {ct} | {title} | 🤖 {agent}")
+
+ if len(items) > 10:
+ lines.append(f"\n... e mais {len(items) - 10}")
+
+ lines.append(f"\n✅ /aprovar <id> | ❌ /reprovar <id>")
+ return "\n".join(lines)
+
+
+if __name__ == "__main__":
+ # Quick test
+ _ensure_table()
+ stats = get_stats()
+ print(f"Stats: {stats}")
+ pending = get_pending()
+ print(f"Pending: {len(pending)}")
+ for p in pending[:3]:
+ print(f" #{p['id']} | {p['content_type']} | {p.get('title','')[:50]}")
diff --git a/ADWs/routines/daily_status_report.py b/ADWs/routines/daily_status_report.py
new file mode 100644
index 00000000..3d75e862
--- /dev/null
+++ b/ADWs/routines/daily_status_report.py
@@ -0,0 +1,164 @@
+#!/usr/bin/env python3
+"""
+ADWs/routines/daily_status_report.py — Report diário de status para WhatsApp.
+
+Coleta: rotinas executadas, tasks/goals, tickets abertos, falhas recentes.
+Envia resumo compacto via WhatsApp (Evolution Go API).
+
+Usage:
+ python3 daily_status_report.py --phone 5511999999999
+ python3 daily_status_report.py --dry-run # só imprime, não envia
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import sys
+from datetime import datetime, timedelta, timezone
+from pathlib import Path
+
+WORKSPACE = Path(__file__).resolve().parent.parent.parent
+sys.path.insert(0, str(WORKSPACE / "dashboard" / "backend"))
+
+DB_PATH = WORKSPACE / "dashboard" / "data" / "evonexus.db"
+BRT_OFFSET = timedelta(hours=-3)
+
+
+def _now_utc() -> datetime:
+ return datetime.now(timezone.utc)
+
+
+def _now_brt() -> datetime:
+ return _now_utc() + BRT_OFFSET
+
+
+def _get_db():
+ import sqlite3
+ conn = sqlite3.connect(str(DB_PATH), timeout=30)
+ conn.row_factory = sqlite3.Row
+ conn.execute("PRAGMA journal_mode=WAL")
+ return conn
+
+
+def generate_report() -> str:
+ """Gera report diário como string formatada."""
+ import os
+ conn = _get_db()
+ now_brt = _now_brt()
+ today_utc = (now_brt - BRT_OFFSET).strftime("%Y-%m-%d") + "T00:00:00.000000Z"
+ yesterday_utc = (_now_brt() - timedelta(days=1)).strftime("%Y-%m-%d") + "T00:00:00.000000Z"
+
+ lines = [f"📋 Status Diário — {now_brt.strftime('%d/%m/%Y')}"]
+
+ # ── Scheduled Tasks (today) ──
+ try:
+ task_rows = conn.execute(
+ "SELECT name, status, last_run, error FROM scheduled_tasks WHERE created_at > ? ORDER BY status DESC, name ASC",
+ (today_utc,),
+ ).fetchall()
+ if task_rows:
+ ok_tasks = [r for r in task_rows if r["status"] == "success"]
+ fail_tasks = [r for r in task_rows if r["status"] == "fail"]
+ lines.append(f"\n📌 Rotinas hoje: {len(ok_tasks)} ok / {len(fail_tasks)} fail")
+ for r in fail_tasks[:8]:
+ err = (r["error"] or "sem detalhe")[:60]
+ lines.append(f" ❌ {r['name']}: {err}")
+ except Exception:
+ pass
+
+ # ── Goal Tasks ──
+ try:
+ gt_rows = conn.execute(
+ "SELECT status, COUNT(*) as cnt FROM goal_tasks GROUP BY status"
+ ).fetchall()
+ gt_stats = {r["status"]: r["cnt"] for r in gt_rows}
+ if gt_stats:
+ done = gt_stats.get("done", 0)
+ open_ = gt_stats.get("open", 0)
+ in_prog = gt_stats.get("in_progress", 0)
+ lines.append(f"\n🎯 Goals: {done} done / {in_prog} em andamento / {open_} abertos")
+ except Exception:
+ pass
+
+ # ── Tickets ──
+ try:
+ open_tickets = conn.execute(
+ "SELECT COUNT(*) as cnt FROM tickets WHERE status='open'"
+ ).fetchone()["cnt"]
+ blocked_tickets = conn.execute(
+ "SELECT COUNT(*) as cnt FROM tickets WHERE status='blocked'"
+ ).fetchone()["cnt"]
+ in_progress_tickets = conn.execute(
+ "SELECT COUNT(*) as cnt FROM tickets WHERE status='in_progress'"
+ ).fetchone()["cnt"]
+ lines.append(f"\n🎫 Tickets: {open_tickets} open / {in_progress_tickets} em andamento / {blocked_tickets} bloqueados")
+
+ pending_assign = conn.execute(
+ "SELECT title, priority, assignee_agent FROM tickets WHERE status IN ('open','in_progress','blocked') ORDER BY CASE priority WHEN 'urgent' THEN 1 WHEN 'high' THEN 2 ELSE 3 END, created_at ASC LIMIT 5"
+ ).fetchall()
+ if pending_assign:
+ lines.append("\n Top tickets:")
+ for t in pending_assign:
+ pri_icon = "🔴" if t["priority"] == "urgent" else ("🟡" if t["priority"] == "high" else "🔵")
+ assignee = t["assignee_agent"] or "sem agente"
+ title = t["title"][:45]
+ lines.append(f" {pri_icon} {title} — {assignee}")
+ except Exception:
+ pass
+
+ # ── Heartbeat failures (last 24h) ──
+ try:
+ last24 = (_now_utc() - timedelta(hours=24)).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
+ hb_fails = conn.execute(
+ "SELECT heartbeat_id, error FROM heartbeat_runs WHERE started_at > ? AND status='fail' ORDER BY started_at DESC LIMIT 5"
+ , (last24,)).fetchall()
+ if hb_fails:
+ lines.append(f"\n⚠️ Falhas (24h): {len(hb_fails)}")
+ for h in hb_fails:
+ err = (h["error"] or "exit code 1")[:70]
+ lines.append(f" • {h['heartbeat_id']}: {err}")
+ except Exception:
+ pass
+
+ # ── Footer ──
+ lines.append(f"\n🕐 Gerado em {now_brt.strftime('%H:%M BRT')}")
+ return "\n".join(lines)
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description="Report diário de status via WhatsApp")
+ parser.add_argument("--phone", required=False, help="Número WhatsApp (ex: 5511999999999). Também lê WHATSAPP_PHONE do .env")
+ parser.add_argument("--dry-run", action="store_true", help="Apenas imprime, não envia")
+ args = parser.parse_args()
+
+ phone = args.phone or os.environ.get("WHATSAPP_PHONE", "")
+
+ report = generate_report()
+
+ if args.dry_run:
+ print(report)
+ return 0
+
+ if not phone:
+ print("[daily_status_report] ERRO: passe --phone ou defina WHATSAPP_PHONE no .env")
+ return 1
+
+ try:
+ from notifications import send_whatsapp
+ except ImportError:
+ print("[daily_status_report] ERRO: não foi importar notifications.send_whatsapp")
+ return 1
+
+ ok = send_whatsapp(report, phone)
+ if ok:
+ print(f"[daily_status_report] enviado para {phone}")
+ else:
+ print(f"[daily_status_report] FALHA ao enviar (cheque EVOLUTION_GO_URL/KEY no .env)")
+ return 1
+
+ return 0
+
+
+if __name__ == "__main__":
+ import os
+ sys.exit(main())
diff --git a/ADWs/routines/hourly_report.py b/ADWs/routines/hourly_report.py
new file mode 100644
index 00000000..dc9c16d8
--- /dev/null
+++ b/ADWs/routines/hourly_report.py
@@ -0,0 +1,176 @@
+#!/usr/bin/env python3
+"""
+ADWs/routines/hourly_report.py — Relatório horário de atividade do EvoNexus.
+
+Roda de hora em hora durante horário comercial (08h-20h BRT).
+Coleta: heartbeats executados, tasks completadas, rotinas, erros.
+Envia resumo compacto via Telegram.
+
+Usage:
+ python3 hourly_report.py # gera e envia
+ python3 hourly_report.py --dry-run # só imprime, não envia
+"""
+from __future__ import annotations
+
+import json
+import os
+import sys
+from datetime import datetime, timezone, timedelta
+from pathlib import Path
+
+WORKSPACE = Path(__file__).resolve().parent.parent.parent
+sys.path.insert(0, str(WORKSPACE / "dashboard" / "backend"))
+
+DB_PATH = WORKSPACE / "dashboard" / "data" / "evonexus.db"
+BRT_OFFSET = timedelta(hours=-3)
+
+
+def _now_brt() -> datetime:
+ return datetime.now(timezone.utc) + BRT_OFFSET
+
+
+def _get_db():
+ import sqlite3
+ conn = sqlite3.connect(str(DB_PATH), timeout=30)
+ conn.row_factory = sqlite3.Row
+ conn.execute("PRAGMA journal_mode=WAL")
+ return conn
+
+
+def generate_report() -> str:
+ """Generate hourly activity report as formatted string."""
+ conn = _get_db()
+ now_brt = _now_brt()
+ hour_ago_utc = (now_brt - timedelta(hours=1)).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
+ today_utc = now_brt.strftime("%Y-%m-%d") + "T00:00:00.000000Z"
+
+ # ── Heartbeats (last hour) ──
+ hb_runs = conn.execute(
+ """SELECT heartbeat_id, status, duration_ms, error, triggered_by
+ FROM heartbeat_runs
+ WHERE started_at > ?
+ ORDER BY started_at DESC""",
+ (hour_ago_utc,)
+ ).fetchall()
+
+ # ── Heartbeats (today) ──
+ hb_today = conn.execute(
+ """SELECT status, COUNT(*) as cnt
+ FROM heartbeat_runs
+ WHERE started_at > ?
+ GROUP BY status""",
+ (today_utc,)
+ ).fetchall()
+ hb_today_stats = {r["status"]: r["cnt"] for r in hb_today}
+
+ # ── Tasks (today) ──
+ tasks_today = conn.execute(
+ """SELECT status, COUNT(*) as cnt
+ FROM scheduled_tasks
+ WHERE created_at > ?
+ GROUP BY status""",
+ (today_utc,)
+ ).fetchall()
+ task_stats = {r["status"]: r["cnt"] for r in tasks_today}
+
+ # ── Goal Tasks ──
+ goal_tasks = conn.execute(
+ """SELECT status, COUNT(*) as cnt
+ FROM goal_tasks
+ GROUP BY status"""
+ ).fetchall()
+ gt_stats = {r["status"]: r["cnt"] for r in goal_tasks}
+
+ # ── Pending approvals (tickets) ──
+ pending_tickets = conn.execute(
+ """SELECT COUNT(*) as cnt FROM tickets WHERE status='open'"""
+ ).fetchone()["cnt"]
+
+ # ── Zombie runs ──
+ zombies = conn.execute(
+ """SELECT COUNT(*) as cnt FROM heartbeat_runs
+ WHERE status='running' AND started_at < ?""",
+ ((now_brt - timedelta(hours=2)).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),)
+ ).fetchone()["cnt"]
+
+ # ── Top failures (last hour) ──
+ failures = [r for r in hb_runs if r["status"] == "fail"]
+
+ conn.close()
+
+ # ── Format ──
+ hour_str = now_brt.strftime("%H:%M")
+ lines = [f"📊 Relatório {hour_str} BRT"]
+
+ # Heartbeat summary
+ ok = hb_today_stats.get("success", 0)
+ fail = hb_today_stats.get("fail", 0)
+ running = hb_today_stats.get("running", 0)
+ total = ok + fail + running
+ rate = f"{(ok / max(1, ok + fail) * 100):.0f}%" if (ok + fail) > 0 else "N/A"
+ lines.append(f"❤️ Heartbeats hoje: {ok} ok / {fail} fail / {running} running ({rate})")
+
+ # Last hour detail
+ if hb_runs:
+ last_hour_ok = sum(1 for r in hb_runs if r["status"] == "success")
+ last_hour_fail = sum(1 for r in hb_runs if r["status"] == "fail")
+ lines.append(f"⏱ Última hora: +{last_hour_ok} ok / -{last_hour_fail} fail")
+
+ # Failures detail
+ if failures:
+ lines.append(f"\n⚠️ Falhas ({len(failures)}):")
+ for f in failures[:5]: # max 5
+ err = (f["error"] or "exit code 1")[:80]
+ lines.append(f" • {f['heartbeat_id']}: {err}")
+
+ # Tasks
+ if task_stats:
+ t_pending = task_stats.get("pending", 0)
+ t_completed = task_stats.get("completed", 0)
+ t_failed = task_stats.get("failed", 0)
+ lines.append(f"\n📌 Tasks: {t_completed} ok / {t_failed} fail / {t_pending} pending")
+
+ # Goal tasks
+ if gt_stats:
+ gt_open = gt_stats.get("open", 0)
+ gt_done = gt_stats.get("done", 0)
+ lines.append(f"🎯 Goals: {gt_done} done / {gt_open} open")
+
+ # Pending tickets
+ if pending_tickets > 0:
+ lines.append(f"🎫 Tickets abertos: {pending_tickets}")
+
+ # Zombies
+ if zombies > 0:
+ lines.append(f"🧟 Zombie runs: {zombies} (recomendo limpeza)")
+
+ # Footer
+ lines.append(f"\n🕐 Próximo relatório: {(now_brt + timedelta(hours=1)).strftime('%H:%M')}")
+
+ return "\n".join(lines)
+
+
+def main() -> int:
+ import argparse
+ p = argparse.ArgumentParser()
+ p.add_argument("--dry-run", action="store_true", help="Only print, don't send")
+ args = p.parse_args()
+
+ report = generate_report()
+
+ if args.dry_run:
+ print(report)
+ return 0
+
+ from notifications import notify_hourly_report
+ sent = notify_hourly_report(report)
+ if sent:
+ print(f"[hourly_report] sent at {_now_brt().strftime('%H:%M BRT')}")
+ else:
+ print(f"[hourly_report] NOT sent (Telegram not configured)")
+ print(report)
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/ADWs/routines/memory_sync.py b/ADWs/routines/memory_sync.py
index 81f74e1e..bec4b617 100644
--- a/ADWs/routines/memory_sync.py
+++ b/ADWs/routines/memory_sync.py
@@ -1,41 +1,156 @@
#!/usr/bin/env python3
-"""ADW: Memory Sync — Consolidates memory via Clawdia"""
-
-import sys, os
-sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
-from runner import run_claude, banner, summary
-
-PROMPT = """Run the memory consolidation routine:
-
-1. Read the last 3 daily logs in 'workspace/daily-logs/' (most recent first)
-2. Read the meeting summaries from the last 3 days in 'workspace/meetings/summaries/'
-3. Analyze recent git log: `git log --oneline --since="3 days ago"` and `git diff --stat HEAD~10` to understand what changed in the workspace
-4. For each source, extract:
- - Decisions made → save in memory/ as type 'project'
- - New people or new context about people → save as type 'user' or update existing
- - Feedback or approach corrections → save as type 'feedback'
- - New terms or external references → save as type 'reference'
- - Skills or routines created/changed → update references if relevant
-5. Before saving, check if similar memory already exists — update instead of duplicating
-6. **Ingest propagation** — when saving/updating a memory, check which OTHER memories reference the same entity and update them too. Examples:
- - New info about a person → update their people/ file AND any projects/ that mention them
- - New project detail → update glossary.md if it has a codename entry
- - Role change → update people/ file, glossary.md nicknames table, and CLAUDE.md hot cache
-7. Update MEMORY.md with pointers to new files
-8. Update memory/index.md — ensure all files in memory/ are cataloged by category
-9. Append operations to memory/log.md with format: [DATE] SYNC — summary of changes
-
-Report at the end: how many memories created/updated by type, and how many cross-references propagated.
-Be concise — don't create memories for obvious things or things already documented in code."""
+"""ADW: Memory Sync — deterministic daily memory snapshot."""
+
+from __future__ import annotations
+
+import re
+import subprocess
+import sys
+from datetime import datetime
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
+from runner import banner, run_script, summary
+
+WORKSPACE = Path(__file__).resolve().parents[2]
+MEMORY_DIR = WORKSPACE / "memory"
+SNAPSHOT_PATH = MEMORY_DIR / "context" / "automated-memory-sync.md"
+INDEX_PATH = MEMORY_DIR / "index.md"
+MEMORY_INDEX_PATH = MEMORY_DIR / "MEMORY.md"
+LOG_PATH = MEMORY_DIR / "log.md"
+
+
+def _latest_file(root: Path) -> Path | None:
+ if not root.is_dir():
+ return None
+ files = [
+ path for path in root.rglob("*")
+ if path.is_file() and path.name != ".gitkeep" and path.suffix.lower() in {".md", ".html", ".txt"}
+ ]
+ if not files:
+ return None
+ return max(files, key=lambda path: path.stat().st_mtime)
+
+
+def _read_excerpt(path: Path | None, limit: int = 7000) -> str:
+ if path is None:
+ return "_No source file found._"
+ try:
+ text = path.read_text(encoding="utf-8", errors="replace")
+ except OSError as exc:
+ return f"_Could not read {path}: {exc}_"
+ if path.suffix.lower() == ".html":
+ text = re.sub(r"", " ", text, flags=re.I | re.S)
+ text = re.sub(r"", " ", text, flags=re.I | re.S)
+ text = re.sub(r"<[^>]+>", " ", text)
+ text = re.sub(r"\\s+", " ", text).strip()
+ return text[:limit].strip() or "_Source file is empty._"
+
+
+def _git_output(args: list[str]) -> str:
+ try:
+ result = subprocess.run(
+ args,
+ cwd=WORKSPACE,
+ text=True,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ timeout=20,
+ check=False,
+ )
+ except (OSError, subprocess.TimeoutExpired) as exc:
+ return f"_Command failed: {' '.join(args)} ({exc})_"
+ output = (result.stdout or result.stderr or "").strip()
+ return output or "_No output._"
+
+
+def _ensure_index_entry(path: Path) -> None:
+ entry = "- [Automated Memory Sync](memory/context/automated-memory-sync.md) — Latest bounded daily memory snapshot"
+ try:
+ content = path.read_text(encoding="utf-8") if path.exists() else "# Memory Index\n"
+ except OSError:
+ return
+ if "memory/context/automated-memory-sync.md" in content:
+ return
+ if "## Context" in content:
+ content = content.replace("## Context\n", f"## Context\n{entry}\n", 1)
+ else:
+ content = content.rstrip() + f"\n\n## Context\n{entry}\n"
+ path.write_text(content, encoding="utf-8")
+
+
+def _run_sync() -> dict:
+ MEMORY_DIR.mkdir(exist_ok=True)
+ SNAPSHOT_PATH.parent.mkdir(parents=True, exist_ok=True)
+
+ now = datetime.now().strftime("%Y-%m-%d %H:%M")
+ daily = _latest_file(WORKSPACE / "workspace" / "daily-logs")
+ meeting = _latest_file(WORKSPACE / "workspace" / "meetings" / "summaries")
+ git_log = _git_output(["git", "log", "--oneline", "--since=24 hours ago", "-n", "20"])
+ diff_stat = _git_output(["git", "diff", "--stat", "HEAD~5"])
+
+ daily_label = daily.relative_to(WORKSPACE) if daily else "none"
+ meeting_label = meeting.relative_to(WORKSPACE) if meeting else "none"
+ content = f"""# Automated Memory Sync
+
+Last updated: {now}
+
+This file is maintained by `ADWs/routines/memory_sync.py`. It is intentionally bounded so the daily routine stays reliable and does not spend an open-ended agent session scanning the workspace.
+
+## Sources
+
+- Daily log: `{daily_label}`
+- Meeting summary: `{meeting_label}`
+- Git window: last 24 hours, max 20 commits
+- Diff window: `git diff --stat HEAD~5`
+
+## Recent Daily Log Excerpt
+
+{_read_excerpt(daily)}
+
+## Recent Meeting Summary Excerpt
+
+{_read_excerpt(meeting)}
+
+## Git Activity
+
+```text
+{git_log}
+```
+
+## Diff Stat
+
+```text
+{diff_stat}
+```
+"""
+ SNAPSHOT_PATH.write_text(content, encoding="utf-8")
+
+ _ensure_index_entry(INDEX_PATH)
+ _ensure_index_entry(MEMORY_INDEX_PATH)
+
+ LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
+ with LOG_PATH.open("a", encoding="utf-8") as f:
+ f.write(
+ f"\n[{datetime.now().date()}] SYNC — Automated bounded snapshot updated "
+ f"from daily log `{daily_label}`, meeting `{meeting_label}`, and git activity. "
+ "1 memory updated, 0 created, 0 cross-references propagated."
+ )
+
+ return {
+ "ok": True,
+ "summary": "updated automated-memory-sync.md from bounded sources",
+ }
+
def main():
- banner("🧠 Memory Sync", "Logs • Meetings → Memory | @clawdia")
- results = []
- results.append(run_claude(PROMPT, log_name="memory-sync", timeout=600, agent="clawdia-assistant"))
+ banner("Memory Sync", "Bounded snapshot -> memory/context")
+ results = [run_script(_run_sync, log_name="memory-sync", timeout=60)]
summary(results, "Memory Sync")
+
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
- print("\n⚠ Cancelled.")
+ print("\nCancelled.")
diff --git a/ADWs/routines/uso_modelos_dia.py b/ADWs/routines/uso_modelos_dia.py
new file mode 100755
index 00000000..688bc544
--- /dev/null
+++ b/ADWs/routines/uso_modelos_dia.py
@@ -0,0 +1,307 @@
+#!/usr/bin/env python3
+"""
+Routines/uso_modelos_dia.py — Exercita visivelmente cada modelo NVIDIA disponível.
+
+Roda diariamente via scheduler.py + pode ser disparada manualmente.
+Cada modelo é pingado com um prompt curto que pede um resumo em PT-BR
++ registra token usage e custo no log/JSONL de routines (ADWs/logs/metrics.json).
+Resultado: fica visível no /costs qual modelo está sendo usado.
+
+Outputs:
+ ADWs/logs/routines/uso_modelos_dia-{DATE}.jsonl — 1 entry por modelo
+
+Usage:
+ python3 uso_modelos_dia.py # roda todos os modelos do chain
+ python3 uso_modelos_dia.py --only nvidia # só NVIDIA (default)
+ python3 uso_modelos_dia.py --models model1,model2
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import re
+import sys
+import time
+import urllib.error
+import urllib.request
+from datetime import datetime, timezone, date
+from pathlib import Path
+
+WORKSPACE = Path(__file__).resolve().parent.parent.parent
+sys.path.insert(0, str(WORKSPACE / "dashboard" / "backend"))
+
+# Mirrors the chain in provider_fallback.NVIDIA_MODEL_CHAIN — kept in sync
+# intentionally so the rotina exercises the same models the dispatcher rotates
+# through on 429. Authoritative order from Felipe (2026-06-16) — validated by
+# POST /v1/chat/completions on each model returning 200.
+NVIDIA_DAILY_MODELS = [
+ "minimaxai/minimax-m3", # 1
+ "stepfun-ai/step-3.7-flash", # 2
+ "moonshotai/kimi-k2.6", # 3
+ "deepseek-ai/deepseek-v4-flash", # 4
+ "z-ai/glm-5.1", # 5
+ "nvidia/nemotron-3-ultra-550b-a55b", # 6
+ "nvidia/nemotron-3-super-120b-a12b", # 7
+ "qwen/qwen3.5-122b-a10b", # 8
+ "qwen/qwen3.5-397b-a17b", # 9
+ "openai/gpt-oss-120b", # 10
+ "microsoft/phi-4-multimodal-instruct", # 11
+ "stepfun-ai/step-3.5-flash", # 12
+]
+
+OPENROUTER_DAILY_MODELS = [
+ "openrouter/owl-alpha",
+]
+
+PROMPT = (
+ "Responda em PT-BR em 1-2 frases: por que modelos NVIDIA NIM são úteis "
+ "para múltiplos agentes operando em paralelo com budget limitado? "
+ "Cite 1 benefício operacional concreto."
+)
+
+NVIDIA_BASE_URL = "https://integrate.api.nvidia.com/v1"
+OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
+
+LOGS_DIR = WORKSPACE / "ADWs" / "logs" / "routines"
+LOGS_DIR.mkdir(parents=True, exist_ok=True)
+
+
+def _now_iso() -> str:
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
+
+
+def _get_api_key(provider: str) -> str:
+ """Read API key from env first, then config/providers.json."""
+ if provider == "nvidia":
+ env_key = os.environ.get("NVIDIA_API_KEY") or os.environ.get("OPENAI_API_KEY", "")
+ if env_key:
+ return env_key
+ cfg_path = WORKSPACE / "config" / "providers.json"
+ if cfg_path.is_file():
+ try:
+ cfg = json.loads(cfg_path.read_text(encoding="utf-8"))
+ ek = (cfg.get("providers", {}).get("nvidia", {})
+ .get("env_vars", {}).get("OPENAI_API_KEY", ""))
+ if ek and "****" not in ek:
+ return ek
+ except Exception:
+ pass
+ return ""
+ if provider == "openrouter":
+ return os.environ.get("OPENROUTER_API_KEY", "") or os.environ.get("OPENAI_API_KEY", "")
+ return ""
+
+
+def _call_chat(base_url: str, model: str, api_key: str, prompt: str, timeout: int = 120) -> dict:
+ """Single chat completion call. Returns {ok, output, error, duration_ms, ...}."""
+ url = f"{base_url.rstrip('/')}/chat/completions"
+ body = json.dumps({
+ "model": model,
+ "messages": [{"role": "user", "content": prompt}],
+ "max_tokens": 200,
+ "temperature": 0.3,
+ "stream": False,
+ }).encode("utf-8")
+
+ req = urllib.request.Request(
+ url, data=body, method="POST",
+ headers={
+ "Content-Type": "application/json",
+ "Authorization": f"Bearer {api_key}",
+ },
+ )
+ start = time.time()
+ try:
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
+ data = json.loads(resp.read().decode("utf-8"))
+ choice = (data.get("choices") or [{}])[0]
+ msg = choice.get("message") or {}
+ text = (msg.get("content") or "").strip()
+ refusal = msg.get("refusal")
+ if not text and refusal:
+ text = f"[refusal] {refusal}"
+ usage = data.get("usage", {}) or {}
+ return {
+ "ok": True,
+ "output": text,
+ "tokens_in": usage.get("prompt_tokens"),
+ "tokens_out": usage.get("completion_tokens"),
+ "total_tokens": usage.get("total_tokens"),
+ "duration_ms": int((time.time() - start) * 1000),
+ "error": None,
+ "status_code": 200,
+ "finish_reason": choice.get("finish_reason"),
+ }
+ except urllib.error.HTTPError as e:
+ body = e.read().decode("utf-8", errors="replace")[:1500]
+ return {
+ "ok": False, "output": "", "error": body,
+ "duration_ms": int((time.time() - start) * 1000),
+ "status_code": e.code, "tokens_in": None, "tokens_out": None, "total_tokens": None,
+ }
+ except (urllib.error.URLError, TimeoutError, OSError) as e:
+ return {
+ "ok": False, "output": "", "error": str(e),
+ "duration_ms": int((time.time() - start) * 1000),
+ "status_code": None, "tokens_in": None, "tokens_out": None, "total_tokens": None,
+ }
+
+
+def _log_entry(name: str, entry: dict, today: str) -> Path:
+ log_file = LOGS_DIR / f"{name}-{today}.jsonl"
+ with open(log_file, "a", encoding="utf-8") as f:
+ f.write(json.dumps(entry, ensure_ascii=False) + "\n")
+ return log_file
+
+
+def run(nvidia_only: bool = True, only: list[str] | None = None) -> dict:
+ """Exercise each model once. Returns summary for stdout and metrics."""
+ started = _now_iso()
+ today = date.today().isoformat()
+ results: list[dict] = []
+ summary = {"nvidia_ok": 0, "nvidia_fail": 0, "openrouter_ok": 0, "openrouter_fail": 0}
+
+ targets_nvidia = [m for m in NVIDIA_DAILY_MODELS if not only or m in only] if only else NVIDIA_DAILY_MODELS
+ nvidia_key = _get_api_key("nvidia")
+ if not nvidia_key:
+ print("[uso_modelos_dia] ERRO: NVIDIA API key não encontrada (env NVIDIA_API_KEY ou config/providers.json)")
+ return summary
+
+ print(f"[uso_modelos_dia] Exercitando {len(targets_nvidia)} modelos NVIDIA…", flush=True)
+ for model in targets_nvidia:
+ r = _call_chat(NVIDIA_BASE_URL, model, nvidia_key, PROMPT)
+ entry = {
+ "ts": _now_iso(),
+ "routine": "uso_modelos_dia",
+ "provider": "nvidia",
+ "model": model,
+ "ok": r["ok"],
+ "duration_ms": r["duration_ms"],
+ "tokens_in": r["tokens_in"],
+ "tokens_out": r["tokens_out"],
+ "total_tokens": r["total_tokens"],
+ "status_code": r["status_code"],
+ "error": r["error"],
+ "output_preview": (r["output"] or "")[:160],
+ }
+ results.append(entry)
+ _log_entry("uso_modelos_dia", entry, today)
+ if r["ok"]:
+ summary["nvidia_ok"] += 1
+ print(f" ✓ NVIDIA:{model} ok ({r['duration_ms']}ms, t={r['total_tokens']})", flush=True)
+ else:
+ summary["nvidia_fail"] += 1
+ err_snippet = (r["error"] or "").splitlines()[0][:120] if r["error"] else "?"
+ print(f" ✗ NVIDIA:{model} FAIL ({r['status_code']}) {err_snippet}", flush=True)
+
+ # OpenRouter only on explicit request — avoid burning credits for daily exercise
+ if not nvidia_only and not only:
+ or_key = _get_api_key("openrouter")
+ if or_key:
+ print("[uso_modelos_dia] Exercitando OpenRouter…", flush=True)
+ for model in OPENROUTER_DAILY_MODELS:
+ r = _call_chat(OPENROUTER_BASE_URL, model, or_key, PROMPT)
+ entry = {
+ "ts": _now_iso(),
+ "routine": "uso_modelos_dia",
+ "provider": "openrouter",
+ "model": model,
+ "ok": r["ok"],
+ "duration_ms": r["duration_ms"],
+ "tokens_in": r["tokens_in"],
+ "tokens_out": r["tokens_out"],
+ "total_tokens": r["total_tokens"],
+ "status_code": r["status_code"],
+ "error": r["error"],
+ "output_preview": (r["output"] or "")[:160],
+ }
+ results.append(entry)
+ _log_entry("uso_modelos_dia", entry, today)
+ if r["ok"]:
+ summary["openrouter_ok"] += 1
+ print(f" ✓ OpenRouter:{model} ok ({r['duration_ms']}ms)", flush=True)
+ else:
+ summary["openrouter_fail"] += 1
+ print(f" ✗ OpenRouter:{model} FAIL ({r['status_code']})", flush=True)
+
+ # Update metrics.json keyed by routine name
+ _update_metrics(results)
+
+ finished = _now_iso()
+ print(f"[uso_modelos_dia] Done in {started} → {finished}. "
+ f"NVIDIA ok={summary['nvidia_ok']} fail={summary['nvidia_fail']}.",
+ flush=True)
+ return summary
+
+
+def _update_metrics(results: list[dict]) -> None:
+ """Light-touch append to ADWs/logs/metrics.json so /costs reflects usage."""
+ metrics_path = WORKSPACE / "ADWs" / "logs" / "metrics.json"
+ today = date.today().isoformat()
+
+ try:
+ existing = json.loads(metrics_path.read_text(encoding="utf-8")) if metrics_path.is_file() else {}
+ except Exception:
+ existing = {}
+
+ name = "uso_modelos_dia"
+ entry = existing.setdefault(name, {
+ "agent": "system", "runs": 0, "successes": 0, "success_rate": 0,
+ "avg_seconds": 0, "total_input_tokens": 0, "total_output_tokens": 0,
+ "total_cost_usd": 0.0, "avg_cost_usd": 0.0, "last_run": None,
+ })
+
+ total_runs_before = entry.get("runs", 0)
+ new_runs = len(results)
+ successes = sum(1 for r in results if r.get("ok"))
+ new_total_tokens = sum((r.get("total_tokens") or 0) for r in results)
+ new_in = sum((r.get("tokens_in") or 0) for r in results)
+ new_out = sum((r.get("tokens_out") or 0) for r in results)
+ total_ms = sum((r.get("duration_ms") or 0) for r in results)
+
+ entry["runs"] += new_runs
+ entry["successes"] += successes
+ if entry["runs"] > 0:
+ # 0-100 percentage, matching ADWs/runner.py's convention for every
+ # other routine's success_rate field — this one used to store a 0-1
+ # fraction instead, which every consumer (Routines.tsx, overview.py)
+ # reads assuming 0-100. Confirmed live: 25/30 real successes stored
+ # as 0.8333 displayed/classified as "0.8333% success" / "critical"
+ # instead of the real 83.3% / "healthy".
+ entry["success_rate"] = round((entry["successes"] / entry["runs"]) * 100, 1)
+ # Aggregate token totals
+ entry["total_input_tokens"] = entry.get("total_input_tokens", 0) + new_in
+ entry["total_output_tokens"] = entry.get("total_output_tokens", 0) + new_out
+ # Running average seconds (weighted by call count)
+ prev_avg_s = entry.get("avg_seconds", 0) or 0
+ prev_count = total_runs_before
+ entry["avg_seconds"] = round(
+ ((prev_avg_s * prev_count) + (total_ms / 1000)) / max(1, entry["runs"]), 3
+ )
+ # Light cost estimate: ~$0.00018/1k input, $0.0006/1k output (rough NVIDIA tier-1 estimate)
+ est_cost = (new_in / 1_000_000) * 0.18 + (new_out / 1_000_000) * 0.60
+ entry["total_cost_usd"] = round(entry.get("total_cost_usd", 0) + est_cost, 6)
+ entry["avg_cost_usd"] = round(entry["total_cost_usd"] / max(1, entry["runs"]), 6)
+ entry["last_run"] = _now_iso()
+
+ metrics_path.write_text(json.dumps(existing, ensure_ascii=False, indent=2), encoding="utf-8")
+
+
+def main() -> int:
+ p = argparse.ArgumentParser()
+ p.add_argument("--all-providers", action="store_true",
+ help="Também exercita OpenRouter (desligado por padrão).")
+ p.add_argument("--models", type=str, default="",
+ help="Lista separada por vírgula — só exercita esses modelos.")
+ args = p.parse_args()
+
+ only = [m.strip() for m in args.models.split(",") if m.strip()] or None
+ summary = run(nvidia_only=not args.all_providers, only=only)
+ # Exit 0 if at least one NVIDIA model succeeded
+ return 0 if (summary["nvidia_ok"] > 0) else 1
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/ADWs/runner.py b/ADWs/runner.py
index 5a2cda4b..88519b06 100644
--- a/ADWs/runner.py
+++ b/ADWs/runner.py
@@ -45,6 +45,39 @@ def _parse_usage(json_result: dict) -> dict:
}
+def _agent_prompt(agent: str | None) -> str:
+ """Return agent persona text without YAML frontmatter."""
+ if not agent:
+ return ""
+ agent_file = WORKSPACE / ".claude" / "agents" / f"{agent}.md"
+ try:
+ content = agent_file.read_text(encoding="utf-8")
+ except OSError:
+ return f"You are the {agent} agent."
+ if content.startswith("---"):
+ parts = content.split("---", 2)
+ if len(parts) == 3:
+ content = parts[2]
+ for marker in ("\n# Persistent Agent Memory", "\n## MEMORY.md"):
+ if marker in content:
+ content = content.split(marker, 1)[0]
+ return content.strip()
+
+
+def _embed_agent_for_openclaude(prompt: str, agent: str | None) -> str:
+ """OpenClaude can misparse --agent frontmatter; embed persona in prompt."""
+ persona = _agent_prompt(agent)
+ if not persona:
+ return prompt
+ return (
+ f"{persona}\n\n"
+ f"CRITICAL: You MUST fully embody this agent persona. "
+ f"You are NOT Claude, OpenClaude, or a generic assistant — you ARE {agent}. "
+ f"Never break character. Follow ALL instructions above.\n\n"
+ f"---\n\nTask:\n{prompt}"
+ )
+
+
def _save_metrics(log_name, duration, returncode, agent, stdout, usage=None):
"""Save accumulated metrics per routine in metrics.json."""
metrics_file = LOGS_DIR / "metrics.json"
@@ -70,6 +103,8 @@ def _save_metrics(log_name, duration, returncode, agent, stdout, usage=None):
m["avg_seconds"] = round(m["total_seconds"] / m["runs"], 1)
m["last_run"] = datetime.now().isoformat()
m["agent"] = agent or "none"
+ m["last_returncode"] = returncode
+ m["last_success"] = returncode == 0
if returncode == 0:
m["successes"] += 1
@@ -127,13 +162,16 @@ def _log_to_file(log_name, prompt, stdout, stderr, returncode, duration, usage=N
_ALLOWED_CLI_COMMANDS = frozenset({"claude", "openclaude"})
-def _spawn_cli(cli_command: str, prompt: str, agent: str | None, provider_env: dict) -> subprocess.Popen:
+def _spawn_cli(cli_command: str, prompt: str, agent: str | None, provider_env: dict, max_turns: int) -> subprocess.Popen:
"""Spawn a CLI process using only hardcoded command strings.
Uses a dictionary lookup so that the subprocess argument is always
a static string, satisfying semgrep/opengrep subprocess injection rules.
"""
- base_args = ["--print", "--dangerously-skip-permissions", "--output-format", "json"]
+ base_args = ["--print", "--max-turns", str(max_turns), "--dangerously-skip-permissions", "--output-format", "json"]
+ if cli_command == "openclaude":
+ prompt = _embed_agent_for_openclaude(prompt, agent)
+ agent = None
if agent:
base_args.extend(["--agent", agent])
base_args.append(prompt)
@@ -195,7 +233,7 @@ def _get_provider_config() -> tuple[str, dict]:
return "claude", {}
-def run_claude(prompt: str, log_name: str = "unnamed", timeout: int = 600, agent: str = None) -> dict:
+def run_claude(prompt: str, log_name: str = "unnamed", timeout: int = 600, agent: str = None, max_turns: int = 10) -> dict:
"""
Execute AI CLI (claude or openclaude) with streaming output.
@@ -207,6 +245,7 @@ def run_claude(prompt: str, log_name: str = "unnamed", timeout: int = 600, agent
log_name: Name for logs
timeout: Timeout in seconds
agent: Agent name (.claude/agents/*.md) — if None, runs without agent
+ max_turns: Maximum CLI tool-use turns before stopping the run
"""
cli_command, provider_env = _get_provider_config()
@@ -214,13 +253,83 @@ def run_claude(prompt: str, log_name: str = "unnamed", timeout: int = 600, agent
agent_label = f"@{agent}"
else:
agent_label = ""
+
+ use_fallback = os.environ.get("ADW_PROVIDER_FALLBACK", "1").lower() not in (
+ "0", "false", "no",
+ )
+ if use_fallback:
+ try:
+ sys.path.insert(0, str(WORKSPACE / "dashboard" / "backend"))
+ from provider_fallback import invoke_with_fallback
+
+ provider_label = "[fallback]"
+ console.print(f" [step]▶[/step] {log_name} [dim]{agent_label} {provider_label}[/dim]", end="")
+ start_time = datetime.now()
+ result = invoke_with_fallback(
+ prompt=prompt,
+ max_turns=max_turns,
+ timeout_seconds=timeout,
+ agent=agent or "",
+ )
+ duration = (datetime.now() - start_time).total_seconds()
+ stdout = result.get("output", "") or ""
+ stderr = result.get("error", "") or ""
+ success = result.get("status") == "success"
+
+ usage = None
+ if result.get("tokens_in") is not None or result.get("tokens_out") is not None or result.get("cost_usd") is not None:
+ usage = {
+ "input_tokens": result.get("tokens_in") or 0,
+ "output_tokens": result.get("tokens_out") or 0,
+ "cache_creation_tokens": result.get("cache_creation_tokens") or 0,
+ "cache_read_tokens": result.get("cache_read_tokens") or 0,
+ "cost_usd": result.get("cost_usd") or 0,
+ }
+
+ result_text = stdout
+ try:
+ json_result = json.loads(stdout)
+ usage = usage or _parse_usage(json_result)
+ result_text = json_result.get("result", stdout)
+ except (json.JSONDecodeError, TypeError, AttributeError):
+ pass
+
+ full_prompt = f"[agent:{agent}] {prompt}" if agent else prompt
+ returncode = 0 if success else 1
+ _log_to_file(log_name, full_prompt, result_text, stderr, returncode, duration, usage)
+ _save_metrics(log_name, duration, returncode, agent, result_text, usage)
+
+ model_info = f" | {result.get('provider_id')}:{result.get('model')}" if result.get("provider_id") else ""
+ if success:
+ cost_str = ""
+ if usage:
+ tokens_total = usage["input_tokens"] + usage["output_tokens"]
+ cost_str = f" | {tokens_total:,}tok | ${usage['cost_usd']:.2f}"
+ console.print(f"\r [success]✓[/success] {log_name} [dim]({duration:.0f}s{cost_str}{model_info})[/dim]")
+ else:
+ console.print(f"\r [error]✗[/error] {log_name} [dim](fallback exhausted, {duration:.0f}s{model_info})[/dim]")
+ if stderr:
+ for err_line in stderr.strip().splitlines()[:3]:
+ console.print(f" [error]{err_line}[/error]")
+
+ return {
+ "success": success,
+ "stdout": result_text,
+ "stderr": stderr,
+ "returncode": returncode,
+ "duration": duration,
+ "usage": usage,
+ }
+ except Exception as e:
+ console.print(f"\r [warning]⚠[/warning] {log_name} [dim](fallback unavailable: {e}; using active provider)[/dim]")
+
provider_label = f"[{cli_command}]" if cli_command != "claude" else ""
console.print(f" [step]▶[/step] {log_name} [dim]{agent_label} {provider_label}[/dim]", end="")
start_time = datetime.now()
try:
- process = _spawn_cli(cli_command, prompt, agent, provider_env)
+ process = _spawn_cli(cli_command, prompt, agent, provider_env, max_turns)
stdout_lines = []
line_count = 0
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c1a796f8..d0e8cce8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,27 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [0.33.0] - 2026-04-25
+
+Plugin contract release. Five PRs merged in one day to unblock the EvoNexus Plugin Nutri (and any future plugin needing per-endpoint role enforcement, public token-bound portals, or safe uninstall). Plus a UX fix so `409 CONFLICT` from plugin install actually says *why* it conflicted.
+
+### Added
+
+- **`requires_role` on `PluginWritableResource`** (PR #55) — plugins can declare a list of roles allowed on each writable endpoint. The host returns `403` when `current_user.role` is not in the list. `'admin'` always passes (super-user override). Backwards compatible: resources without the field accept any authenticated user.
+- **Auto-injected readonly bind params** (PR #55) — every `readonly_data` query receives `:current_user_id` and `:current_user_role` server-side. Plugins reference them directly in SQL for scoping (`WHERE primary_nutritionist_id = :current_user_id`). Both names are reserved — clients that try to spoof them via `?current_user_id=...` get `400`.
+- **`public_pages` capability** (PR #53) — token-bound public portals at `/p/{slug}/{route_prefix}/{token}`. Token validated against a plugin-declared `token_source.column`. CSP, rate limit, and security headers applied. Read-only `readonly_data` queries can be exposed to the portal via `public_via` + `bind_token_param`.
+- **HTML shell content negotiation** (PR #56) — when a request includes `text/html` in `Accept`, the host renders a minimal HTML shell that loads the plugin bundle as a module and instantiates the declared custom element with `data-token`. Programmatic clients (`Accept: application/javascript`, default `*/*`) keep getting the raw bundle. Plugins ship a single JS bundle and get a working browser experience for free.
+- **`safe_uninstall` capability** (PR #54) — three-step uninstall wizard with `preserved_tables` (renamed to `_orphan_{slug}_*` instead of dropped), pre-uninstall hook (sandboxed: read-only DB, no `BRAIN_REPO_MASTER_KEY`), and required user confirmation (checkbox + typed phrase + ZIP password). Reinstall verifies SHA256 and restores access to preserved data.
+- **Rate limit + security headers** (PR #52) — `flask-limiter` with in-memory storage on the public share endpoint and any future `/p/...` route. Five security headers applied to public responses (`Referrer-Policy`, `Cache-Control: no-store`, HSTS, `X-Content-Type-Options`, `Pragma`).
+
+### Fixed
+
+- **Plugin install wizard now shows the actual reason for `409 CONFLICT`.** The frontend was treating any 4xx as an opaque error string. Now `buildError` in `lib/api.ts` falls back to `data.conflicts[0]` when the standard `error`/`message` fields are absent (which is the case for the plugin preview endpoint), so a version mismatch shows up as `"409 CONFLICT: Plugin 'nutri' requires EvoNexus >= 0.33.0, but installed version is 0.32.3."` instead of just `"409 CONFLICT"`. `PluginInstallModal` also fixes the type of `conflicts` (was `Record`, the backend always returned `string[]`) and renders each conflict as a list item.
+
+### Compat
+
+- All existing plugins (PM Essentials, etc.) work unchanged. New manifest fields default to absent / `None` and the auto-injected bind params are silently ignored if the SQL doesn't reference them. The `409` body shape for plugin install was already `{conflicts: [...], manifest, ...}` — only the frontend's interpretation changed.
+
## [0.32.3] - 2026-04-25
Patch release fixing a long-standing Workspace UI bug where folders refused to open and the dev console flooded with `400 Path is a directory` requests, plus a small UX win on the file share dialog (reuse existing share links instead of generating a new token every time). Also includes the upstream PR #51 (private-repo plugin update flow + ClickUp webhook compat + DetachedInstanceError).
diff --git a/Dockerfile b/Dockerfile
index 9785ec05..291840e5 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -14,9 +14,11 @@ ENV PATH="/root/.local/bin:$PATH"
RUN npm install -g @anthropic-ai/claude-code
# Install OpenClaude CLI (required for non-Anthropic providers: OpenAI, Codex OAuth, OpenRouter, Gemini, etc.)
-# Pin to @latest to avoid the npm dist-tag lag; min supported is 0.3.0
-# (first version with the Codex shortcut endpoint fix, openclaude#566).
-RUN npm install -g @gitlawb/openclaude@latest
+# Pinned to a tested version instead of @latest — floating @latest meant every
+# image rebuild could silently change CLI behavior (backoff, error bodies,
+# provider rotation) without anyone testing the new release first. Bump this
+# deliberately after validating the new version, not automatically.
+RUN npm install -g @gitlawb/openclaude@0.23.0
# Install GitHub CLI
RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \
diff --git a/Dockerfile.dev b/Dockerfile.dev
index 86f7ca5b..80bd4135 100644
--- a/Dockerfile.dev
+++ b/Dockerfile.dev
@@ -32,9 +32,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& rm -rf /var/lib/apt/lists/*
# CLIs usadas pelo terminal embutido (/agents) e pela página de providers (/providers)
+# openclaude pinado (não @latest) — ver Dockerfile principal para o motivo.
RUN npm install -g \
@anthropic-ai/claude-code \
- @gitlawb/openclaude@latest
+ @gitlawb/openclaude@0.23.0
# uv
RUN curl -LsSf https://astral.sh/uv/install.sh | sh
diff --git a/Dockerfile.swarm b/Dockerfile.swarm
index e49b69ce..7ff99888 100644
--- a/Dockerfile.swarm
+++ b/Dockerfile.swarm
@@ -26,14 +26,32 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
RUN curl -LsSf https://astral.sh/uv/install.sh | sh
ENV PATH="/root/.local/bin:${PATH}"
+# Cache-buster: the CI publish workflow builds with cache-from/cache-to
+# type=gha, so without this the block below stays cached forever across CI
+# runs and "@latest" silently freezes at whatever version first built the
+# layer. Passing a build-arg that changes every run (e.g. github.run_id)
+# invalidates this layer and everything after it, forcing a fresh npm/apt
+# install on every stack deploy.
+ARG CACHEBUST=1
+
# Install Claude Code CLI (default provider)
RUN npm install -g @anthropic-ai/claude-code
-# Install OpenClaude CLI — pinned to @latest which is ≥0.3.0 (the first
-# release with the Codex shortcut endpoint fix that resolves the
-# api.responses.write scope issue). Upstream setup.py also installs @latest
-# so Swarm and VPS behavior stay aligned.
-RUN npm install -g @gitlawb/openclaude@latest
+# Install OpenClaude CLI — pinned to a tested version instead of @latest.
+# Floating @latest meant every Swarm redeploy could silently ship a new CLI
+# release (backoff, error-body, provider-rotation behavior all changed
+# under us across recent releases) without anyone testing it first. Bump
+# this deliberately after validating the new version.
+RUN npm install -g @gitlawb/openclaude@0.23.0
+
+# Install opencode CLI — provider "opencode" (ver opencode.json na raiz
+# do workspace). Pinned pela mesma razão do openclaude acima.
+RUN npm install -g opencode-ai@1.17.18
+
+# sharp — optional peer dep the CLI's own Read tool needs to process image
+# files (photos, reference images, screenshots). Without it, Read on any
+# image fails every time with "image support is not installed".
+RUN npm install -g sharp
# GitHub CLI
RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
@@ -46,6 +64,10 @@ RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
# Todoist CLI (used by int-todoist skill)
RUN npm install -g todoist-ts-cli
+# Bun — required by Claude Code channel plugins (the telegram channel's
+# start script is `bun install && bun server.ts`)
+RUN npm install -g bun
+
# Timezone (default America/Sao_Paulo, override via TZ env var)
ENV TZ=America/Sao_Paulo
RUN ln -snf /usr/share/zoneinfo/${TZ} /etc/localtime && echo "${TZ}" > /etc/timezone
@@ -54,11 +76,27 @@ WORKDIR /workspace
# Python deps (cache-friendly layer)
COPY pyproject.toml uv.lock ./
-RUN uv venv .venv && uv sync
+RUN uv venv .venv && uv sync --no-dev
+
+# LiteLLM proxy — fronts NVIDIA NIM with an Anthropic-compatible /v1/messages
+# endpoint so `claude --channels` (Telegram bot) can run without an Anthropic
+# key. Used by scripts/telegram_swarm_entry.sh.
+RUN uv pip install --python .venv/bin/python 'litellm[proxy]'
# Application code — everything else
COPY . .
+# .claude/settings.json ships CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1, which
+# isolates background sub-agents in a git worktree. COPY only brings tracked
+# files, never .git, so /workspace has no repo and every background-agent
+# launch inside the container fails with "not in a git repository and no
+# WorktreeCreate hooks are configured". An empty repo is enough for worktree
+# creation to succeed.
+RUN cd /workspace && git init -q \
+ && git config user.email "bot@evonexus.local" \
+ && git config user.name "EvoNexus" \
+ && git commit -q --allow-empty -m "baseline (image build)"
+
# Stash first-boot defaults somewhere the bootstrap entrypoint can find them.
# /workspace/config is a writable volume in Swarm; on first boot entrypoint.sh
# copies these seeds into it so the dashboard has something to edit.
@@ -70,6 +108,14 @@ RUN mkdir -p /workspace/_defaults/config \
cp /workspace/.env.example /workspace/_defaults/.env.example; \
fi
+# Stash .claude so the entrypoint can re-seed built-ins into the mounted
+# /workspace/.claude volume on every boot (custom-*/plugin-* files persist).
+# agent-memory is excluded — it has its own volume and must never be reset.
+RUN if [ -d /workspace/.claude ]; then \
+ cp -a /workspace/.claude /workspace/_defaults/claude \
+ && rm -rf /workspace/_defaults/claude/agent-memory; \
+ fi
+
# Bootstrap / secrets-to-env / wait-for-config wrapper
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
diff --git a/Dockerfile.swarm.dashboard b/Dockerfile.swarm.dashboard
index e19a2f23..5892ebcb 100644
--- a/Dockerfile.swarm.dashboard
+++ b/Dockerfile.swarm.dashboard
@@ -43,7 +43,7 @@ FROM python:3.12-slim AS runtime
# System deps: curl for healthcheck + Node.js 22 for terminal-server binary.
# We use NodeSource so the final image is clean (no build toolchain).
RUN apt-get update && apt-get install -y --no-install-recommends \
- curl ca-certificates gnupg openssl \
+ curl ca-certificates git gnupg openssl \
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& apt-get clean \
@@ -53,12 +53,30 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
RUN curl -LsSf https://astral.sh/uv/install.sh | sh
ENV PATH="/root/.local/bin:${PATH}"
-# Both CLIs — the Providers page in the UI switches between them at runtime.
-# @gitlawb/openclaude@latest is pinned to the npm dist-tag which as of the
-# current fix is ≥0.3.0 (first version with the Codex shortcut endpoint).
+# Cache-buster: the CI publish workflow builds with cache-from/cache-to
+# type=gha, so without this the block below stays cached forever across CI
+# runs and "@latest" silently freezes at whatever version first built the
+# layer. Passing a build-arg that changes every run (e.g. github.run_id)
+# invalidates this layer, forcing a fresh npm install on every stack deploy.
+ARG CACHEBUST=1
+
+# CLIs — the Providers page in the UI switches between them at runtime.
+# @gitlawb/openclaude is pinned to a tested version instead of @latest —
+# floating @latest meant every redeploy could silently ship a new CLI release
+# without anyone testing it first. Bump deliberately after validating.
+# @openai/codex is used only to run the supported Codex device-auth flow and
+# persist ~/.codex/auth.json for OpenClaude's codex_auth provider.
+# sharp is an optional peer dep the CLI's own Read tool needs to process
+# image files (photos, reference images, screenshots) — without it, Read
+# on any image fails with "image support is not installed" every time.
+# opencode-ai é o CLI usado pelo provider "opencode" (ver opencode.json
+# na raiz do workspace) — pinado pela mesma razão do openclaude acima.
RUN npm install -g \
@anthropic-ai/claude-code \
- @gitlawb/openclaude@latest
+ @gitlawb/openclaude@0.23.0 \
+ opencode-ai@1.17.18 \
+ @openai/codex \
+ sharp
# Timezone
ENV TZ=America/Sao_Paulo
@@ -70,16 +88,37 @@ WORKDIR /workspace
COPY pyproject.toml uv.lock ./
RUN uv venv .venv && uv sync --no-dev
+# MemPalace (Base de Conhecimento — busca semântica local, chromadb).
+# Pré-instalado na imagem: o install via botão do dashboard cai na layer do
+# container e some a cada redeploy; os dados (chroma/sources) persistem no
+# volume dashboard/data.
+RUN uv pip install --python .venv/bin/python mempalace
+
# Application code
COPY dashboard/ dashboard/
COPY social-auth/ social-auth/
COPY scheduler.py ./
+COPY backup.py ./
COPY .claude/ .claude/
COPY memory/ memory/
COPY ADWs/ ADWs/
COPY config/ config/
+COPY docs/ docs/
COPY Makefile ./
COPY .env.example ./
+# Config do provider "opencode" (baseURL/apiKey editáveis via Providers) —
+# heartbeats rodam neste container (Flask+terminal-server+heartbeats).
+COPY opencode.json ./
+# `opencode run` só descobre esse provider customizado subindo diretórios a
+# partir do seu cwd até achar um opencode.json — funciona pra sessões cujo
+# workingDir está sob /workspace, mas NÃO pra sessões com workingDir fora
+# dessa árvore (ex.: pastas de agente fora do repo). Copiar pro config
+# global do XDG (~/.config/opencode, container roda como root => /root)
+# resolve isso: é encontrado não importa o cwd da sessão. Confirmado ao vivo
+# 2026-07-14 — sem isso, `-m opencode/auto` falha com
+# "ProviderModelNotFoundError: Model not found: opencode/auto." fora de
+# /workspace (mascarado como "Unexpected server error" genérico no NDJSON).
+RUN mkdir -p /root/.config/opencode && cp opencode.json /root/.config/opencode/opencode.json
# Pre-compiled terminal-server node_modules from stage 2
COPY --from=terminal-build /terminal/node_modules dashboard/terminal-server/node_modules
@@ -90,11 +129,28 @@ COPY --from=frontend-build /frontend/dist dashboard/frontend/dist
# SQLite data dir (matches upstream Dockerfile.dashboard)
RUN mkdir -p dashboard/data
+# .claude/settings.json ships CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1, which
+# isolates background sub-agents in a git worktree. COPY only brings tracked
+# files, never .git, so /workspace has no repo and every background-agent
+# launch inside the container fails with "not in a git repository and no
+# WorktreeCreate hooks are configured". An empty repo is enough for worktree
+# creation to succeed.
+RUN cd /workspace && git init -q \
+ && git config user.email "bot@evonexus.local" \
+ && git config user.name "EvoNexus" \
+ && git commit -q --allow-empty -m "baseline (image build)"
+
# First-boot defaults for the bootstrap entrypoint (writable config volume)
RUN mkdir -p /workspace/_defaults/config \
&& cp -a /workspace/config/. /workspace/_defaults/config/ 2>/dev/null || true \
&& cp /workspace/.env.example /workspace/_defaults/.env.example 2>/dev/null || true
+# Stash .claude so the entrypoint can re-seed built-ins into the mounted
+# /workspace/.claude volume on every boot (custom-*/plugin-* files persist).
+# agent-memory is excluded — it has its own volume and must never be reset.
+RUN cp -a /workspace/.claude /workspace/_defaults/claude \
+ && rm -rf /workspace/_defaults/claude/agent-memory
+
# Bootstrap wrapper (config volume + source .env + wait-for-key)
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
diff --git a/HANDOFF-evo-nexus-backup-restore-plugins.md b/HANDOFF-evo-nexus-backup-restore-plugins.md
new file mode 100644
index 00000000..7a86e03d
--- /dev/null
+++ b/HANDOFF-evo-nexus-backup-restore-plugins.md
@@ -0,0 +1,402 @@
+# Handoff: Restore VPS, Backup SQLite e Plugins 500
+
+Data: 2026-07-05
+Status: em andamento
+
+## 1. Objetivo
+
+Restaurar corretamente o EvoNexus na VPS a partir de um backup local, sem perder integrações, plugins, dados do dashboard e workspace. Durante a restauração foi descoberto que o backup atual pode capturar bancos SQLite em estado inconsistente por copiar `*.db` sem incluir/aplicar WAL, causando `database disk image is malformed` no container. Também surgiu um erro novo: a área de plugins está retornando `500 Internal Server Error`, e localmente a aplicação está pedindo criação de usuário, indicando divergência de DB/config entre ambiente local e VPS.
+
+## 2. Contexto essencial
+
+- Repositório local: `/home/sistemabritto/Documentos/evo-nexus`.
+- Stack VPS: Docker Swarm, serviço principal `evonexus_evonexus_dashboard`.
+- Imagens usadas na VPS:
+ - `excarplex/evo-nexus-dashboard:latest`
+ - `excarplex/evo-nexus-runtime:latest`
+- Stack file local relevante: `evonexus-vps.stack.yml`.
+- O dashboard usa SQLite em `/workspace/dashboard/data/evonexus.db`.
+- Na VPS, o mount real do DB do dashboard é:
+ - `evonexus_evonexus_dashboard_data -> /workspace/dashboard/data`
+- A configuração persistente e integrações passam por `/workspace/config/.env`.
+- O entrypoint faz symlink:
+ - `/workspace/.env -> /workspace/config/.env`
+- Portanto, o `.env` correto em Swarm é o do volume `config`, não um arquivo solto dentro da imagem.
+- O stack monta `/workspace/config`, mas não monta `/workspace/.env` diretamente. Isso é esperado porque o entrypoint cria o symlink.
+- Integrações core são avaliadas por variáveis de ambiente carregadas de `.env`, conforme `dashboard/backend/routes/integrations.py`.
+- O backup local atual inclui `.env` e `dashboard/data/evonexus.db`, mas o script atual exclui `.db-wal` e `.db-shm`.
+- Isso é perigoso quando SQLite está em WAL: copiar só o `.db` pode gerar backup sem transações recentes ou até malformado.
+- O usuário pediu para corrigir o backup e fazer push para GitHub, mas interrompeu antes da edição.
+- O usuário agora quer handoff para continuar com Fable/outra sessão.
+
+## 3. O que já foi feito
+
+1. Analisamos logs iniciais da VPS.
+ - O endpoint `POST /api/backups//restore` retornava `202`.
+ - Isso significa apenas “job aceito”, não “restore concluído”.
+
+2. Foi identificado um bug de UX/API no restore:
+ - `dashboard/backend/routes/backups.py` guarda `_running_jobs["restore"]`.
+ - Mas `/api/backups/status` retorna só `_running_jobs["backup"]`.
+ - Resultado: o frontend não mostra erro/conclusão real do restore.
+
+3. Foi identificado que o frontend usa restore em modo `merge` por padrão.
+ - Em `merge`, arquivos existentes não são sobrescritos.
+ - Isso explicava parcialmente “subiu mas não apareceu nada”.
+ - O usuário refez via browser com `replace`.
+
+4. O restore via browser continuou sem visibilidade real.
+ - Foi recomendado rodar restore manual dentro do container.
+
+5. Na VPS, `docker ps` mostrou container do dashboard:
+ - ID inicial usado: `59573e9af77c`
+ - Nome: `evonexus_evonexus_dashboard.1...`
+
+6. Descobrimos que a imagem da VPS não contém `/workspace/backup.py`.
+ - Existe apenas `/workspace/ADWs/routines/backup.py`.
+ - Então foi usado um script Python inline para extrair o ZIP diretamente.
+
+7. O usuário restaurou o ZIP manualmente dentro do container:
+ - ZIP: `/workspace/backups/evonexus-backup-20260704-203851.zip`
+ - Saída:
+ - `created_at: 2026-07-04T20:38:51.761046`
+ - `file_count: 580`
+ - `RESTORE COMPLETE`
+ - `restored: 580`
+
+8. Após restart do serviço, o dashboard entrou em crash loop.
+ - Logs mostraram:
+ - `sqlite3.DatabaseError: database disk image is malformed`
+ - SQL: `PRAGMA main.table_info("users")`
+ - Causa provável: `evonexus.db` restaurado veio inconsistente/corrompido.
+
+9. Tentamos recuperar SQLite com `.recover`.
+ - Primeiro foi usado o volume errado por suspeita de nome.
+ - Depois o usuário inspecionou mounts e confirmou o volume correto:
+ - `evonexus_evonexus_dashboard_data -> /workspace/dashboard/data`
+
+10. O usuário pausou o dashboard:
+ - `docker service scale evonexus_evonexus_dashboard=0`
+
+11. O usuário rodou recovery no volume correto.
+ - Houve um erro intermediário por comando quebrado em múltiplas linhas e por falta de espaço em `sqlite3 evonexus.db ".recover"`.
+ - Isso gerou um `evonexus.db` vazio e a aplicação passou a pedir setup/criação de usuário.
+ - Esse caminho foi descartado porque zerou o DB.
+
+12. Recuperamos a partir do backup `evonexus.db.bad.manual`.
+ - Comando usado:
+ ```bash
+ docker run --rm -v evonexus_evonexus_dashboard_data:/data alpine sh -lc 'apk add --no-cache sqlite >/dev/null; cd /data; rm -f evonexus.db evonexus.db-wal evonexus.db-shm; sqlite3 evonexus.db.bad.manual ".recover" | sqlite3 evonexus.db; sqlite3 evonexus.db "PRAGMA integrity_check;"; ls -lh evonexus.db evonexus.db.bad.manual; sqlite3 evonexus.db ".tables"; sqlite3 evonexus.db "select count(*) from users;"'
+ ```
+ - Saída relevante:
+ - `ok`
+ - tabelas presentes, incluindo `users`, `plugins_installed`, `runtime_configs`, `brain_repo_configs`, `knowledge_connections`
+ - `select count(*) from users;` retornou `1`
+
+13. O dashboard foi escalado de volta:
+ ```bash
+ docker service scale evonexus_evonexus_dashboard=1
+ ```
+ - Logs mostraram Flask subindo corretamente.
+ - O erro `database disk image is malformed` sumiu.
+
+14. O usuário informou que “subiu uma parte”, mas integrações ainda não voltaram.
+ - Foi analisado o código local e confirmado que integrações dependem do `.env`.
+ - O `entrypoint.sh` carrega `/workspace/config/.env`.
+ - O backup atual provavelmente incluiu `.env` no caminho raiz, mas em Swarm o caminho persistente real é `config/.env` via symlink.
+
+15. Foi iniciada análise local para corrigir `backup.py`.
+ - `backup.py` atual coleta arquivos ignorados pelo git com:
+ - walk dinâmico de `workspace`, `memory`, `plugins`
+ - `git ls-files --others --ignored --exclude-standard`
+ - `.env`, `config/workspace.yaml`, `dashboard/data/evonexus.db` são ignorados pelo git e entram no backup.
+ - `.db-wal` e `.db-shm` são explicitamente excluídos por `EXCLUDE_EXTENSIONS`.
+ - Localmente existe:
+ - `dashboard/data/evonexus.db`
+ - `dashboard/data/evonexus.db-wal`
+ - `dashboard/data/mempalace/chroma.sqlite3`
+ - `dashboard/data/evonexus.db` local passa `PRAGMA integrity_check`.
+ - `dashboard/data/mempalace/chroma.sqlite3` local passa `PRAGMA integrity_check`.
+
+16. Foi preparado o plano de correção, mas nenhuma alteração foi aplicada antes da interrupção:
+ - Alterar `backup.py` para gerar snapshots consistentes de SQLite usando `sqlite3.Connection.backup()`.
+ - Ao zipar arquivos SQLite vivos, escrever a cópia snapshot no ZIP, não o arquivo `.db` direto.
+ - Manter exclusão de `.db-wal` e `.db-shm`, desde que o snapshot incorpore WAL corretamente.
+ - Stagear e commitar apenas `backup.py` porque o worktree tem muitas mudanças não relacionadas.
+
+17. O usuário adicionou novo problema:
+ - Plugins retornando:
+ - `500 INTERNAL SERVER ERROR`
+ - HTML padrão do Flask.
+ - Localhost pedindo criação de usuário.
+ - Essa parte ainda não foi investigada.
+
+## 4. Estado atual
+
+- VPS:
+ - Dashboard voltou a subir depois do SQLite recovery.
+ - Último log bom visto:
+ - Flask rodando em `0.0.0.0:8080`
+ - `/api/version` retornando `200`
+ - O banco recuperado tem pelo menos 1 usuário.
+ - Parte dos dados aparece.
+ - Integrações ainda não aparecem corretamente.
+ - Plugins agora dão `500 Internal Server Error` segundo o usuário.
+
+- Local:
+ - Repo em `/home/sistemabritto/Documentos/evo-nexus`.
+ - Branch atual no momento da análise:
+ - `feat/chat-openclaude-provider-routing`
+ - Remote:
+ - `origin` e `fork` apontam para `github.com/sistemabritto/evo-nexus.git`
+ - `gh auth status` estava autenticado como `sistemabritto`.
+ - Worktree está sujo com muitas mudanças não relacionadas.
+ - Importante: não usar `git add -A`.
+ - Apenas `backup.py` deve ser stageado para o fix de backup, salvo se o próximo agente fizer correções adicionais específicas.
+
+- Problemas confirmados:
+ - Backup atual pode gerar SQLite inconsistente.
+ - Restore via browser não expõe status/erro real de restore.
+ - Integrações dependem de `/workspace/config/.env` no Swarm.
+ - Plugins 500 ainda sem diagnóstico.
+ - Localhost pedindo criação de usuário indica DB local ausente/zerado/inconsistente ou app apontando para outro `dashboard/data/evonexus.db`.
+
+## 5. Próximos passos
+
+1. Não criar usuário novo ainda, nem local nem VPS, até entender qual DB/config está sendo lido.
+
+2. Diagnosticar plugins 500 na VPS:
+ ```bash
+ docker service logs evonexus_evonexus_dashboard --since 20m 2>&1 | grep -iE "plugin|traceback|exception|error|500" -A20 -B10
+ ```
+ Também abrir a rota que falha e olhar traceback completo:
+ ```bash
+ docker service logs -f evonexus_evonexus_dashboard
+ ```
+ Depois acessar Plugins no browser para capturar a exceção.
+
+3. Confirmar se as integrações estão no `.env` persistente da VPS:
+ ```bash
+ docker run --rm -v evonexus_evonexus_config:/config alpine sh -lc 'ls -lah /config && sed -n "1,220p" /config/.env'
+ ```
+ Atenção: esse comando exibe segredos. Não colar output completo em canais públicos. Se precisar compartilhar, mascarar tokens.
+
+4. Conferir se o ZIP restaurado continha `.env` e/ou `config/.env`:
+ ```bash
+ docker run --rm -v evonexus_evonexus_backups:/backups alpine sh -lc 'apk add --no-cache unzip >/dev/null; unzip -l /backups/evonexus-backup-20260704-203851.zip | grep -E "(^| )(\.env|config/\.env)$"'
+ ```
+ Se só havia `.env`, o restore manual escreveu `/workspace/.env` no container, mas em Swarm o symlink aponta para `/workspace/config/.env`. É necessário garantir que backup e restore preservem `config/.env`.
+
+5. Corrigir `backup.py` local para snapshot SQLite consistente.
+ Implementação sugerida:
+ - importar `sqlite3`;
+ - criar helper `_is_sqlite_file(path: Path)`;
+ - criar helper `_write_sqlite_snapshot_to_zip(zf, src, rel, tmpdir)`;
+ - usar `sqlite3.connect(f"file:{src}?mode=ro", uri=True)` e `src_conn.backup(dst_conn)`;
+ - escrever snapshot temporário no ZIP com o mesmo `rel`;
+ - para SQLite inválido ou vazio, cair para `zf.write` com aviso, ou falhar explicitamente para bancos críticos.
+
+6. Ajustar coleta de env para Swarm:
+ - Garantir que `config/.env` entre no backup se existir.
+ - Hoje `git check-ignore` indica que `config/.env` é ignorado por regra `.env`, então deve entrar via `git ls-files --others --ignored`.
+ - Mesmo assim, validar manifesto do próximo backup para confirmar.
+
+7. Gerar backup novo local após corrigir script.
+ - Ideal: parar serviços locais que escrevem SQLite antes do backup.
+ - Se estiver usando Docker Compose local:
+ ```bash
+ docker compose stop || true
+ python backup.py backup
+ docker compose start || true
+ ```
+ - Se estiver rodando app local sem Docker, parar o processo Flask/terminal antes.
+ - Validar ZIP gerado:
+ ```bash
+ python - <<'PY'
+ from pathlib import Path
+ import json, zipfile, sqlite3, tempfile
+ zips = sorted(Path("backups").glob("evonexus-backup-*.zip"))
+ p = zips[-1]
+ print(p)
+ with zipfile.ZipFile(p) as z:
+ m = json.loads(z.read("manifest.json"))
+ files = [e["path"] for e in m["files"]]
+ for target in [".env", "config/.env", "dashboard/data/evonexus.db", "dashboard/data/mempalace/chroma.sqlite3"]:
+ print(target, target in files)
+ with tempfile.NamedTemporaryFile(suffix=".db") as tmp:
+ tmp.write(z.read("dashboard/data/evonexus.db"))
+ tmp.flush()
+ con = sqlite3.connect(tmp.name)
+ print("evonexus integrity:", con.execute("PRAGMA integrity_check").fetchone()[0])
+ print("users:", con.execute("select count(*) from users").fetchone()[0])
+ con.close()
+ PY
+ ```
+
+8. Testar `backup.py` com uma execução real.
+ - O backup novo deve não gerar DB malformado.
+ - Conferir tamanho e manifesto.
+
+9. Commit e push:
+ - Usar apenas `backup.py` se for só o fix de backup.
+ - Como worktree está misto:
+ ```bash
+ git diff -- backup.py
+ git add backup.py
+ git commit -m "Fix consistent SQLite backup snapshots"
+ git push -u fork feat/chat-openclaude-provider-routing
+ ```
+ - Se preferir branch nova para esse fix:
+ ```bash
+ git switch -c codex/fix-sqlite-backup-snapshots
+ git add backup.py
+ git commit -m "Fix consistent SQLite backup snapshots"
+ git push -u fork codex/fix-sqlite-backup-snapshots
+ ```
+ - Não stagear arquivos não relacionados.
+
+10. Depois de gerar backup novo, restaurar na VPS com mais cuidado:
+ - Escalar serviços que escrevem para os volumes para 0:
+ ```bash
+ docker service scale evonexus_evonexus_dashboard=0
+ docker service scale evonexus_evonexus_scheduler=0
+ docker service scale evonexus_evonexus_telegram=0
+ ```
+ - Subir ZIP novo para volume `evonexus_evonexus_backups`.
+ - Restaurar com script robusto que respeite symlink/env:
+ - Para arquivos `config/.env`, escrever em `/workspace/config/.env`.
+ - Para `.env`, decidir se deve copiar para `config/.env` no Swarm.
+ - Subir serviços novamente.
+
+11. Corrigir bug do status de restore no backend/frontend.
+ - Backend: `/api/backups/status` deve aceitar `?type=restore` ou retornar ambos `backup` e `restore`.
+ - Frontend: quando `handleRestore`, pollar status de restore, não backup.
+ - Logar exceções de thread com traceback, não só `str(e)`.
+
+## 6. Perguntas em aberto
+
+- O backup novo deve incluir os dois caminhos `.env` e `config/.env`, ou normalizar para `config/.env` no Swarm?
+- O restore web deve sobrescrever `.env`/`config/.env` automaticamente? Isso pode quebrar produção se o backup veio de local com URLs/keys diferentes.
+- O erro 500 de plugins vem do DB recuperado, de plugin instalado ausente no volume `plugins`, de schema antigo, ou de config `.env` faltante?
+- O usuário quer preservar exatamente o usuário/login antigo ou aceita recriar usuário se os dados principais forem recuperados?
+- O backup deve falhar quando banco crítico SQLite não passar `integrity_check`, ou deve incluir com warning?
+- Deve haver comando oficial de restore para imagens Swarm, já que `/workspace/backup.py` não existe no container `excarplex/evo-nexus-dashboard:latest`?
+
+## 7. Artefatos relevantes
+
+### Arquivos locais
+
+- `backup.py`
+ - Script de backup/restore local.
+ - Precisa ser corrigido para snapshots SQLite.
+
+- `evonexus-vps.stack.yml`
+ - Stack Swarm.
+ - Mostra volumes persistentes e environment.
+
+- `entrypoint.sh`
+ - Cria `/workspace/config/.env`.
+ - Gera `EVONEXUS_SECRET_KEY` e `KNOWLEDGE_MASTER_KEY` se faltarem.
+ - Symlinka `/workspace/.env` para `/workspace/config/.env`.
+ - Faz source do `.env`.
+
+- `dashboard/backend/routes/backups.py`
+ - Endpoint de backup/restore.
+ - Bug: status expõe backup, não restore.
+
+- `dashboard/backend/routes/integrations.py`
+ - Integrações core dependem de env vars.
+ - Custom/plugin integrations também avaliam env vars.
+
+- `dashboard/backend/routes/plugins.py`
+ - Provável ponto para investigar 500 de plugins.
+
+- `dashboard/backend/models.py`
+ - Tabelas relevantes: `users`, `plugins_installed`, `runtime_configs`, `brain_repo_configs`, `knowledge_connections`.
+
+### Comandos usados na VPS
+
+Confirmar mounts do serviço:
+```bash
+docker service inspect evonexus_evonexus_dashboard --format '{{range .Spec.TaskTemplate.ContainerSpec.Mounts}}{{println .Source "->" .Target}}{{end}}'
+```
+
+Saída relevante:
+```text
+evonexus_evonexus_dashboard_data -> /workspace/dashboard/data
+evonexus_evonexus_config -> /workspace/config
+evonexus_evonexus_workspace -> /workspace/workspace
+evonexus_evonexus_memory -> /workspace/memory
+evonexus_evonexus_agent_memory -> /workspace/.claude/agent-memory
+```
+
+Recovery que funcionou:
+```bash
+docker service scale evonexus_evonexus_dashboard=0
+docker run --rm -v evonexus_evonexus_dashboard_data:/data alpine sh -lc 'apk add --no-cache sqlite >/dev/null; cd /data; rm -f evonexus.db evonexus.db-wal evonexus.db-shm; sqlite3 evonexus.db.bad.manual ".recover" | sqlite3 evonexus.db; sqlite3 evonexus.db "PRAGMA integrity_check;"; ls -lh evonexus.db evonexus.db.bad.manual; sqlite3 evonexus.db ".tables"; sqlite3 evonexus.db "select count(*) from users;"'
+docker service scale evonexus_evonexus_dashboard=1
+```
+
+Saída relevante:
+```text
+defensive off
+ok
+users
+1
+```
+
+Logs bons após recovery:
+```text
+Serving Flask app 'app'
+Running on http://127.0.0.1:8080
+GET /api/version HTTP/1.1" 200
+```
+
+Erro anterior:
+```text
+sqlite3.DatabaseError: database disk image is malformed
+sqlalchemy.exc.DatabaseError: [SQL: PRAGMA main.table_info("users")]
+```
+
+### Estado Git local no momento da análise
+
+Branch:
+```text
+feat/chat-openclaude-provider-routing
+```
+
+Worktree tinha muitas mudanças não relacionadas. Exemplos:
+```text
+ M .claude/skills/create-goal/SKILL.md
+ M ADWs/routines/memory_sync.py
+ M Makefile
+ M dashboard/backend/app.py
+ M dashboard/backend/models.py
+ M dashboard/backend/notifications.py
+ M dashboard/backend/routes/overview.py
+ M dashboard/terminal-server/src/claude-bridge.js
+ M scripts/post_to_x.py
+ M start-services.sh
+?? dashboard/backend/routes/instagram.py
+?? tests/backend/test_mempalace_routes.py
+```
+
+Não stagear tudo.
+
+## 8. Instruções pra próxima sessão
+
+- Falar em português com o usuário, direto e operacional.
+- O usuário está em modo incidente; priorize comandos prontos e curtos.
+- Evitar comandos multiline complexos na VPS porque o terminal dele quebrou heredocs e linhas longas com indentação.
+- Preferir comandos de uma linha quando o usuário for copiar para VPS.
+- Não pedir para ele colar `.env` completo sem mascarar segredos.
+- Não mandar criar usuário novo enquanto houver chance de o app estar apontando para DB errado.
+- Não usar `git add -A`; o worktree local tem várias mudanças de outros trabalhos.
+- Antes de qualquer push, revisar `git diff -- backup.py` e stagear só o escopo.
+- Corrigir primeiro a causa raiz do backup SQLite. Sem isso, repetir restore pode recriar o problema.
+- Para o 500 de plugins, buscar traceback real nos logs antes de editar código.
+- Lembrar que a imagem Swarm atual não tem `/workspace/backup.py`; qualquer procedimento de restore na VPS precisa usar script inline, incluir `backup.py` na imagem, ou restaurar via UI corrigida.
+- Se for fazer PR, usar draft PR e explicar claramente:
+ - root cause: SQLite copiado enquanto WAL ativo;
+ - fix: snapshot consistente via SQLite backup API;
+ - impacto: backups restauráveis e menos risco de DB malformado.
diff --git a/Makefile b/Makefile
index bbcec2ed..16b23f3f 100644
--- a/Makefile
+++ b/Makefile
@@ -13,11 +13,9 @@
PYTHON := $(shell command -v uv >/dev/null 2>&1 && echo "uv run python" || echo "python3")
ADW_DIR := ADWs/routines
-# Load .env if it exists
-ifneq (,$(wildcard .env))
-include .env
-export
-endif
+# Do not include .env here. GNU make does not parse shell-style .env files
+# safely when values contain spaces or slashes. Long-running services load .env
+# through start-services.sh; Python routines read provider config/env at runtime.
# ── Setup ──────────────────────────────────
@@ -150,18 +148,54 @@ uninstall: ## 🗑️ Full cleanup — stop services, remove nginx, da
bling-auth: ## 🔐 Bling OAuth2 login (one-time: capture access + refresh tokens into .env)
@python3 .claude/skills/int-bling/scripts/bling_auth.py
-telegram: ## 📨 Start Telegram bot in background (screen)
- @if screen -list | grep -q '\.telegram'; then \
+telegram: ## 📨 Start Telegram bot using active provider in background (screen)
+ @command -v screen >/dev/null 2>&1 || { echo "❌ 'screen' is not installed — run: sudo apt install screen"; exit 1; }
+ @pkill -f '[e]xternal_plugins/telegram.*start' 2>/dev/null || true
+ @pkill -f '[c]laude --channels plugin:telegram' 2>/dev/null || true
+ @if screen -list | grep -qE '\.telegram[[:space:]]'; then \
echo "⚠ Telegram bot is already running. Use 'make telegram-stop' first or 'make telegram-attach' to connect."; \
else \
- screen -dmS telegram claude --channels plugin:telegram@claude-plugins-official --dangerously-skip-permissions; \
- echo "✅ Telegram bot running in background (screen: telegram)"; \
+ screen -dmS telegram python3 scripts/telegram_provider_bot.py; \
+ printf "⏳ Waiting for Telegram provider bot"; \
+ for i in $$(seq 1 10); do \
+ pgrep -af '[p]ython3 scripts/telegram_provider_bot.py' >/dev/null && break; \
+ printf "."; sleep 1; \
+ done; \
+ pgrep -af '[p]ython3 scripts/telegram_provider_bot.py' >/dev/null && echo " ✅ ready" || { echo " ❌ Telegram provider bot did not start — check: screen -r telegram"; exit 1; }; \
+ echo "✅ Telegram bot running with active EvoNexus provider (screen: telegram)"; \
echo "📺 Ver: screen -r telegram"; \
echo "🛑 Parar: make telegram-stop"; \
fi
-telegram-stop: ## 🛑 Stop the Telegram bot
- @screen -S telegram -X quit 2>/dev/null && echo "✅ Telegram bot stopped" || echo "⚠ Was not running"
+telegram-channel: ## 🧪 Start legacy Claude Code Channels Telegram bot via LiteLLM proxy
+ @command -v screen >/dev/null 2>&1 || { echo "❌ 'screen' is not installed — run: sudo apt install screen"; exit 1; }
+ @command -v bun >/dev/null 2>&1 || [ -x "$$HOME/.bun/bin/bun" ] || { echo "❌ 'bun' is not installed (required by the telegram plugin MCP) — run: curl -fsSL https://bun.sh/install | bash"; exit 1; }
+ @command -v curl >/dev/null 2>&1 || { echo "❌ 'curl' is not installed"; exit 1; }
+ @[ -x .venv/bin/litellm ] || { echo "❌ litellm not installed in .venv — run: uv pip install 'litellm[proxy]'"; exit 1; }
+ @if screen -list | grep -q '\.telegram-proxy'; then \
+ echo "ℹ LiteLLM proxy already running (screen: telegram-proxy)"; \
+ else \
+ screen -dmS telegram-proxy bash scripts/telegram_litellm_proxy.sh; \
+ printf "⏳ Starting LiteLLM proxy (Anthropic→NVIDIA) on :4000"; \
+ for i in $$(seq 1 30); do \
+ curl -s -m2 http://127.0.0.1:4000/health/readiness >/dev/null 2>&1 && break; \
+ printf "."; sleep 1; \
+ done; \
+ curl -s -m2 http://127.0.0.1:4000/health/readiness >/dev/null 2>&1 && echo " ✅ ready" || { echo " ❌ proxy failed to start — check: screen -r telegram-proxy"; exit 1; }; \
+ fi
+ @if screen -list | grep -qE '\.telegram[[:space:]]'; then \
+ echo "⚠ Telegram bot is already running. Use 'make telegram-stop' first or 'make telegram-attach' to connect."; \
+ else \
+ screen -dmS telegram env ANTHROPIC_BASE_URL=http://127.0.0.1:4000 ANTHROPIC_AUTH_TOKEN=sk-evonexus-telegram-local ANTHROPIC_MODEL=telegram-nvidia claude --channels plugin:telegram@claude-plugins-official --dangerously-skip-permissions; \
+ echo "✅ Legacy Telegram channel running (screen: telegram)"; \
+ fi
+
+telegram-stop: ## 🛑 Stop the Telegram bot (and its LiteLLM proxy)
+ @screen -S telegram -X quit 2>/dev/null && echo "✅ Telegram bot stopped" || echo "⚠ Bot was not running"
+ @screen -S telegram-debug -X quit 2>/dev/null && echo "✅ Telegram debug bot stopped" || true
+ @screen -S telegram-proxy -X quit 2>/dev/null && echo "✅ LiteLLM proxy stopped" || echo "⚠ Proxy was not running"
+ @pkill -f '[e]xternal_plugins/telegram.*start' 2>/dev/null && echo "✅ Legacy Telegram plugin stopped" || true
+ @pkill -f '[c]laude --channels plugin:telegram' 2>/dev/null && echo "✅ Legacy Telegram channel stopped" || true
telegram-attach: ## 📺 Connect to Telegram terminal (Ctrl+A D to detach)
@screen -r telegram
diff --git a/PROMPT-CODEX-VPS-DEPLOY.md b/PROMPT-CODEX-VPS-DEPLOY.md
new file mode 100644
index 00000000..8e95bb53
--- /dev/null
+++ b/PROMPT-CODEX-VPS-DEPLOY.md
@@ -0,0 +1,214 @@
+# Prompt para o Codex — Migração do EvoNexus para VPS
+
+## Contexto geral
+
+O EvoNexus é um sistema de orquestração de agentes (Claude Code, cronjobs, rotinas, dashboard Flask+React com terminal embutido) que roda localmente no meu PC. Preciso migrá-lo para minha VPS onde já rodam outros serviços (Hermes, EvoCRM, Traefik, pgvector). O PC não fica ligado a maior parte do dia, então a migração para VPS é obrigatória.
+
+**Objetivo final:** EvoNexus rodando na VPS como serviço Docker Swarm, na mesma rede `network_public` dos outros serviços, acessível via subdomínio `nexus.workflowapi.com.br` através do Traefik.
+
+## Ambiente da VPS (informações conhecidas)
+
+### Rede Docker
+- **`network_public`** — rede Docker external já criada na VPS, usada por Hermes, EvoCRM, Traefik. Todos os serviços do Swarm ficam nela.
+- **`hermes_internal`** — rede secundária usada pelo Hermes (não precisa mexer aqui).
+
+### Traefik (reverse proxy)
+- Já rodando na VPS como serviço Docker Swarm
+- Entry points: `websecure` (443/TLS) e `web` (80)
+- Cert resolver: `letsencryptresolver` (também existe `letsencrypt` em algumas stacks)
+- Padrão de labels: `traefik.enable=true`, `traefik.docker.network=network_public`, `traefik.http.routers.X.rule=Host(...)`, etc.
+- Domínio base: `*.workflowapi.com.br`
+
+### Banco de dados
+- **`pgvector`** — container PostgreSQL com extensão pgvector já rodando na `network_public`, usado pelo EvoCRM.
+- Password do postgres: definida via env `POSTGRES_PASSWORD` na VPS.
+- **IMPORTANTE:** O EvoNexus usa **SQLite** (`dashboard.db`) como seu banco — NÃO usa Postgres nativamente. O `pyproject.toml` inclui `psycopg2-binary` e `alembic` como deps, mas o app roda em SQLite por padrão.
+
+**Decisão sobre banco:** Preciso que o Codex verifique se o EvoNexus pode continuar usando SQLite (mais simples, menos um container pra subir) ou se há benefício real em migrar para o `pgvector` que já existe na VPS. Avaliar: o `dashboard.db` é um arquivo SQLite que vive dentro de um volume Docker — se reiniciar o container ou redeploiar a imagem, os dados persistem desde que o volume esteja nomeado. Para um sistema de orquestração que provavelmente tem pouco volume de escrita no DB (a maior parte é logs e agent memory em arquivos), SQLite com volume nomeado é suficiente. Confirmar isso analisando o código.
+
+### Serviços já presentes na VPS
+- Hermes Agent (profiles: mistica, excarplex) — porta 9119 (dashboard), 8642 (API server)
+- EvoCRM — auth (3001), crm (3000), core (5555), processor (8000), bot_runtime (8080), gateway (3030), redis (6379)
+- Traefik — reverse proxy com TLS automático via Let's Encrypt
+- pgvector — PostgreSQL 15+ com extensão vector
+
+## Estado atual do EvoNexus (local)
+
+### Dockerfiles existentes
+1. **`Dockerfile.swarm`** — runtime image (Python + Node 22 + claude-code CLI + openclaude CLI + gh CLI + todoist CLI + uv). Usa entrypoint.sh com bootstrap de config volume, geração de secret key, wait-for-key, Docker Secrets support. ENTRYPOINT = `entrypoint.sh`, CMD = `bash`.
+2. **`Dockerfile.swarm.dashboard`** — dashboard image (3-stage: frontend build com Vite → terminal-server node-pty compile → runtime Python 3.12 + Node 22 + uv + ambos CLIs). Inclui `start-dashboard.sh` que sobe Flask (:8080) + terminal-server (:32352) simultaneamente. HEALTHCHECK em `/api/version`.
+3. **`Dockerfile`** — runtime para local (sem Swarm bootstrap).
+4. **`Dockerfile.dashboard`** — dashboard Python-only (sem terminal-server, sem Node no runtime).
+5. **`Dockerfile.dev`** — dev local com volumes montados.
+
+### Compose files existentes
+- **`docker-compose.yml`** — local com build (3 services: dashboard, telegram, runner).
+- **`docker-compose.dev.yml`** — local dev com hot reload.
+- **`docker-compose.hub.yml`** — para usuários finais, puxa imagens do Docker Hub `evoapicloud/evo-nexus-*`. Sem rede externa, sem Traefik.
+- **`docker-compose.proxy.yml`** — igual o hub mas com `expose` em vez de `ports` (para reverse proxy).
+- **`evonexus.stack.yml`** — stack para Portainer/Swarm, usa rede `traefik-public` (external) e Traefik labels. **Este é o mais próximo do que preciso, mas usa `traefik-public` em vez de `network_public`.**
+
+### CI/CD
+- **`.github/workflows/docker-publish-britto.yml`** — workflow GitHub Actions que builda `Dockerfile.swarm` e `Dockerfile.swarm.dashboard` e publica no Docker Hub do meu usuário (`sistemabritto`). Triggers: push na branch `feat/chat-openclaude-provider-routing`, tags `v*`, e manual. Já configurado com secrets `DOCKERHUB_USERNAME` e `DOCKERHUB_TOKEN`.
+
+### Git
+- Fork: `https://github.com/sistemabritto/evo-nexus.git`
+- Branch ativa: `feat/chat-openclaude-provider-routing`
+- Origin: mesmo repo (push)
+
+### Estrutura de serviços do EvoNexus
+1. **dashboard** — Flask backend (:8080) + React frontend (servido como static) + Node terminal-server (:32352). Tudo num único container.
+2. **telegram** — bot que escuta mensagens Telegram via Claude Code com plugin `plugin:telegram@claude-plugins-official`.
+3. **scheduler** — executa `scheduler.py` (rotinas automáticas com a lib `schedule`).
+
+Todos os 3 serviços compartilham volumes: `config`, `workspace`, `memory`, `adw_logs`, `agent_memory`, `claude_auth`, `codex_auth`.
+
+### entrypoint.sh
+- Cria `/workspace/config` a partir de defaults na primeira boot
+- Gera `EVONEXUS_SECRET_KEY` e `KNOWLEDGE_MASTER_KEY` automaticamente
+- Sela `.env` do config volume como env vars
+- Se `REQUIRE_ANTHROPIC_KEY=1`, espera em loop 30s até a key aparecer (configurada via dashboard UI)
+
+### Configuração pós-deploy
+- Tudo (Anthropic API key, OpenRouter, NVIDIA NIM, Stripe, Omie, Bling, Asaas, Todoist, Telegram bot token, etc.) é configurado via dashboard UI após o primeiro boot
+- Não há secrets no stack file — zero secrets committed
+
+## O que eu preciso que o Codex faça
+
+### 1. Auditoria do estado atual
+
+Verificar se os Dockerfiles Swarm (`Dockerfile.swarm` e `Dockerfile.swarm.dashboard`) estão completos e corretos para buildar. Pontos de atenção:
+
+- **`Dockerfile.swarm.dashboard`** Stage 2 (terminal-build): Node 22-slim + python3 + make + g++ para compilar `node-pty`. Verificar se o `dashboard/terminal-server/package.json` e `package-lock.json` existem e estão commitados.
+- **`Dockerfile.swarm.dashboard`** Stage 1 (frontend-build): `npm install --legacy-peer-deps` resolve conflito de peer deps do React. Confirmar que `dashboard/frontend/package.json` está commitado.
+- **`start-dashboard.sh`**: Verificar se tem permissão de execução (chmod +x) e se o `sed -i 's/\r//'` (remoção de CRLF) está presente — sem isso, scripts bash com line endings Windows quebram no Linux.
+- **`entrypoint.sh`**: Mesma coisa — verificar line endings e permissão.
+- **`.dockerignore`**: Confirmar que exclui `.env`, `.git`, `node_modules`, `__pycache__`, `.venv`, `workspace/`, `dashboard/data/`, `ADWs/logs/`, `.claude/agent-memory/`, `.claude/.env` para não vazar secrets nem dados locais na imagem.
+
+Reportar qualquer arquivo faltante, erro de sintaxe, ou inconsistência.
+
+### 2. Criar o stack file para a VPS
+
+Baseado no `evonexus.stack.yml` existente, criar um **novo arquivo `evonexus-vps.stack.yml`** com as seguintes alterações:
+
+**a) Rede:** Trocar `traefik-public` por `network_public` (external: true, name: network_public) — é a rede que já existe na minha VPS.
+
+**b) Traefik labels:** Atualizar para o domínio `nexus.workflowapi.com.br`:
+- `traefik.http.routers.evonexus_dashboard.rule=Host(\`nexus.workflowapi.com.br\`)`
+- `traefik.http.routers.evonexus_terminal.rule=Host(\`nexus.workflowapi.com.br\`) && PathPrefix(\`/terminal\`)`
+- Usar `traefik.docker.network=network_public`
+- Usar `traefik.http.routers.X.entrypoints=websecure`
+- Usar `traefik.http.routers.X.tls.certresolver=letsencryptresolver` (padrão da VPS)
+
+**c) Imagens:** Usar `sistemabritto/evo-nexus-dashboard:latest` e `sistemabritto/evo-nexus-runtime:latest` (meu namespace no Docker Hub, não o `evoapicloud`).
+
+**d) Volumes:** Manter os mesmos volumes nomeados do stack original:
+```
+evonexus_config, evonexus_workspace, evonexus_dashboard_data, evonexus_memory,
+evonexus_adw_logs, evonexus_agent_memory, evonexus_claude_auth, evonexus_codex_auth
+```
+
+**e) Banco de dados:** Por enquanto manter SQLite (dashboard.db dentro do volume `evonexus_dashboard_data`). Não adicionar um serviço de Postgres ao stack — o EvoNexus não precisa. Confirmar que o `dashboard.db` está dentro de um volume nomeado e não no filesystem efêmero do container.
+
+**f) Environment:** Manter variáveis do stack original:
+```
+TZ=America/Sao_Paulo
+EVONEXUS_PORT=8080
+TERMINAL_SERVER_PORT=32352
+FORWARDED_ALLOW_IPS=*
+REQUIRE_ANTHROPIC_KEY=1 (telegram + scheduler)
+```
+
+**g) Healthcheck:** O `Dockerfile.swarm.dashboard` já tem HEALTHCHECK em `/api/version`. Não precisa duplicar no compose.
+
+**h) Resource limits:** Manter os limites do stack original (1 CPU, 1024M por serviço) — ajustar se necessário para a VPS.
+
+### 3. Verificar o fluxo de build e publish
+
+Confirmar que o `.github/workflows/docker-publish-britto.yml` está correto e vai funcionar:
+
+- **Triggers:** Push na branch `feat/chat-openclaude-provider-routing` → build + push `:latest` e `:sha-XXXX`
+- **Namespace:** `${{ secrets.DOCKERHUB_USERNAME }}` → deve resolver para `sistemabritto`
+- **Imagens:** `evo-nexus-runtime` (Dockerfile.swarm) e `evo-nexus-dashboard` (Dockerfile.swarm.dashboard)
+- **Platforms:** `linux/amd64` (VPS é x86_64)
+- **Secrets necessários:** `DOCKERHUB_USERNAME` e `DOCKERHUB_TOKEN` — confirmar que estão configurados no repo GitHub (Settings → Secrets and variables → Actions)
+
+Após fazer push da branch, as imagens devem aparecer em `https://hub.docker.com/r/sistemabritto/evo-nexus-dashboard/tags` e `https://hub.docker.com/r/sistemabritto/evo-nexus-runtime/tags`.
+
+### 4. Checklist de deploy na VPS
+
+Criar um checklist passo a passo do que precisa ser feito na VPS para subir o EvoNexus. Deve incluir:
+
+1. **Pré-requisitos na VPS:**
+ - Confirmar que `network_public` existe: `docker network ls | grep network_public`
+ - Confirmar que Traefik está rodando e acessível
+ - Confirmar que o domínio `nexus.workflowapi.com.br` aponta para o IP da VPS (DNS A record)
+ - Confirmar que Docker Swarm está ativo: `docker node ls`
+
+2. **Deploy do stack:**
+ ```bash
+ # Copiar evonexus-vps.stack.yml para a VPS
+ docker stack deploy -c evonexus-vps.stack.yml evonexus
+ ```
+
+3. **Verificação pós-deploy:**
+ - `docker service ls` — ver se os 3 serviços (dashboard, telegram, scheduler) estão `Replicas 1/1`
+ - `docker service logs evonexus_evonexus_dashboard --tail 50` — ver se Flask e terminal-server subiram
+ - `curl -k https://nexus.workflowapi.com.br/api/version` — deve retornar JSON com a versão
+ - Abrir `https://nexus.workflowapi.com.br` no navegador — wizard de setup deve aparecer
+
+4. **Configuração via dashboard UI:**
+ - Criar conta admin
+ - Configurar provider (Anthropic API key, ou NVIDIA NIM, ou OpenRouter)
+ - Configurar Telegram bot token (se for usar o canal Telegram)
+ - Configurar integrações (Omie, Bling, Asaas, etc. — conforme necessário)
+
+5. **DNS:**
+ - Adicionar registro A `nexus` → IP da VPS (ou CNAME se já tiver wildcard `*.workflowapi.com.br`)
+
+### 5. Validação final
+
+Rodar uma verificação local antes do deploy na VPS:
+
+```bash
+# Buildar localmente as imagens Swarm para testar
+docker build -f Dockerfile.swarm -t evo-nexus-runtime:test .
+docker build -f Dockerfile.swarm.dashboard -t evo-nexus-dashboard:test .
+
+# Subir com o stack file novo (adaptando para não precisar da rede externa)
+docker compose -f evonexus-vps.stack.yml up -d # pode precisar ajustes pra local
+```
+
+Confirmar que as imagens buildam sem erro e o container dashboard inicia corretamente com `curl http://localhost:8080/api/version` respondendo.
+
+## Pontos de atenção específicos
+
+1. **Line endings:** O repo foi desenvolvido em Windows em algum momento. Verificar se `entrypoint.sh` e `start-dashboard.sh` têm CRLF (quebrariam no Linux da VPS). Se tiverem, converter para LF com `sed -i 's/\r$//'` ou `dos2unix`.
+
+2. **Permissões de execução:** `entrypoint.sh` e `start-dashboard.sh` precisam de `chmod +x`. Os Dockerfiles Swarm já fazem `chmod +x` no `entrypoint.sh`, mas confirmar que `start-dashboard.sh` também recebe.
+
+3. **Volume `dashboard.db`:** O arquivo `dashboard.db` (SQLite) está em `dashboard/data/` e é montado como volume `evonexus_dashboard_data` no container (`/workspace/dashboard/data`). Confirmar que o volume nomeado cobre esse path e que o DB persiste entre deploys.
+
+4. **`.claude/` no build:** O `Dockerfile.swarm.dashboard` copia `.claude/` inteiro (`COPY .claude/ .claude/`), exceto o que está no `.dockerignore`. Confirmar que `.claude/agent-memory/` e `.claude/.env` estão excluídos (já estão no `.dockerignore`).
+
+5. **`config/` no build:** The `Dockerfile.swarm.dashboard` copia `config/` inteiro. Confirmar que `providers.json` e `heartbeats.yaml` (que podem ter dados sensíveis locais) não vão parar na imagem — idealmente só os `.example` deveriam ir, ou então sobrescrever via volume em runtime.
+
+6. **`_defaults/` bootstrap:** O `Dockerfile.swarm.dashboard` cria `/workspace/_defaults/` a partir de `config/` e `.env.example` no build. O `entrypoint.sh` usa isso para popular o volume `config` na primeira boot. Verificar que isso funciona corretamente — o container dashboard precisa ter `entrypoint.sh` antes de `start-dashboard.sh`.
+
+7. **Replicas:** Como Hermes, EvoCRM e agora EvoNexus vão competir por recursos na mesma VPS, confirmar que os limites de CPU/memória do stack file estão adequados. Hermes aloca 1 CPU / 1GB, EvoCRM serviços menores. EvoNexus com 3 serviços a 1 CPU / 1GB cada é 3GB total — pode ser pesado. Avaliar se o scheduler e o telegram precisam de tantos recursos, ou se podem ser reduzidos (0.5 CPU / 512M talvez).
+
+8. **Dependência entre serviços:** No `docker-compose.hub.yml`, `telegram` e `scheduler` têm `depends_on: dashboard (condition: service_healthy)`. No `evonexus.stack.yml` não tem `depends_on` (Swarm não suporta condition). Como o `entrypoint.sh` já faz wait-for-key, isso é OK — telegram e scheduler esperam a key aparecer, que só é configurada depois do dashboard estar acessível. Confirmar que isso funciona.
+
+## Resumo do que entregar
+
+1. ✅ Auditoria dos Dockerfiles e scripts → report do que está OK e do que precisa correção
+2. ✅ Novo arquivo `evonexus-vps.stack.yml` → stack file pronto pra VPS
+3. ✅ Verificação do workflow de CI/CD → confirma que build/push vai funcionar
+4. ✅ Checklist de deploy na VPS → passo a passo
+5. ✅ Validação local → build test das imagens antes do push
+
+## Observação
+
+O projeto está em `/home/sistemabritto/Documentos/evo-nexus/`. A branch ativa é `feat/chat-openclaude-provider-routing`. O fork no GitHub é `sistemabritto/evo-nexus`.
+
+Responda em português. Seja direto e técnico. Execute os comandos necessários para validar o que estão pedindo — não apenas descreva o que faria.
diff --git a/PROMPT-OMNIROUTE-CONFIG.md b/PROMPT-OMNIROUTE-CONFIG.md
new file mode 100644
index 00000000..b6e421bf
--- /dev/null
+++ b/PROMPT-OMNIROUTE-CONFIG.md
@@ -0,0 +1,142 @@
+# Prompt — Configurar o OmniRoute com um agente (Claude Code)
+
+Este arquivo é um **prompt pronto pra copiar e colar** num agente de código
+(Claude Code, ou qualquer agente com acesso a shell/HTTP) para **auditar e
+otimizar um OmniRoute remoto** — o gateway de IA da stack
+[Omni-Nexus](https://github.com/sistemabritto/omni-nexus#readme) — usando a
+management API e o CLI oficial em modo remoto.
+
+Foi extraído de uma configuração real feita em produção (Sistema Britto, VPS
+Swarm). O agente estuda o estado atual antes de mexer, aplica só mudanças
+reversíveis e valida tudo com chamadas reais.
+
+> Mantido por [Sistema Britto](https://sistemabritto.com.br) ·
+> OmniRoute é um projeto de [Diego Souza](https://github.com/diegosouzapw/OmniRoute) (MIT)
+
+---
+
+## Pré-requisitos
+
+1. **OmniRoute no ar** — ex.: serviço `omniroute` da
+ [`evonexus-vps.stack.example.yml`](evonexus-vps.stack.example.yml)
+ (dashboard em `https://omni.SEU-DOMINIO.com.br`).
+2. **Access Token com scope `admin`** — gere no dashboard do OmniRoute, na
+ seção **Access Tokens** (formato `oma_live_...`). É o token de
+ **gerenciamento** — não confunda com as API keys de inferência (`sk-...`,
+ geradas em **Dashboard → Endpoints**).
+3. Um agente com acesso a shell (Claude Code em qualquer diretório serve).
+
+## Como usar
+
+Substitua os dois placeholders e cole o prompt abaixo no agente:
+
+- `{{OMNIROUTE_URL}}` → ex.: `https://omni.seudominio.com.br`
+- `{{ADMIN_TOKEN}}` → seu `oma_live_...` de scope admin
+
+---
+
+## O prompt
+
+````text
+Você vai auditar e otimizar um gateway OmniRoute remoto usando a management
+API dele. Trabalhe em etapas, SEMPRE leia o estado atual antes de alterar
+qualquer coisa, e valide cada mudança com uma chamada real.
+
+Servidor: {{OMNIROUTE_URL}}
+Access Token (scope admin): {{ADMIN_TOKEN}}
+
+## Fatos importantes sobre a API (aprenda antes de começar)
+
+- Access Tokens `oma_live_...` autenticam a MAIORIA das rotas de gerenciamento
+ (`Authorization: Bearer `), mas NÃO servem para inferência (`/v1/*`)
+ nem para as rotas `/api/settings/` (compression etc.), que exigem
+ sessão do dashboard OU uma API key `sk-...` com scope "manage".
+- API keys de inferência (`sk-...`) são criadas via `POST /api/keys`
+ (body: {"name":"...","scopes":[...]}). Para obter uma key administrativa,
+ crie com scopes ["manage"] — a rota aceita o access token admin. A resposta
+ traz o plaintext UMA vez; guarde.
+- `/v1/chat/completions` do OmniRoute STREAMA SSE por padrão — sempre mande
+ "stream": false em testes que fazem parse do JSON.
+- Rotas que spawnam processos (/api/services/*, /api/mcp/*, /api/plugins/*)
+ são loopback-only — não tente por token remoto.
+- O roteamento `auto` é zero-config (combo virtual + LKGP): variantes
+ `auto/[:]` (coding, reasoning, fast, cheap, free, pro...)
+ são anunciadas em GET /v1/models.
+
+## Etapa 1 — Validar acesso e inventariar
+
+1. GET /api/cli/whoami com o token → confirme scope admin.
+2. GET /api/settings → salve o JSON (estado antes).
+3. GET /api/providers → liste conexões: provider, isActive, testStatus,
+ providerSpecificData.
+4. (Opcional) Instale o CLI oficial num diretório temporário
+ (`npm install omniroute`) e conecte:
+ `omniroute connect {{OMNIROUTE_URL}} --key --name vps`
+ — aí `omniroute providers metrics`, `omniroute cost` etc. funcionam.
+
+## Etapa 2 — Keys nomeadas (telemetria de custo por consumidor)
+
+1. POST /api/keys {"name":"-provider"} → key de inferência
+ dedicada (ex.: para o EvoNexus/Omni-Nexus, bots, IDEs). Uma key por
+ consumidor = custo rastreável por key no dashboard.
+2. POST /api/keys {"name":"agent-manage","scopes":["manage"]} → key
+ administrativa para as rotas /api/settings/*.
+
+## Etapa 3 — Otimizações globais (aplique e confirme uma a uma)
+
+1. PATCH /api/settings (com o access token):
+ {"autoRefreshProviderQuota": true, "debugMode": false}
+ → o roteador `auto` passa a decidir com dados frescos de quota
+ (maximiza assinaturas antes de queimar API paga) e corta overhead de
+ log em produção.
+2. PUT /api/settings/compression (com a key "manage" da etapa 2):
+ {"enabled": true, "defaultMode": "off",
+ "autoTriggerMode": "standard", "autoTriggerTokens": 32000}
+ → requests pequenos ficam intocados; contextos grandes (agentes de
+ código, heartbeats) ganham ~30% de economia com proteção de código.
+3. Conexões marcadas como falhas (ex.: openrouter "credits_exhausted"):
+ se providerSpecificData.importFreeModelsOnly=true, os modelos :free
+ custam $0 e não dependem de crédito — force um re-teste:
+ POST /api/providers//test
+ → testStatus deve voltar a "active".
+
+## Etapa 4 — Validar fim-a-fim (com a key de inferência da etapa 2)
+
+1. POST /v1/chat/completions {"model":"auto","messages":[...],
+ "max_tokens":10,"stream":false} → espere HTTP 200 e inspecione os
+ headers X-OmniRoute-Provider / X-OmniRoute-Model /
+ X-OmniRoute-Compression (deve vir "off" em request pequeno).
+2. GET /v1/models → liste as variantes `auto/` anunciadas.
+3. Teste um modelo :free explicitamente se reativou alguma conexão.
+
+## Etapa 5 — Relatório
+
+Entregue: tabela antes/depois das settings alteradas, keys criadas (nome +
+4 últimos chars, NUNCA a key inteira em logs), status das conexões, e
+recomendação de mapeamento de model tiers para o cliente (ex.:
+opus→auto/claude-opus, sonnet→auto/coding, haiku→auto/best-fast).
+
+## Regras de segurança
+
+- NÃO altere: requireLogin, senhas, JWT/API secrets, portas, proxy global.
+- NÃO reinicie serviços nem chame rotas de lifecycle.
+- NÃO imprima keys/tokens completos no relatório final.
+- Toda mudança deve ser reversível com um único PATCH/PUT (documente o
+ valor anterior).
+````
+
+---
+
+## Alternativa via MCP
+
+O OmniRoute também expõe um servidor MCP (`mcpEnabled` nas settings, rotas
+`/api/mcp/*` — **loopback-only** por segurança). Para gerenciar por MCP o
+agente precisa rodar na mesma máquina do OmniRoute. Para gestão remota, o
+caminho acima (Access Token + management API/CLI) é o suportado.
+
+## Referências
+
+- [Docs do OmniRoute](https://github.com/diegosouzapw/OmniRoute/tree/main/docs) —
+ `guides/REMOTE-MODE.md`, `routing/AUTO-COMBO.md`, `compression/COMPRESSION_GUIDE.md`
+- [README do Omni-Nexus](README.md) — deploy completo da stack na VPS
+- [Stack de exemplo](evonexus-vps.stack.example.yml) — serviço `omniroute`
diff --git a/README.md b/README.md
index 70be4e38..dbd6fa89 100644
--- a/README.md
+++ b/README.md
@@ -8,276 +8,259 @@
-
EvoNexus
+
Omni-Nexus
- Multi-agent operating layer built around the Claude Code CLI — part of the Evolution Foundation ecosystem.
+ Distribuição turbinada do EvoNexus pronta para VPS —
+ com gateway de IA OmniRoute embutido na stack,
+ seletor de providers e bot do Telegram multi-provider.
-
+ Uma camada de upgrade mantida por Sistema Britto sobre o EvoNexus da Evolution Foundation.
+
---
-> **Disclaimer:** EvoNexus is an independent, **unofficial open-source project**. It is **not affiliated with, endorsed by, or sponsored by Anthropic**. "Claude" and "Claude Code" are trademarks of Anthropic, PBC. This project integrates with Claude Code as a third-party tool and requires users to provide their own installation and credentials.
+> **Disclaimer:** assim como o EvoNexus original, este é um projeto open source **não oficial**, **não afiliado, endossado ou patrocinado pela Anthropic**. "Claude" e "Claude Code" são marcas da Anthropic, PBC. O projeto integra o Claude Code como ferramenta de terceiros e exige que você forneça sua própria instalação e credenciais.
---
-## What It Is
+## O que é este fork
-EvoNexus is an open source, **unofficial** multi-agent operating layer built around the [Claude Code](https://docs.anthropic.com/en/docs/claude-code) CLI protocol — but **not locked to any single LLM provider**. It runs natively on Anthropic's `claude` CLI by default, and can transparently switch to OpenAI, Google Gemini, OpenRouter (200+ models), AWS Bedrock, Google Vertex AI, or Codex Auth via [OpenClaude](https://www.npmjs.com/package/@gitlawb/openclaude). Same agents, same skills, same workflows — your choice of backend.
+O [EvoNexus](https://github.com/evolution-foundation/evo-nexus) é uma camada operacional multi-agente construída sobre o CLI do Claude Code: **38 agentes especializados** (17 de negócio + 21 de engenharia), 190+ skills, rotinas agendadas, heartbeats, tickets, goals e um dashboard web completo. Toda essa base vem do projeto original da [Evolution Foundation](https://evolutionfoundation.com.br) — leia o [README upstream](https://github.com/evolution-foundation/evo-nexus#readme) para conhecer a plataforma em profundidade.
-It turns a single CLI installation into a team of **38 specialized agents** organized in two ortogonal layers — **17 business agents** (operations, finance, community, marketing, HR, legal, product, data, learning retention) and **21 engineering agents** (architecture, planning, code review, testing, debugging, security, design, cycle orchestration, retrospective — 19 derived from [oh-my-claudecode](https://github.com/yeachan-heo/oh-my-claudecode), MIT, by Yeachan Heo + 2 native: Helm and Mirror). The engineering layer follows a canonical 6-phase workflow documented in `.claude/rules/dev-phases.md`.
+O **Omni-Nexus** é a camada de upgrade do [Sistema Britto](https://sistemabritto.com.br) em cima disso, com um objetivo claro: **rodar o EvoNexus inteiro numa VPS com Docker Swarm, sem depender de nenhum gateway de IA externo e sem exigir login claude.ai** — usando o provider que você quiser, inclusive vários ao mesmo tempo com fallback automático.
-**This is not a chatbot.** It is a real operating layer that runs routines, generates HTML reports, syncs meetings, triages emails, monitors community health, tracks financial metrics, and consolidates everything into a unified dashboard — all automated.
+### O upgrade em resumo
-## Part of the Evolution Foundation ecosystem
+| Camada | O que foi adicionado |
+|---|---|
+| **OmniRoute na stack** | Gateway de IA self-hosted ([OmniRoute](https://github.com/diegosouzapw/OmniRoute), 237+ providers) como serviço do Swarm, com DNS interno, auth nativa e volume persistente |
+| **Seletor de providers** | Provider `omnirouter` no dashboard + roteamento OpenClaude no terminal/chat, Codex OAuth via device auth, NVIDIA NIM, resolução de chaves por provider |
+| **Telegram multi-provider** | Bot em modo `provider`: responde pelo provider ativo, troca de provider no chat com `/provider`, áudio (Whisper/Groq), imagens, leitura de URLs e memória por conversa — sem login claude.ai |
+| **Pipeline de deploy VPS** | GitHub Actions → imagens no seu Docker Hub → stack de exemplo para Portainer/Traefik com volumes persistentes e backups SQLite consistentes |
+| **Hardening** | Dezenas de correções de produção: precedência de chaves por provider, auto-updater do CLI travado, trust/permissions como root em container, recuperação de EIO no terminal, allowlist do Telegram re-seedada a cada boot |
-EvoNexus is one of the projects maintained by Evolution Foundation. It is the operating layer that orchestrates the Foundation's own work — including the development of [Evo CRM Community](https://github.com/evolution-foundation/evo-crm-community), [Evolution API](https://github.com/evolution-foundation/evolution-api) and [Evolution Go](https://github.com/evolution-foundation/evolution-go).
+---
-### Why EvoNexus?
+## OmniRoute — o gateway de IA da stack
-- **Markdown-first agents** — agents are `.md` files with system prompts, not code. No SDK, no compile step. Add an agent by dropping a file in `.claude/agents/`, or package reusable bundles via the plugin system (see [`docs/introduction.md`](docs/introduction.md))
-- **Skills as instructions** — reusable capabilities are markdown too. 190+ skills covering finance, community, social, engineering, data, legal, HR, ops, product, CS
-- **Multi-provider by design** — default runs on Anthropic's native `claude` CLI, but can switch to OpenRouter, OpenAI, Gemini, AWS Bedrock, Google Vertex, or Codex Auth via [OpenClaude](https://www.npmjs.com/package/@gitlawb/openclaude) without touching a line of code. Your keys, your model choice, no vendor lock-in
-- **MCP integrations** — first-class support for Google Calendar, Gmail, GitHub, Linear, Telegram, Canva, Notion, and more via the Model Context Protocol
-- **Slash commands** — `/clawdia`, `/flux`, `/pulse`, `/apex` invoke agents directly from the terminal
-- **Persistent memory** — `CLAUDE.md` + per-agent memory survives across sessions
-- **CLI-first, local-only** — runs anywhere the Claude CLI (or OpenClaude) runs. Your data never leaves your infrastructure
+O upgrade mais importante deste fork: o [OmniRoute](https://github.com/diegosouzapw/OmniRoute) (MIT, criado por [diegosouzapw](https://github.com/diegosouzapw)) roda **dentro da sua stack Swarm** como o serviço opcional `omniroute`, e o EvoNexus fala com ele pela rede interna.
----
+**Por que isso importa:**
-## Key Features
-
-- **Multi-Provider** — runs on Anthropic (native `claude`) or any of 6 alternate backends via [OpenClaude](https://www.npmjs.com/package/@gitlawb/openclaude): OpenRouter (200+ models), OpenAI, Google Gemini, Codex Auth, AWS Bedrock, Google Vertex AI. Switch providers from the dashboard, no code changes
-- **17 Core Business Agents + Custom** — Ops, Finance, Projects, Community, Social, Strategy, Sales, Courses, Learning Retention, Personal, Knowledge, Marketing, HR, Customer Success, Legal, Product, Data — plus user-created `custom-*` agents (gitignored)
-- **21 Engineering Agents** — architecture, planning, code review, testing, debugging, security, design, cycle orchestration, retrospective
-- **190+ Skills + Custom** — organized by domain prefix (`social-`, `fin-`, `int-`, `prod-`, `mkt-`, `gog-`, `obs-`, `discord-`, `pulse-`, `sage-`, `hr-`, `legal-`, `ops-`, `cs-`, `data-`, `pm-`, `dev-`)
-- **7 Core + 20 Custom Routines** — daily, weekly, and monthly ADWs managed by a scheduler
-- **Web Dashboard** — React + Flask app with auth, roles, web terminal, service management
-- **19+ Integrations** — Google Calendar, Gmail, Linear, GitHub, Discord, Telegram, Stripe, Omie, Bling, Asaas, Fathom, Todoist, YouTube, Instagram, LinkedIn, Evolution API, Evolution Go, Evo CRM, and more
-- **Persistent Memory** — two-tier system (CLAUDE.md + memory/) with LLM Wiki pattern
-- **Knowledge Base** — optional semantic search via [MemPalace](https://github.com/milla-jovovich/mempalace) (local ChromaDB vectors, one-click install)
-- **Full Observability** — JSONL logs, execution metrics, cost tracking per routine
-- **Heartbeats** — proactive agents that wake on a schedule, run a 9-step protocol, and decide whether to act
-- **Goal Cascade** — Mission → Project → Goal → Task hierarchy
-- **Tickets** — persistent conversation/work threads with atomic checkout
+- **Fim da dependência externa** — se um gateway público cai (503), seu bot e seus heartbeats caem junto. Self-hosted, o único ponto de falha é a sua VPS.
+- **237+ providers com fallback automático** — configure OpenAI, Anthropic, Gemini, DeepSeek, Groq, NVIDIA e o que mais quiser no dashboard do OmniRoute; com `OPENAI_MODEL=auto` ele roteia pro melhor disponível e cai pro próximo se um falhar.
+- **Codex OAuth embutido** — conecte sua conta ChatGPT Plus/Pro no OmniRoute e use a cota do Codex como um provider comum.
+- **Compressão de tokens** (RTK/Caveman) — reduz o custo de contexto em 15–95% dependendo do conteúdo.
+- **Latência mínima** — o EvoNexus acessa `http://omniroute:20128/v1` via alias DNS do Swarm, sem sair pra internet e sem passar pelo Traefik.
----
+**Como fica a arquitetura:**
-## Screenshots
+```
+Telegram / Dashboard / Heartbeats / Rotinas
+ |
+ v
+EvoNexus (provider ativo: omnirouter)
+ |
+ v http://omniroute:20128/v1 (rede interna do Swarm)
+OmniRoute (self-hosted)
+ |
+ +-- Codex OAuth (ChatGPT Plus) [priority 1]
+ +-- NVIDIA NIM [fallback]
+ +-- OpenRouter / Gemini / DeepSeek… [fallback]
+```
-
-
-
-
-
-
-
-
-
-
-
+**Segurança:** o dashboard do OmniRoute usa a **auth nativa** (login + sessão JWT). Não coloque basic-auth do Traefik na frente — as chamadas internas do dashboard (SSE/WS/API) usam header `Authorization` próprio e entram em loop de 401. O `REQUIRE_API_KEY=true` garante que a API `/v1` só responde com chave válida.
----
+**Configuração assistida por agente:** [PROMPT-OMNIROUTE-CONFIG.md](PROMPT-OMNIROUTE-CONFIG.md) — prompt pronto pra colar num Claude Code (ou agente similar) que audita e otimiza o seu OmniRoute pela management API: keys nomeadas com telemetria de custo, compressão com auto-trigger, refresh de quota e reativação de modelos `:free`. Extraído de uma configuração real em produção.
-## Quick Start
+---
-> **Starting out?** After installing, open Claude Code and call **`/oracle`**. It's the official entry point of EvoNexus: runs the initial setup, interviews you about your business, shows what the toolkit can automate for you, and delivers a phased activation plan.
+## Seletor de providers
-### Method 1 — Docker (no setup, runs anywhere)
+A página **Providers** do dashboard ganhou o provider **OMNIROUTER** (qualquer endpoint OpenAI-compatível com URL, chave e modelo customizados), somando-se aos existentes (Anthropic nativo, OpenRouter, OpenAI, Gemini, NVIDIA NIM, Codex Auth, Bedrock, Vertex).
-```bash
-curl -O https://raw.githubusercontent.com/evolution-foundation/evo-nexus/main/docker-compose.hub.yml
-docker compose -f docker-compose.hub.yml up -d
-open http://localhost:8080
-```
+Regras de resolução de chave que este fork corrigiu e agora documenta (leia antes de debugar um 401):
-The setup wizard loads on first boot. Paste your Anthropic / OpenAI / Codex key and you're done. Full guide: [docs/guides/docker-install.md](docs/guides/docker-install.md).
+1. **A chave do próprio provider em `config/providers.json` sempre vence** — é ela que a página Providers grava.
+2. As chaves do `.env` são **fallback**, usadas só quando o provider não tem chave própria.
+3. `NVIDIA_API_KEY` do ambiente só é enviada para endpoints `*.nvidia.com` — nunca vaza para outros gateways.
-### Method 2 — One command (CLI)
+O terminal e o chat do dashboard usam o CLI [OpenClaude](https://www.npmjs.com/package/@gitlawb/openclaude) para providers não-Anthropic, com ambiente limpo por sessão, `--fallback-model` automático e auto-update do CLI desativado em produção (um self-update no meio da sessão matava o processo).
-```bash
-npx @evoapi/evo-nexus
-```
+---
-### Method 3 — Manual clone (developers / contributors)
+## Bot do Telegram multi-provider
-```bash
-git clone --depth 1 https://github.com/evolution-foundation/evo-nexus.git
-cd evo-nexus
+No EvoNexus original, o canal do Telegram usa o modo nativo do Claude Code (channels) — que **exige login claude.ai dentro do container** e não funciona com providers OpenAI-compatíveis. Este fork adiciona o **modo `provider`** (`TELEGRAM_MODE=provider`, padrão na stack): um runtime próprio que responde pelo provider ativo do dashboard.
-# Interactive setup wizard
-make setup
-```
+O que o bot faz:
-### Prerequisites
+| Recurso | Como |
+|---|---|
+| Responder pelo provider ativo | Chat Completions no provider configurado (OmniRoute, NVIDIA, OpenRouter, Codex…) |
+| **Trocar de provider no chat** | `/provider omnirouter` · `/provider status` · `/provider default` (volta ao global) |
+| Sessão nova | `/new` (limpa a memória local da conversa) |
+| Áudio → texto | Transcrição via Whisper na API da Groq (`/groq set ` para configurar) |
+| Imagens | Descreve e responde sobre fotos enviadas |
+| URLs | Baixa e resume links colados na conversa |
+| Memória por chat | Histórico local por conversa, com identificação de quem falou |
+| Fallback | Se o provider primário falha, percorre a cadeia `fallback_providers` do providers.json |
-| Tool | Required | Install |
-|---|---|---|
-| **Claude Code** | Yes (CLI install) | `npm install -g @anthropic-ai/claude-code` |
-| **Python 3.11+** | Yes (CLI install) | via `uv` |
-| **Node.js 18+** | Yes (CLI install) | [nodejs.org](https://nodejs.org) |
-| **uv** | Yes (CLI install) | `curl -LsSf https://astral.sh/uv/install.sh \| sh` |
-| **Docker Engine 24+** | Yes (Docker install) | [docs.docker.com/engine/install](https://docs.docker.com/engine/install/) |
+Benefício direto: o bot **sobrevive a redeploys sem re-login** (nada de sessão claude.ai pra expirar) e você escolhe o custo por conversa — manda o dia a dia pro modelo barato e troca pro modelo forte com um comando.
---
-## AI Providers
+## Deploy completo na VPS (passo a passo)
-EvoNexus runs on **Anthropic's Claude** by default. For OpenAI, Gemini, Bedrock, OpenRouter, Vertex AI, or Codex Auth, it switches to [OpenClaude](https://www.npmjs.com/package/@gitlawb/openclaude).
+### Pré-requisitos
-| Provider | Binary | Key env vars |
-|---|---|---|
-| **Anthropic** (default) | `claude` | native auth |
-| **OpenRouter** (200+ models) | `openclaude` | `CLAUDE_CODE_USE_OPENAI`, `OPENAI_BASE_URL`, `OPENAI_API_KEY`, `OPENAI_MODEL` |
-| **OpenAI** | `openclaude` | `CLAUDE_CODE_USE_OPENAI`, `OPENAI_API_KEY`, `OPENAI_MODEL` |
-| **Google Gemini** | `openclaude` | `CLAUDE_CODE_USE_GEMINI`, `GEMINI_API_KEY`, `GEMINI_MODEL` |
-| **Codex Auth** | `openclaude` | `CLAUDE_CODE_USE_OPENAI`, `OPENAI_MODEL=codexplan` |
-| **AWS Bedrock** | `openclaude` | `CLAUDE_CODE_USE_BEDROCK`, `AWS_REGION`, `AWS_BEARER_TOKEN_BEDROCK` |
-| **Google Vertex AI** | `openclaude` | `CLAUDE_CODE_USE_VERTEX`, `ANTHROPIC_VERTEX_PROJECT_ID`, `CLOUD_ML_REGION` |
+- VPS com **Docker Swarm** inicializado (`docker swarm init`)
+- **Traefik** rodando e conectado à rede externa `network_public` (entrypoint TLS `websecure`, cert resolver `letsencryptresolver`)
+- **Portainer** (recomendado) ou acesso SSH para `docker stack deploy`
+- Dois subdomínios apontando pra VPS (A record): um pro EvoNexus (ex.: `nexus.seudominio.com.br`) e um pro dashboard do OmniRoute (ex.: `omni.seudominio.com.br`)
-```bash
-npm install -g @gitlawb/openclaude
-```
+### 1. Publique as imagens no seu Docker Hub
-The setup wizard asks which provider you want during `make setup`, and you can switch at any time from the **Providers** page in the dashboard.
+O workflow [`.github/workflows/docker-publish-britto.yml`](.github/workflows/docker-publish-britto.yml) builda as duas imagens Swarm (`evo-nexus-runtime` e `evo-nexus-dashboard`) e publica **no seu namespace** do Docker Hub. Faça fork deste repositório e configure os secrets em *Settings → Secrets and variables → Actions*:
----
+| Secret | Valor |
+|---|---|
+| `DOCKERHUB_USERNAME` | seu usuário do Docker Hub (vira o namespace das imagens) |
+| `DOCKERHUB_TOKEN` | Access Token (Docker Hub → Account Settings → Security) |
-## Web Dashboard
+Qualquer push na branch de deploy (ou tag `vX.Y.Z`, ou disparo manual) publica `:latest` e `:sha-xxxx`. Build típico: ~2 min com cache.
-A full web UI at `http://localhost:8080`:
+### 2. Suba a stack no Portainer
-| Page | What it does |
+Use a [`evonexus-vps.stack.example.yml`](evonexus-vps.stack.example.yml) como base (Portainer → Stacks → Add stack → Web editor). Ela sobe 4 serviços:
+
+| Serviço | O que é |
|---|---|
-| **Overview** | Unified dashboard with metrics from all agents |
-| **Systems** | Register and manage apps/services |
-| **Reports** | Browse HTML reports generated by routines |
-| **Agents** | View agent definitions and system prompts |
-| **Routines** | Metrics per routine + manual run |
-| **Tasks** | Schedule one-off actions at a specific date/time |
-| **Skills** | Browse all 190+ skills by category |
-| **Templates** | Preview HTML report templates |
-| **Services** | Start/stop scheduler, channels with live logs |
-| **Memory** | Browse agent and global memory files |
-| **Knowledge** | Semantic search via [MemPalace](https://github.com/milla-jovovich/mempalace) |
-| **Integrations** | Status of all connected services + OAuth setup |
-| **Chat** | Embedded Claude Code terminal (xterm.js + WebSocket) |
-| **Users** | User management with roles |
-| **Audit Log** | Full audit trail of all actions |
-| **Config** | View CLAUDE.md, routines config, workspace settings |
+| `evonexus_dashboard` | Flask + React + terminal web + heartbeats (exposto via Traefik) |
+| `evonexus_scheduler` | Rotinas agendadas (ADWs) |
+| `evonexus_telegram` | Bot do Telegram em modo provider |
+| `omniroute` | Gateway de IA (opcional, mas recomendado) |
-```bash
-make dashboard-app # Start Flask + React on :8080
-```
+Preencha as variáveis da stack (aba *Environment variables* do Portainer):
----
+| Variável | Como gerar |
+|---|---|
+| `EVONEXUS_DOMAIN` | seu domínio (ex.: `nexus.seudominio.com.br`) |
+| `DASHBOARD_API_TOKEN` | `openssl rand -base64 32` |
+| `OMNIROUTE_DOMAIN` | domínio do dashboard do OmniRoute (ex.: `omni.seudominio.com.br`) |
+| `OMNIROUTE_INITIAL_PASSWORD` | senha de login do dashboard do OmniRoute |
+| `OMNIROUTE_JWT_SECRET` | `openssl rand -base64 48` |
+| `OMNIROUTE_API_KEY_SECRET` | `openssl rand -hex 32` |
+| `OMNIROUTE_STORAGE_KEY` | `openssl rand -hex 32` — **guarde bem: cifra o SQLite do OmniRoute; perder = perder as configs** |
+| `SMTP_*` | opcionais (notificações por email) |
-## Architecture
+A stack **não contém nenhuma credencial de propósito** — todos os tokens de integrações (Google, Stripe, Linear…) são configurados depois, pela UI.
-```
-User (human)
- |
- v
-Claude Code (orchestrator)
- |
- +-- Clawdia — ops: agenda, emails, tasks, decisions, dashboard
- +-- Flux — finance: Stripe, ERP, MRR, cash flow, monthly close
- +-- Atlas — projects: Linear, GitHub, milestones, sprints
- +-- Pulse — community: Discord, WhatsApp, sentiment, FAQ
- +-- Pixel — social: content, calendar, cross-platform analytics
- +-- Sage — strategy: OKRs, roadmap, prioritization, scenarios
- +-- Nex — sales: pipeline, proposals, qualification
- +-- Mentor — courses: learning paths, modules
- +-- Kai — personal: health, habits, routine
- +-- Oracle — entry point: onboarding, business discovery
- +-- Mako — marketing: campaigns, content, SEO, brand
- +-- Aria — HR: recruiting, onboarding, performance
- +-- Zara — customer success: triage, escalation, health
- +-- Lex — legal: contracts, compliance, NDA, risk
- +-- Nova — product: specs, roadmaps, metrics, research
- +-- Dex — data/BI: analysis, SQL, dashboards
-```
+### 3. Configure o OmniRoute
-Each agent has:
-- System prompt in `.claude/agents/`
-- Slash command in `.claude/commands/`
-- Persistent memory in `.claude/agent-memory/`
-- Related skills in `.claude/skills/`
+1. Acesse `https://omni.seudominio.com.br` e faça login com a `OMNIROUTE_INITIAL_PASSWORD`.
+2. Na aba de **providers**, conecte o que você usa: Codex OAuth (ChatGPT Plus), NVIDIA, Gemini, DeepSeek, etc. A **ordem de prioridade** define o roteamento do `auto`.
+3. Na aba **Endpoints**, gere uma **API key** (`sk-...`) para o EvoNexus.
----
+> ⚠️ As keys vivem no SQLite do volume `omniroute_data`. Se você zerar o volume, **todas as keys morrem** — gere uma nova e atualize no EvoNexus. E pra zerar o volume no Swarm: `docker volume rm` falha com "volume in use" enquanto containers parados de tasks antigas existirem; remova-os antes com `docker ps -a --filter volume=omniroute_data -q | xargs docker rm -f`.
+
+### 4. Plugue o OmniRoute como provider do EvoNexus
-## Documentation
+Acesse `https://nexus.seudominio.com.br` → **Providers** → **OMNIROUTER**:
-| Resource | Link |
+| Campo | Valor |
|---|---|
-| Website | [evolutionfoundation.com.br](https://evolutionfoundation.com.br) |
-| Documentation | [docs.evolutionfoundation.com.br](https://docs.evolutionfoundation.com.br) |
-| Community | [evolutionfoundation.com.br/community](https://evolutionfoundation.com.br/community) |
-| Getting Started | [docs/getting-started.md](docs/getting-started.md) |
-| Architecture | [docs/architecture.md](docs/architecture.md) |
-| Routines | [ROUTINES.md](ROUTINES.md) |
-| Roadmap | [ROADMAP.md](ROADMAP.md) |
-| Changelog | [CHANGELOG.md](CHANGELOG.md) |
-| Contributing | [CONTRIBUTING.md](CONTRIBUTING.md) |
-| Security | [SECURITY.md](SECURITY.md) |
+| Base URL | `http://omniroute:20128/v1` (DNS interno do Swarm — não use a URL pública) |
+| API Key | a key gerada no passo 3 |
+| Model | `auto` (deixa o OmniRoute rotear e fazer fallback) |
----
+Marque como provider ativo. Pronto: dashboard, terminal, heartbeats, rotinas e Telegram passam a responder pelo OmniRoute.
-## Contributing
+### 5. Telegram (opcional)
-Contributions are welcome! Please read [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines on how to submit issues, propose features, and open pull requests.
+1. Crie um bot no [@BotFather](https://t.me/BotFather) e pegue o token.
+2. No dashboard → **Integrations**, salve `TELEGRAM_BOT_TOKEN` e `TELEGRAM_CHAT_ID` (seu chat id).
+3. O serviço `evonexus_telegram` já sobe em `TELEGRAM_MODE=provider`. Mande um `ping` — deve responder pelo provider ativo.
-Join our [community](https://evolutionfoundation.com.br/community) to discuss ideas and collaborate.
+> ⚠️ Cada deploy precisa do **seu próprio bot/token** — dois pollers no mesmo token brigam (HTTP 409) e um rouba as mensagens do outro.
----
+### 6. Atualizações
+
+Push na branch de deploy → GitHub Actions publica as imagens novas → na VPS:
+
+```bash
+docker service update --force --image SEU_USUARIO/evo-nexus-dashboard:latest evonexus_evonexus_dashboard
+docker service update --force --image SEU_USUARIO/evo-nexus-runtime:latest evonexus_evonexus_telegram
+docker service update --force --image SEU_USUARIO/evo-nexus-runtime:latest evonexus_evonexus_scheduler
+```
-## Security
+### Troubleshooting rápido
-For security issues, **do not open a public issue**. Email **suporte@evofoundation.com.br** or use GitHub's private vulnerability reporting. See [SECURITY.md](SECURITY.md) for details.
+| Sintoma | Causa provável |
+|---|---|
+| `401 Unauthorized: chave API inválida/expirada` | Key do provider errada **no providers.json** (a página Providers grava lá; o `.env` é só fallback) — ou key do OmniRoute morta por reset de volume |
+| Bot responde `All providers failed` | Provider ativo sem chave válida; teste `/provider status` no chat |
+| Terminal morre com exit 1 no meio da sessão | Auto-update do CLI (já travado com `DISABLE_AUTOUPDATER=1` nesta versão) |
+| `workspace has not been trusted` como root | Entrypoints desta versão re-seedam o trust e exportam `IS_SANDBOX=1` a cada boot — confira se está na imagem atualizada |
+| Dashboard do OmniRoute em loop de 401 | Basic-auth do Traefik na frente — remova; a auth é nativa |
---
-## Credits & Acknowledgments
+## O que vem do upstream (e continua aqui)
-EvoNexus stands on the shoulders of great open source projects:
+Tudo do EvoNexus original está preservado: os 38 agentes, as 190+ skills, rotinas/scheduler, heartbeats (protocolo de 9 passos), goals (cascata Mission → Project → Goal → Task), tickets com checkout atômico, memória persistente em duas camadas, knowledge base semântica, dashboard completo com auditoria e gestão de usuários, e as 19+ integrações (Google, Linear, GitHub, Discord, Stripe, Omie, Bling, Asaas, Fathom, Todoist…).
-- **[oh-my-claudecode](https://github.com/yeachan-heo/oh-my-claudecode)** by **Yeachan Heo** (MIT) — 19 of the 21 engineering agents (including `apex-architect`, `bolt-executor`, `lens-reviewer`) and all `dev-*` skills are derived from OMC v4.11.4. The 2 native agents (`helm-conductor`, `mirror-retro`) and the 6-phase workflow (`.claude/rules/dev-phases.md`) are EvoNexus-native additions. See [NOTICE.md](NOTICE.md) for the full list of derived components and modifications.
+Documentação da plataforma: [README original](https://github.com/evolution-foundation/evo-nexus#readme) · [docs.evolutionfoundation.com.br](https://docs.evolutionfoundation.com.br) · [docs/getting-started.md](docs/getting-started.md) · [docs/architecture.md](docs/architecture.md) · [ROUTINES.md](ROUTINES.md) · [CHANGELOG.md](CHANGELOG.md)
---
-## License
+## Créditos & Agradecimentos
+
+Este fork existe porque outros construíram coisas excelentes antes:
+
+- **[EvoNexus](https://github.com/evolution-foundation/evo-nexus)** pela **[Evolution Foundation](https://evolutionfoundation.com.br)** — a plataforma inteira: agentes, skills, rotinas, heartbeats, goals, tickets, dashboard e integrações. Este repositório é um fork derivado; todo o mérito da base é deles. Site: [evolutionfoundation.com.br](https://evolutionfoundation.com.br) · Suporte: suporte@evofoundation.com.br
+- **[OmniRoute](https://github.com/diegosouzapw/OmniRoute)** por **[Diego Souza](https://github.com/diegosouzapw)** (MIT) — o gateway de IA self-hosted que esta distribuição embute na stack.
+- **[oh-my-claudecode](https://github.com/yeachan-heo/oh-my-claudecode)** por **Yeachan Heo** (MIT) — 19 dos 21 agentes de engenharia e as skills `dev-*` derivam do OMC (herdado do upstream). Detalhes em [NOTICE.md](NOTICE.md).
+- **[OpenClaude](https://www.npmjs.com/package/@gitlawb/openclaude)** — o CLI que permite rodar o protocolo do Claude Code em providers alternativos.
+
+A camada de upgrade (OmniRoute na stack, seletor de providers, Telegram multi-provider, pipeline VPS e hardening) é mantida por **[Sistema Britto](https://sistemabritto.com.br)**.
+
+---
-EvoNexus is licensed under the Apache License 2.0, with additional brand-protection conditions (LOGO/copyright preservation and Usage Notification requirement). See [LICENSE](LICENSE) for full details.
+## Licença
-For licensing inquiries, contact **suporte@evofoundation.com.br**.
+Este fork mantém integralmente a licença do EvoNexus original: **Apache License 2.0 com condições adicionais de proteção de marca** — preservação de LOGO/copyright nos componentes de frontend e requisito de notificação de uso. Veja [LICENSE](LICENSE) para o texto completo.
-## Trademarks
+Em conformidade com essas condições, esta distribuição **não remove nem modifica** o LOGO e as informações de copyright do EvoNexus no console e nas aplicações. Para questões de licenciamento do EvoNexus, contate **suporte@evofoundation.com.br**.
-"Evolution Foundation", "Evolution" and "EvoNexus" are trademarks of Evolution Foundation. See [TRADEMARKS.md](TRADEMARKS.md) for the brand assets policy.
+## Marcas
-Third-party attributions are documented in [NOTICE](NOTICE) and [NOTICE.md](NOTICE.md).
+"Evolution Foundation", "Evolution" e "EvoNexus" são marcas da Evolution Foundation — veja [TRADEMARKS.md](TRADEMARKS.md). "Omni-Nexus" nomeia apenas esta distribuição derivada e não é afiliado à Evolution Foundation além da relação de fork. Atribuições de terceiros: [NOTICE](NOTICE) e [NOTICE.md](NOTICE.md).
---
+ )}
{/* end input row wrapper (relative) */}
diff --git a/dashboard/frontend/src/components/AgentTerminal.tsx b/dashboard/frontend/src/components/AgentTerminal.tsx
index ef438a0a..28ba68b5 100644
--- a/dashboard/frontend/src/components/AgentTerminal.tsx
+++ b/dashboard/frontend/src/components/AgentTerminal.tsx
@@ -3,6 +3,7 @@ import { Terminal } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import { WebLinksAddon } from '@xterm/addon-web-links'
import '@xterm/xterm/css/xterm.css'
+import TerminalHudPanel from './TerminalHudPanel'
interface AgentTerminalProps {
agent: string
@@ -73,6 +74,25 @@ const CC_WEB_WS = override
type Status = 'connecting' | 'ready' | 'starting' | 'running' | 'error' | 'exited'
+// Terminal HUD (see workspace/development/features/terminal-hud) — only
+// populated for opencode REPL sessions; claude/openclaude (pty-interactive)
+// sessions never send 'hud_update', so this stays null for them and the
+// semaphore/gear panel just doesn't render.
+interface HudState {
+ busy: boolean
+ heavy: boolean
+ providerId: string
+ providerModel: string
+ tokensPerSec: number
+ totalTokens: number | null
+ bestTokensPerSec: number
+ shift: boolean
+}
+
+function isEioMessage(message: unknown) {
+ return /\bEIO\b|read EIO|write EIO/i.test(String(message || ''))
+}
+
export default function AgentTerminal({ agent, sessionId: externalSessionId, workingDir, accentColor = '#00FFA7' }: AgentTerminalProps) {
const containerRef = useRef(null)
const termRef = useRef(null)
@@ -82,6 +102,23 @@ export default function AgentTerminal({ agent, sessionId: externalSessionId, wor
const pingRef = useRef | null>(null)
const [status, setStatus] = useState('connecting')
const [errorMsg, setErrorMsg] = useState(null)
+ const [hud, setHud] = useState(null)
+ // opencode REPL sessions get a dedicated input bar below (see render) —
+ // xterm becomes output-only for them so typed keys never reach the
+ // onData forwarder below and the two input paths can't double-process a
+ // keystroke (that's what broke typing the first time this was tried).
+ // Ref because onData is registered once at mount and would otherwise
+ // only ever see the null `hud` from that first render (stale closure).
+ const isOpencodeRef = useRef(false)
+ const [inputValue, setInputValue] = useState('')
+ // Mirrors `status` for the onData closure below, which is registered once
+ // on mount and would otherwise only ever see the 'connecting' status from
+ // that first render (React state, not a ref, doesn't update in a stale
+ // closure).
+ const statusRef = useRef('connecting')
+ useEffect(() => {
+ statusRef.current = status
+ }, [status])
// Mount xterm once
useEffect(() => {
@@ -159,15 +196,35 @@ export default function AgentTerminal({ agent, sessionId: externalSessionId, wor
// alternative.
const AUTO_REPLY_RE = /^\x1b\[(\?|>)[0-9;]*[a-zA-Z]$|^\x1b\[[0-9;]*[nRct]$/
term.onData((data) => {
- // TEMP DEBUG: log every onData payload so we can see what's being
- // sent to the pty on startup
- const hex = Array.from(data).map((c) => (c as unknown as string).charCodeAt(0).toString(16).padStart(2, '0')).join('')
- // eslint-disable-next-line no-console
- console.log('[xterm onData]', data.length, 'B hex:', hex, ' match:', AUTO_REPLY_RE.test(data))
+ // opencode sessions type into the dedicated input bar instead (see
+ // render) — xterm here is output-only for them.
+ if (isOpencodeRef.current) return
if (AUTO_REPLY_RE.test(data)) return
- if (wsRef.current?.readyState === WebSocket.OPEN) {
- wsRef.current.send(JSON.stringify({ type: 'input', data }))
+ const ws = wsRef.current
+ if (!ws || ws.readyState !== WebSocket.OPEN) return
+
+ // The CLI process can die mid-session (provider crash, exit 1, etc.)
+ // without the user noticing beyond the small status badge. Forwarding
+ // raw keystrokes to a dead PTY used to just vanish — the server has
+ // nothing to write them to. Treat "user typed something" as an
+ // implicit restart request instead of a dead end.
+ if (statusRef.current === 'exited' || statusRef.current === 'error') {
+ statusRef.current = 'starting'
+ setStatus('starting')
+ term!.write('\r\n\x1b[33m[Restarting agent]\x1b[0m\r\n')
+ ws.send(JSON.stringify({
+ type: 'start_claude',
+ options: {
+ dangerouslySkipPermissions: true,
+ agent,
+ cols: term!.cols,
+ rows: term!.rows,
+ },
+ }))
+ return
}
+
+ ws.send(JSON.stringify({ type: 'input', data }))
})
return () => {
@@ -184,14 +241,33 @@ export default function AgentTerminal({ agent, sessionId: externalSessionId, wor
const term = termRef.current
if (!term) return
+ let reconnectTimer: ReturnType | null = null
+ let reconnectAttempts = 0
+ let alreadyActive = false
+
+ // The server keeps the pty alive when the socket drops, so a dead WS
+ // only needs a rejoin — reconnect with capped exponential backoff
+ // instead of leaving the terminal dead until the component remounts.
+ function scheduleReconnect(sessionId: string) {
+ if (cancelled || reconnectTimer) return
+ const delay = Math.min(1000 * 2 ** reconnectAttempts, 15000)
+ reconnectAttempts++
+ setStatus('connecting')
+ reconnectTimer = setTimeout(() => {
+ reconnectTimer = null
+ connect(sessionId, true)
+ }, delay)
+ }
+
async function run() {
setStatus('connecting')
setErrorMsg(null)
+ isOpencodeRef.current = false
+ setHud(null)
term!.clear()
// 1) Use provided sessionId or find-or-create for this agent
let sessionId: string
- let alreadyActive = false
try {
if (externalSessionId) {
// Use the specific session provided by the parent (multi-tab mode)
@@ -223,11 +299,35 @@ export default function AgentTerminal({ agent, sessionId: externalSessionId, wor
if (cancelled) return
sessionIdRef.current = sessionId
+ connect(sessionId, false)
+ }
+
+ function connect(sessionId: string, isReconnect: boolean) {
+ if (cancelled) return
+
// 2) Open WS
const ws = new WebSocket(`${CC_WEB_WS}/ws`)
wsRef.current = ws
+ const startClaude = () => {
+ setStatus('starting')
+ const fit = fitRef.current
+ if (fit) {
+ try { fit.fit() } catch {}
+ }
+ ws.send(JSON.stringify({
+ type: 'start_claude',
+ options: {
+ dangerouslySkipPermissions: true,
+ agent,
+ cols: term!.cols,
+ rows: term!.rows,
+ },
+ }))
+ }
+
ws.onopen = () => {
+ setErrorMsg(null)
ws.send(JSON.stringify({ type: 'join_session', sessionId }))
}
@@ -238,12 +338,18 @@ export default function AgentTerminal({ agent, sessionId: externalSessionId, wor
switch (msg.type) {
case 'session_joined': {
+ reconnectAttempts = 0
+ // On reconnect the server replays the whole buffer — clear
+ // first so it doesn't duplicate what's already on screen.
+ if (isReconnect) term!.clear()
// Replay any buffered output
if (Array.isArray(msg.outputBuffer)) {
msg.outputBuffer.forEach((chunk: string) => term!.write(chunk))
}
- // If an agent is already running in this session, just attach
- if (msg.active || alreadyActive) {
+ // If an agent is already running in this session, just attach.
+ // alreadyActive is only trustworthy on the first join — after a
+ // reconnect the process may have died while we were away.
+ if (msg.active || (!isReconnect && alreadyActive)) {
setStatus('running')
// Nudge a resize so the pty matches the current terminal size
const fit = fitRef.current
@@ -251,6 +357,12 @@ export default function AgentTerminal({ agent, sessionId: externalSessionId, wor
try { fit.fit() } catch {}
ws.send(JSON.stringify({ type: 'resize', cols: term!.cols, rows: term!.rows }))
}
+ } else if (isReconnect) {
+ // Process ended while we were disconnected. Restart it in-place
+ // so a transient proxy/browser socket drop doesn't force a full
+ // page refresh to get the agent moving again.
+ term!.write('\r\n\x1b[33m[Reconnected - restarting agent]\x1b[0m\r\n')
+ startClaude()
} else {
// Start Claude with --agent
// Pass cols/rows up-front so the pty is born at the right
@@ -258,20 +370,7 @@ export default function AgentTerminal({ agent, sessionId: externalSessionId, wor
// queries during startup can echo back into the prompt as
// literal text ("0?1;2c0?1;2c") before the first resize
// message arrives.
- setStatus('starting')
- const fit = fitRef.current
- if (fit) {
- try { fit.fit() } catch {}
- }
- ws.send(JSON.stringify({
- type: 'start_claude',
- options: {
- dangerouslySkipPermissions: true,
- agent,
- cols: term!.cols,
- rows: term!.rows,
- },
- }))
+ startClaude()
}
break
}
@@ -290,23 +389,51 @@ export default function AgentTerminal({ agent, sessionId: externalSessionId, wor
}
break
case 'exit':
- setStatus('exited')
- term!.write(`\r\n\x1b[33m[Process exited${msg.code != null ? ` with code ${msg.code}` : ''}]\x1b[0m\r\n`)
+ // EIO is no longer sent as a signal from the server — the
+ // server now forwards signal: null for PTY EIO events. But
+ // keep backward-compat: if an old server still sends 'EIO',
+ // treat it as a normal exit (not an error that restarts).
+ if (msg.signal === 'EIO') {
+ setStatus('exited')
+ term!.write(`\r\n\x1b[33m[Process exited${msg.code != null ? ` with code ${msg.code}` : ''}]\x1b[0m\r\n`)
+ } else {
+ setStatus('exited')
+ term!.write(`\r\n\x1b[33m[Process exited${msg.code != null ? ` with code ${msg.code}` : ''}]\x1b[0m\r\n`)
+ }
break
case 'error':
- setStatus('error')
- setErrorMsg(msg.message || 'Unknown error')
- term!.write(`\r\n\x1b[31m[Error] ${msg.message || ''}\x1b[0m\r\n`)
+ // EIO no longer reaches here — the server converts it to a
+ // normal 'exit' event. If it somehow still arrives, treat it
+ // as an exit, not a retryable error.
+ if (isEioMessage(msg.message)) {
+ setStatus('exited')
+ term!.write('\r\n\x1b[33m[Process exited]\x1b[0m\r\n')
+ } else {
+ setStatus('error')
+ setErrorMsg(msg.message || 'Unknown error')
+ term!.write(`\r\n\x1b[31m[Error] ${msg.message || ''}\x1b[0m\r\n`)
+ }
break
case 'pong':
break
+ case 'hud_update':
+ isOpencodeRef.current = true
+ setHud({
+ busy: !!msg.busy,
+ heavy: !!msg.heavy,
+ providerId: msg.providerId || '',
+ providerModel: msg.providerModel || '',
+ tokensPerSec: typeof msg.tokensPerSec === 'number' ? msg.tokensPerSec : 0,
+ totalTokens: typeof msg.totalTokens === 'number' ? msg.totalTokens : null,
+ bestTokensPerSec: typeof msg.bestTokensPerSec === 'number' ? msg.bestTokensPerSec : 0,
+ shift: !!msg.shift,
+ })
+ break
}
}
ws.onerror = () => {
- if (cancelled) return
- setStatus('error')
- setErrorMsg('WebSocket error')
+ // onclose always follows onerror — reconnect is handled there.
}
ws.onclose = () => {
@@ -314,6 +441,8 @@ export default function AgentTerminal({ agent, sessionId: externalSessionId, wor
clearInterval(pingRef.current)
pingRef.current = null
}
+ if (cancelled) return
+ scheduleReconnect(sessionId)
}
// Keepalive
@@ -324,10 +453,33 @@ export default function AgentTerminal({ agent, sessionId: externalSessionId, wor
}, 25000)
}
+ // Returning to the tab reconnects immediately instead of waiting out
+ // the backoff (browsers also throttle timers in hidden tabs, so the
+ // pending reconnect may not have fired while the tab was away).
+ const onVisible = () => {
+ if (document.hidden || cancelled) return
+ const sessionId = sessionIdRef.current
+ if (!sessionId) return
+ const ws = wsRef.current
+ if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return
+ if (reconnectTimer) {
+ clearTimeout(reconnectTimer)
+ reconnectTimer = null
+ }
+ reconnectAttempts = 0
+ connect(sessionId, true)
+ }
+ document.addEventListener('visibilitychange', onVisible)
+
run()
return () => {
cancelled = true
+ document.removeEventListener('visibilitychange', onVisible)
+ if (reconnectTimer) {
+ clearTimeout(reconnectTimer)
+ reconnectTimer = null
+ }
if (pingRef.current) {
clearInterval(pingRef.current)
pingRef.current = null
@@ -383,8 +535,91 @@ export default function AgentTerminal({ agent, sessionId: externalSessionId, wor
)}
+ {/* Dashboard row (car-panel HUD, Sprint 3) — own row instead of
+ crammed into the 32px status bar above, so the LCD readout has
+ real width to work with instead of truncating provider/model. */}
+ {hud && (
+
+
+
+ {/* Semaphore (Sprint 2): 3 fixed lights, only the active one
+ lit — green=waiting for a prompt, yellow=working,
+ red=last completed turn was heavy (>20k tokens). Priority
+ red > yellow > green when both could apply (a heavy turn
+ just finished right as a new one starts). Always visible,
+ including on mobile. */}
+
+ )}
+
{/* xterm */}
+
+ {/* Dedicated input bar (opencode sessions only, i.e. hud !== null) —
+ keeps what you type visually separate from the AI's streamed
+ response instead of both sharing the same scrollback, which read
+ as confusing (couldn't tell where your line ended and the reply
+ began). xterm above is output-only for these sessions — see the
+ isOpencodeRef guard in the onData handler. */}
+ {hud && (
+
+ )}
)
}
diff --git a/dashboard/frontend/src/components/PluginInstallModal.tsx b/dashboard/frontend/src/components/PluginInstallModal.tsx
index 834e3708..ccc32703 100644
--- a/dashboard/frontend/src/components/PluginInstallModal.tsx
+++ b/dashboard/frontend/src/components/PluginInstallModal.tsx
@@ -7,7 +7,10 @@ import SecurityScanSection, { type ScanVerdict, type ScanResult } from './Securi
interface PreviewResult {
manifest: Record
warnings: string[]
- conflicts?: Record
+ // Backend returns conflicts as a list of human-readable strings (not a dict).
+ // See plugin_loader.PluginInstaller.preview() — each blocker is a string
+ // appended to result["conflicts"].
+ conflicts?: string[]
}
interface Props {
@@ -140,7 +143,9 @@ export default function PluginInstallModal({ onClose, onInstalled }: Props) {
const manifest = preview?.manifest ?? {}
const warnings = preview?.warnings ?? []
- const conflicts = preview?.conflicts ? Object.keys(preview.conflicts) : []
+ const conflicts: string[] = Array.isArray(preview?.conflicts)
+ ? (preview!.conflicts as string[]).filter((c): c is string => typeof c === 'string' && c.length > 0)
+ : []
// Install button is amber for WARN, normal green otherwise
const installBtnClass =
@@ -338,7 +343,11 @@ export default function PluginInstallModal({ onClose, onInstalled }: Props) {
@@ -289,7 +289,7 @@ export default function Sidebar() {
)}
diff --git a/dashboard/frontend/src/components/TerminalHudPanel.tsx b/dashboard/frontend/src/components/TerminalHudPanel.tsx
new file mode 100644
index 00000000..4b452add
--- /dev/null
+++ b/dashboard/frontend/src/components/TerminalHudPanel.tsx
@@ -0,0 +1,183 @@
+import { useEffect, useRef, useState } from 'react'
+
+// Terminal HUD — gear/LCD panel (see workspace/development/features/terminal-hud).
+// The gear number carries no meaning of its own — there's no fixed
+// "model X = gear Y" scale to define, and none was asked for. It's the
+// visual "something changed" cue: it advances whenever the backend marks a
+// hud_update as `shift` (provider or model actually changed since the last
+// update). The LCD readout next to it carries the real information:
+// provider, model, live tokens/s.
+interface HudState {
+ busy: boolean
+ heavy: boolean
+ providerId: string
+ providerModel: string
+ tokensPerSec: number
+ totalTokens: number | null
+ bestTokensPerSec: number
+ shift: boolean
+}
+
+interface Props {
+ hud: HudState
+ accentColor: string
+}
+
+// Fires once on the specific tick where `busy` flips true -> false — the
+// moment a turn closes and the live char-based tokens/s estimate is
+// replaced by the real reconciled number (see claude-bridge.js's
+// _emitHudUpdate at turn close). That's the one moment a "needle settle"
+// kick reads as physical rather than jittery — every other tick the number
+// just drifts smoothly as text streams in, no kick needed.
+function useSettleTick(busy: boolean) {
+ const [tick, setTick] = useState(0)
+ const prevBusy = useRef(busy)
+ useEffect(() => {
+ if (prevBusy.current && !busy) {
+ setTick((t) => t + 1)
+ }
+ prevBusy.current = busy
+ }, [busy])
+ return tick
+}
+
+function useGear(providerId: string, providerModel: string, shift: boolean) {
+ const [gear, setGear] = useState(1)
+ const prevKey = useRef('')
+
+ useEffect(() => {
+ const key = `${providerId}/${providerModel}`
+ if (shift && key !== prevKey.current && prevKey.current !== '') {
+ setGear((g) => (g % 5) + 1)
+ }
+ prevKey.current = key
+ // Only re-runs when the shift signal or the provider/model identity
+ // itself changes — not on every hud_update tick (those fire on every
+ // streamed text fragment and would otherwise re-run this on each one).
+ }, [shift, providerId, providerModel])
+
+ return gear
+}
+
+// H-pattern gate coordinates (5-speed, no reverse) in the shifter SVG's
+// 24x24 viewBox — three columns (x=6/12/18) x two rows (y=6/18), plus a
+// neutral crossbar at y=12 you'd have to pass through on a real shifter.
+const GATE_POS: Record = {
+ 1: [6, 6], 2: [6, 18], 3: [12, 6], 4: [12, 18], 5: [18, 6],
+}
+
+export default function TerminalHudPanel({ hud, accentColor }: Props) {
+ const gear = useGear(hud.providerId, hud.providerModel, hud.shift)
+ const settleTick = useSettleTick(hud.busy)
+ // Sprint 3 (terminal-ux-upgrade): tokensPerSec is now a fixed
+ // typical-throughput baseline per model (see claude-bridge.js's
+ // _avgTokensPerSecFor), not a live-measured instantaneous rate — so
+ // there's no meaningful "personal best" to flag anymore (every tick
+ // trivially equals the baseline). The PB badge that used to compare
+ // against bestTokensPerSec is gone; the number just reads as "médio".
+ const speed = Math.max(0, Math.round(hud.tokensPerSec))
+ const [puckX, puckY] = GATE_POS[gear] ?? GATE_POS[1]
+ const routeLabel = `${hud.providerId}/${hud.providerModel}`
+
+ return (
+
+ {/* Shifter knob — H-gate diagram with a puck that slides between the
+ 5 positions (CSS transform transition, not a remount) plus a
+ numeral badge on the corner. Reads as "gear changed" the same way
+ a real shifter would, instead of just an incrementing digit.
+ Bumped from 32px to 44px + brighter gate/bezel contrast — the
+ 32px version read as a barely-visible dark smudge in the panel. */}
+
+
+ {/* Gear numeral badge — `key={gear}` replays the kick animation on
+ every shift, same trick the old plain-numeral version used. */}
+
+ {gear}
+
+
+
+ {/* LCD readout — full route label, no more aggressive truncation
+ now that this panel has its own full-width dashboard row. */}
+
+
+ {routeLabel}
+
+
+ {speed} tok/s méd
+
+
+
+
+
+ )
+}
diff --git a/dashboard/frontend/src/i18n/locales/en-US/index.ts b/dashboard/frontend/src/i18n/locales/en-US/index.ts
index 675e120e..9804a040 100644
--- a/dashboard/frontend/src/i18n/locales/en-US/index.ts
+++ b/dashboard/frontend/src/i18n/locales/en-US/index.ts
@@ -181,7 +181,9 @@ const translations = {
memory: 'Memory',
knowledge: 'Knowledge',
heartbeats: 'Heartbeats',
+ projects: 'Projects',
goals: 'Goals',
+ kanban: 'Kanban',
tasks: 'Tasks',
triggers: 'Triggers',
routines: 'Routines',
@@ -1046,7 +1048,7 @@ const translations = {
tabDevice: 'Device Auth',
generateLink: 'Generate auth link',
step1Open: '1. Open this link to login',
- step2Paste: '2. Authorize access, then paste the callback URL here',
+ step2Paste: '2. Authorize access and paste the final localhost URL here. Do not replace localhost with your domain',
callbackPlaceholder: 'http://localhost:1455/auth/callback?code=...',
confirm: 'Confirm',
verifying: 'Verifying...',
diff --git a/dashboard/frontend/src/i18n/locales/es/index.ts b/dashboard/frontend/src/i18n/locales/es/index.ts
index 721276f4..3d11bf64 100644
--- a/dashboard/frontend/src/i18n/locales/es/index.ts
+++ b/dashboard/frontend/src/i18n/locales/es/index.ts
@@ -169,7 +169,9 @@ const translations = {
memory: 'Memoria',
knowledge: 'Conocimiento',
heartbeats: 'Heartbeats',
+ projects: 'Proyectos',
goals: 'Metas',
+ kanban: 'Kanban',
tasks: 'Tareas',
triggers: 'Disparadores',
routines: 'Rutinas',
@@ -1029,7 +1031,7 @@ const translations = {
tabDevice: 'Autenticación por dispositivo',
generateLink: 'Generar enlace de autenticación',
step1Open: '1. Abre este enlace para iniciar sesión',
- step2Paste: '2. Autoriza el acceso y pega aquí la URL de callback',
+ step2Paste: '2. Autoriza el acceso y pega aquí la URL final con localhost. No cambies localhost por tu dominio',
callbackPlaceholder: 'http://localhost:1455/auth/callback?code=...',
confirm: 'Confirmar',
verifying: 'Verificando...',
diff --git a/dashboard/frontend/src/i18n/locales/pt-BR/index.ts b/dashboard/frontend/src/i18n/locales/pt-BR/index.ts
index 941c612f..ec34c343 100644
--- a/dashboard/frontend/src/i18n/locales/pt-BR/index.ts
+++ b/dashboard/frontend/src/i18n/locales/pt-BR/index.ts
@@ -176,7 +176,9 @@ const translations = {
memory: 'Memória',
knowledge: 'Conhecimento',
heartbeats: 'Heartbeats',
+ projects: 'Projetos',
goals: 'Metas',
+ kanban: 'Kanban',
tasks: 'Tarefas',
triggers: 'Gatilhos',
routines: 'Rotinas',
@@ -1036,7 +1038,7 @@ const translations = {
tabDevice: 'Autenticação por dispositivo',
generateLink: 'Gerar link de autenticação',
step1Open: '1. Abra este link para fazer login',
- step2Paste: '2. Autorize o acesso e cole aqui a URL de callback',
+ step2Paste: '2. Autorize o acesso e cole aqui a URL final com localhost. Não troque localhost pelo domínio',
callbackPlaceholder: 'http://localhost:1455/auth/callback?code=...',
confirm: 'Confirmar',
verifying: 'Verificando...',
diff --git a/dashboard/frontend/src/lib/api.ts b/dashboard/frontend/src/lib/api.ts
index 213989d4..efc8c44a 100644
--- a/dashboard/frontend/src/lib/api.ts
+++ b/dashboard/frontend/src/lib/api.ts
@@ -15,7 +15,20 @@ async function buildError(res: Response): Promise {
let detail = ''
try {
const data = await res.clone().json()
- detail = data?.error || data?.description || data?.message || ''
+ // Try common error shapes first, then plugin-preview-shaped responses
+ // (`{conflicts: [...], manifest, ...}`). Without this, plugin install
+ // 409s surfaced as "409 CONFLICT" with no hint at the actual reason
+ // (e.g. version mismatch).
+ detail =
+ data?.error ||
+ data?.description ||
+ data?.message ||
+ (Array.isArray(data?.conflicts) && data.conflicts.length > 0
+ ? data.conflicts.join(' • ')
+ : '') ||
+ (Array.isArray(data?.details) && data.details.length > 0
+ ? data.details.join(' • ')
+ : '')
} catch {
try {
const text = await res.text()
@@ -73,11 +86,12 @@ export const api = {
if (!res.ok) throw await buildError(res);
return res.json();
},
- delete: async (path: string) => {
+ delete: async (path: string, body?: unknown) => {
const res = await fetch(`${API}/api${path}`, {
method: 'DELETE',
- headers: { ...XHR_HEADER },
+ headers: { 'Content-Type': 'application/json', ...XHR_HEADER },
credentials: 'include',
+ body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) throw await buildError(res);
return res.json();
diff --git a/dashboard/frontend/src/pages/Agents.tsx b/dashboard/frontend/src/pages/Agents.tsx
index b849f28e..5d1abf88 100644
--- a/dashboard/frontend/src/pages/Agents.tsx
+++ b/dashboard/frontend/src/pages/Agents.tsx
@@ -32,6 +32,30 @@ import {
import { api } from '../lib/api'
import { AgentAvatar } from '../components/AgentAvatar'
+// Terminal-server URL for the HUD status poll — same proxy pattern as
+// AgentDetail.tsx: go through the dashboard's /terminal proxy in
+// production (CSP blocks direct cross-port fetches), direct port in Vite
+// dev (no proxy mounted there).
+const isViteDev = import.meta.env.DEV
+const TS_HTTP = isViteDev
+ ? `http://${window.location.hostname}:32352`
+ : `${window.location.origin}/terminal`
+
+interface HudSession {
+ sessionId: string
+ agent: string
+ busy: boolean
+ heavy: boolean
+ providerId: string
+ providerModel: string
+ tokensPerSec: number
+}
+
+interface HudAgentStatus {
+ busy: boolean
+ heavy: boolean
+}
+
type Category = 'business' | 'engineering' | 'custom'
type EngTier = 'reasoning' | 'execution' | 'speed'
@@ -292,7 +316,34 @@ function formatAgentName(name: string): string {
.join(' ')
}
-function AgentCard({ agent, isRunning }: { agent: Agent; isRunning: boolean }) {
+// 3-dot semaphore mirroring the Terminal HUD's (AgentTerminal.tsx): green
+// idle, yellow working, red heavy context/tokens on the last turn.
+function HudSemaphore({ status }: { status: HudAgentStatus }) {
+ const activeIdx = status.heavy ? 2 : status.busy ? 1 : 0
+ return (
+
+ {hudStatus && }
{isRunning && (
@@ -641,6 +693,7 @@ export default function Agents() {
const [agents, setAgents] = useState([])
const [loading, setLoading] = useState(true)
const [runningAgents, setRunningAgents] = useState([])
+ const [hudSessions, setHudSessions] = useState([])
const [filter, setFilter] = useState('all')
const [query, setQuery] = useState('')
const [recentNames] = useState(getRecentAgents)
@@ -668,6 +721,43 @@ export default function Agents() {
return () => clearInterval(interval)
}, [])
+ // Poll opencode terminal HUD status every 4 seconds — reflects the same
+ // semaphore shown inside an open terminal tab (see AgentTerminal.tsx),
+ // aggregated here so a superadmin can see who's working without opening
+ // any single terminal. Only opencode sessions report this (see
+ // server.js's /api/sessions/hud-status) — other providers don't track
+ // turn boundaries yet.
+ useEffect(() => {
+ const fetchHud = () => {
+ fetch(`${TS_HTTP}/api/sessions/hud-status`, { credentials: 'include' })
+ .then((r) => r.json())
+ .then((data) => setHudSessions(data.sessions || []))
+ .catch(() => {})
+ }
+ fetchHud()
+ const interval = setInterval(fetchHud, 4000)
+ return () => clearInterval(interval)
+ }, [])
+
+ // One entry per agent with at least one live opencode session — heavy
+ // wins over busy if the agent has multiple sessions in different states.
+ const hudByAgent = useMemo(() => {
+ const map: Record = {}
+ for (const s of hudSessions) {
+ const prev = map[s.agent]
+ map[s.agent] = {
+ busy: (prev?.busy || s.busy),
+ heavy: (prev?.heavy || s.heavy),
+ }
+ }
+ return map
+ }, [hudSessions])
+
+ const workingAgentNames = useMemo(
+ () => Object.entries(hudByAgent).filter(([, st]) => st.busy).map(([name]) => name),
+ [hudByAgent]
+ )
+
const totalMemories = agents.reduce((sum, a) => sum + a.memory_count, 0)
const activeCount = agents.filter((a) => a.memory_count > 0).length
@@ -742,6 +832,28 @@ export default function Agents() {
{/* Stats bar */}
{!loading && agents.length > 0 && (
+ {/* Superadmin-facing summary: exactly how many + which agents
+ have a live opencode terminal session mid-turn right now
+ (see hudByAgent above — sourced from the same semaphore
+ shown inside each terminal tab). Only rendered once at
+ least one agent is actually working, so it doesn't add
+ permanent noise to an otherwise idle dashboard. */}
+ {workingAgentNames.length > 0 && (
+
2. Authorize access, then copy the URL from the error page:
+
2. Authorize access, then paste the final localhost callback URL here. Do not replace localhost with your domain.
setCallbackUrl(e.target.value)}
placeholder="http://localhost:1455/auth/callback?code=..."
className={inp} autoComplete="off" />
diff --git a/dashboard/frontend/src/pages/Routines.tsx b/dashboard/frontend/src/pages/Routines.tsx
index 0d96fdef..e036b3a5 100644
--- a/dashboard/frontend/src/pages/Routines.tsx
+++ b/dashboard/frontend/src/pages/Routines.tsx
@@ -88,12 +88,22 @@ function transformRoutineMetrics(data: any): Routine[] {
return Object.entries(metrics).map(([name, m]: [string, any]) => {
const runs = Number(m.runs || 0)
const successes = Number(m.successes || 0)
- const successRate = Number(m.success_rate || (runs > 0 ? (successes / runs) * 100 : 0))
+ // Always derived fresh from raw counts, never trusting the stored
+ // success_rate field directly — one routine (uso_modelos_dia) used to
+ // write it as a 0-1 fraction instead of the 0-100 percentage every
+ // other routine's metrics use (ADWs/runner.py), which silently rendered
+ // as "0.8333% success" / misclassified as critical instead of 83.3% /
+ // healthy. Deriving from runs/successes here is immune to any producer
+ // writing success_rate in the wrong units again.
+ const successRate = runs > 0 ? (successes / runs) * 100 : 0
const totalTokens = Number(m.total_input_tokens || 0) + Number(m.total_output_tokens || 0)
const totalCost = Number(m.total_cost_usd || 0)
const avgCost = Number(m.avg_cost_usd || 0)
const avgSeconds = Number(m.avg_seconds || 0)
+ const lastSuccess = typeof m.last_success === 'boolean' ? m.last_success : null
const status: 'healthy' | 'warning' | 'critical' =
+ lastSuccess === false ? 'critical' :
+ lastSuccess === true && successRate < 90 ? 'warning' :
successRate >= 90 ? 'healthy' : successRate >= 70 ? 'warning' : 'critical'
return {
name,
diff --git a/dashboard/frontend/src/pages/onboarding/StepBrainChoose.tsx b/dashboard/frontend/src/pages/onboarding/StepBrainChoose.tsx
index c66fd560..a50f066f 100644
--- a/dashboard/frontend/src/pages/onboarding/StepBrainChoose.tsx
+++ b/dashboard/frontend/src/pages/onboarding/StepBrainChoose.tsx
@@ -33,12 +33,12 @@ export default function StepBrainChoose({ token, onNext, onBack }: StepBrainChoo
useEffect(() => {
if (mode === 'existing') {
setLoadingRepos(true)
- api.get('/brain-repo/detect')
+ api.get(`/brain-repo/detect?token=${encodeURIComponent(token)}`)
.then((data: { repos: Repo[] }) => setRepos(data.repos || []))
.catch(() => setRepos([]))
.finally(() => setLoadingRepos(false))
}
- }, [mode])
+ }, [mode, token])
const handleSave = async () => {
setError('')
diff --git a/dashboard/terminal-server/package-lock.json b/dashboard/terminal-server/package-lock.json
index 0611b4de..09b58b07 100644
--- a/dashboard/terminal-server/package-lock.json
+++ b/dashboard/terminal-server/package-lock.json
@@ -12,6 +12,8 @@
"@anthropic-ai/claude-agent-sdk": "0.2.119",
"cors": "^2.8.5",
"express": "^4.19.2",
+ "marked": "^15.0.12",
+ "marked-terminal": "^7.3.0",
"node-pty": "^1.0.0",
"uuid": "^10.0.0",
"ws": "^8.18.0"
@@ -182,6 +184,16 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@colors/colors": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz",
+ "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=0.1.90"
+ }
+ },
"node_modules/@hono/node-server": {
"version": "1.19.14",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
@@ -519,6 +531,18 @@
"node": ">= 0.6"
}
},
+ "node_modules/@sindresorhus/is": {
+ "version": "4.6.0",
+ "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz",
+ "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sindresorhus/is?sponsor=1"
+ }
+ },
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -565,6 +589,54 @@
}
}
},
+ "node_modules/ansi-escapes": {
+ "version": "7.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz",
+ "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==",
+ "license": "MIT",
+ "dependencies": {
+ "environment": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
+ "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-regex?sponsor=1"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/any-promise": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
+ "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
+ "license": "MIT"
+ },
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@@ -663,6 +735,108 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/chalk": {
+ "version": "5.6.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
+ "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
+ "license": "MIT",
+ "engines": {
+ "node": "^12.17.0 || ^14.13 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/char-regex": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz",
+ "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/cli-highlight": {
+ "version": "2.1.11",
+ "resolved": "https://registry.npmjs.org/cli-highlight/-/cli-highlight-2.1.11.tgz",
+ "integrity": "sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg==",
+ "license": "ISC",
+ "dependencies": {
+ "chalk": "^4.0.0",
+ "highlight.js": "^10.7.1",
+ "mz": "^2.4.0",
+ "parse5": "^5.1.1",
+ "parse5-htmlparser2-tree-adapter": "^6.0.0",
+ "yargs": "^16.0.0"
+ },
+ "bin": {
+ "highlight": "bin/highlight"
+ },
+ "engines": {
+ "node": ">=8.0.0",
+ "npm": ">=5.0.0"
+ }
+ },
+ "node_modules/cli-highlight/node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/cli-table3": {
+ "version": "0.6.5",
+ "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.5.tgz",
+ "integrity": "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==",
+ "license": "MIT",
+ "dependencies": {
+ "string-width": "^4.2.0"
+ },
+ "engines": {
+ "node": "10.* || >= 12.*"
+ },
+ "optionalDependencies": {
+ "@colors/colors": "1.5.0"
+ }
+ },
+ "node_modules/cliui": {
+ "version": "7.0.4",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz",
+ "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==",
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^4.2.0",
+ "strip-ansi": "^6.0.0",
+ "wrap-ansi": "^7.0.0"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "license": "MIT"
+ },
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -778,6 +952,18 @@
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
+ "node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "license": "MIT"
+ },
+ "node_modules/emojilib": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/emojilib/-/emojilib-2.4.0.tgz",
+ "integrity": "sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw==",
+ "license": "MIT"
+ },
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
@@ -787,6 +973,18 @@
"node": ">= 0.8"
}
},
+ "node_modules/environment": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz",
+ "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@@ -817,6 +1015,15 @@
"node": ">= 0.4"
}
},
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
@@ -984,6 +1191,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/get-caller-file": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+ "license": "ISC",
+ "engines": {
+ "node": "6.* || 8.* || >= 10.*"
+ }
+ },
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@@ -1033,6 +1249,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
@@ -1057,6 +1282,15 @@
"node": ">= 0.4"
}
},
+ "node_modules/highlight.js": {
+ "version": "10.7.3",
+ "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz",
+ "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": "*"
+ }
+ },
"node_modules/hono": {
"version": "4.12.15",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.15.tgz",
@@ -1122,6 +1356,15 @@
"node": ">= 0.10"
}
},
+ "node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/is-promise": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
@@ -1168,6 +1411,39 @@
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
"license": "BSD-2-Clause"
},
+ "node_modules/marked": {
+ "version": "15.0.12",
+ "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz",
+ "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==",
+ "license": "MIT",
+ "bin": {
+ "marked": "bin/marked.js"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/marked-terminal": {
+ "version": "7.3.0",
+ "resolved": "https://registry.npmjs.org/marked-terminal/-/marked-terminal-7.3.0.tgz",
+ "integrity": "sha512-t4rBvPsHc57uE/2nJOLmMbZCQ4tgAccAED3ngXQqW6g+TxA488JzJ+FK3lQkzBQOI1mRV/r/Kq+1ZlJ4D0owQw==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-escapes": "^7.0.0",
+ "ansi-regex": "^6.1.0",
+ "chalk": "^5.4.1",
+ "cli-highlight": "^2.1.11",
+ "cli-table3": "^0.6.5",
+ "node-emoji": "^2.2.0",
+ "supports-hyperlinks": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ },
+ "peerDependencies": {
+ "marked": ">=1 <16"
+ }
+ },
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -1243,6 +1519,17 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
+ "node_modules/mz": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
+ "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
+ "license": "MIT",
+ "dependencies": {
+ "any-promise": "^1.0.0",
+ "object-assign": "^4.0.1",
+ "thenify-all": "^1.0.0"
+ }
+ },
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@@ -1258,6 +1545,21 @@
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"license": "MIT"
},
+ "node_modules/node-emoji": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-2.2.0.tgz",
+ "integrity": "sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw==",
+ "license": "MIT",
+ "dependencies": {
+ "@sindresorhus/is": "^4.6.0",
+ "char-regex": "^1.0.2",
+ "emojilib": "^2.4.0",
+ "skin-tone": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/node-pty": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz",
@@ -1310,6 +1612,27 @@
"wrappy": "1"
}
},
+ "node_modules/parse5": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-5.1.1.tgz",
+ "integrity": "sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==",
+ "license": "MIT"
+ },
+ "node_modules/parse5-htmlparser2-tree-adapter": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-6.0.1.tgz",
+ "integrity": "sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==",
+ "license": "MIT",
+ "dependencies": {
+ "parse5": "^6.0.1"
+ }
+ },
+ "node_modules/parse5-htmlparser2-tree-adapter/node_modules/parse5": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz",
+ "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==",
+ "license": "MIT"
+ },
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -1411,6 +1734,15 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/require-directory": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+ "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@@ -1639,6 +1971,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/skin-tone": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/skin-tone/-/skin-tone-2.0.0.tgz",
+ "integrity": "sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA==",
+ "license": "MIT",
+ "dependencies": {
+ "unicode-emoji-modifier-base": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -1648,6 +1992,90 @@
"node": ">= 0.8"
}
},
+ "node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-ansi/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/supports-hyperlinks": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-3.2.0.tgz",
+ "integrity": "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==",
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0",
+ "supports-color": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=14.18"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/supports-hyperlinks?sponsor=1"
+ }
+ },
+ "node_modules/thenify": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
+ "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
+ "license": "MIT",
+ "dependencies": {
+ "any-promise": "^1.0.0"
+ }
+ },
+ "node_modules/thenify-all": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
+ "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
+ "license": "MIT",
+ "dependencies": {
+ "thenify": ">= 3.1.0 < 4"
+ },
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -1676,6 +2104,15 @@
"node": ">= 0.6"
}
},
+ "node_modules/unicode-emoji-modifier-base": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unicode-emoji-modifier-base/-/unicode-emoji-modifier-base-1.0.0.tgz",
+ "integrity": "sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@@ -1731,6 +2168,23 @@
"node": ">= 8"
}
},
+ "node_modules/wrap-ansi": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
@@ -1758,6 +2212,42 @@
}
}
},
+ "node_modules/y18n": {
+ "version": "5.0.8",
+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+ "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/yargs": {
+ "version": "16.2.2",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.2.tgz",
+ "integrity": "sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w==",
+ "license": "MIT",
+ "dependencies": {
+ "cliui": "^7.0.2",
+ "escalade": "^3.1.1",
+ "get-caller-file": "^2.0.5",
+ "require-directory": "^2.1.1",
+ "string-width": "^4.2.0",
+ "y18n": "^5.0.5",
+ "yargs-parser": "^20.2.2"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/yargs-parser": {
+ "version": "20.2.9",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz",
+ "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/zod": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
diff --git a/dashboard/terminal-server/package.json b/dashboard/terminal-server/package.json
index aa1ead35..fa2b4175 100644
--- a/dashboard/terminal-server/package.json
+++ b/dashboard/terminal-server/package.json
@@ -17,6 +17,8 @@
"@anthropic-ai/claude-agent-sdk": "0.2.119",
"cors": "^2.8.5",
"express": "^4.19.2",
+ "marked": "^15.0.12",
+ "marked-terminal": "^7.3.0",
"node-pty": "^1.0.0",
"uuid": "^10.0.0",
"ws": "^8.18.0"
diff --git a/dashboard/terminal-server/src/chat-bridge.js b/dashboard/terminal-server/src/chat-bridge.js
index caf138b1..52135c08 100644
--- a/dashboard/terminal-server/src/chat-bridge.js
+++ b/dashboard/terminal-server/src/chat-bridge.js
@@ -9,12 +9,196 @@ const os = require('os');
const {
loadProviderConfig,
resolveProviderModel,
+ getProviderMode,
} = require('./provider-config');
let sdkModule = null;
// Workspace root is three levels up from this file (dashboard/terminal-server/src/).
const WORKSPACE_ROOT = path.resolve(__dirname, '..', '..', '..');
+// Chat HUD (car-dashboard panel, same component as the Terminal — see
+// TerminalHudPanel.tsx). Chat sessions are short-lived (one per
+// startSession call, not a persistent REPL like claude-bridge's opencode
+// sessions), so this only tracks what's needed for the shift-detection
+// diff between hud_update ticks — much smaller than claude-bridge's
+// per-session HUD bookkeeping.
+const CHAT_HUD_HEAVY_TOKEN_THRESHOLD = 20_000;
+
+/**
+ * Emits a `hud_update`-shaped event through the same `onMessage` channel
+ * every other chat event goes through — server.js already forwards
+ * anything unrecognized as `{ type: 'chat_event', event: msg }`, and
+ * AgentChat.tsx's handleChatEvent special-cases `hud_update` before it
+ * hits the messages-array reducer (see that file). No server.js changes
+ * needed; this piggybacks on existing plumbing.
+ */
+function _emitChatHud(session, onMessage, patch = {}) {
+ if (!onMessage) return;
+ const providerId = patch.providerId ?? session.lastHudProviderId;
+ const providerModel = patch.providerModel ?? session.lastHudProviderModel;
+ const shift = providerId !== session.lastHudProviderId || providerModel !== session.lastHudProviderModel;
+ session.lastHudProviderId = providerId;
+ session.lastHudProviderModel = providerModel;
+ onMessage({
+ type: 'hud_update',
+ busy: false,
+ tokensPerSec: 0,
+ totalTokens: null,
+ heavy: false,
+ bestTokensPerSec: 0,
+ ...patch,
+ providerId,
+ providerModel,
+ shift,
+ });
+}
+
+/**
+ * Build provider fallback chain from config.
+ * Returns array of { providerId, model, cliCommand, envVars, baseUrl } attempts.
+ * Order: active provider's primary model → fallback_models → fallback_providers (with their chains).
+ */
+function buildProviderFallbackChain(providerConfig) {
+ const providers = providerConfig.providers || {};
+ const activeId = providerConfig.active;
+ const activeProvider = providers[activeId] || providerConfig;
+
+ const chain = [];
+
+ // Primary model from active provider
+ const primaryModel = resolveProviderModel(activeProvider);
+ const activeCliCommand = activeProvider.cli_command || providerConfig.cli_command || 'openclaude';
+ const activeEnvVars = { ...(activeProvider.env_vars || {}), ...(providerConfig.env_vars || {}) };
+ const activeBaseUrl = activeEnvVars.OPENAI_BASE_URL || activeProvider.default_base_url;
+
+ if (primaryModel) {
+ chain.push({
+ providerId: activeId,
+ model: primaryModel,
+ cliCommand: activeCliCommand,
+ envVars: { ...activeEnvVars, OPENAI_MODEL: primaryModel },
+ baseUrl: activeBaseUrl,
+ });
+ }
+
+ // Fallback models within same provider
+ for (const fallbackModel of (activeProvider.fallback_models || [])) {
+ if (fallbackModel && fallbackModel !== primaryModel) {
+ chain.push({
+ providerId: activeId,
+ model: fallbackModel,
+ cliCommand: activeCliCommand,
+ envVars: { ...activeEnvVars, OPENAI_MODEL: fallbackModel },
+ baseUrl: activeBaseUrl,
+ });
+ }
+ }
+
+ // Fallback providers
+ for (const fallbackProviderId of (activeProvider.fallback_providers || [])) {
+ const fp = providers[fallbackProviderId];
+ if (!fp) continue;
+
+ const fpModel = resolveProviderModel(fp);
+ const fpCliCommand = fp.cli_command || 'openclaude';
+ // Somente o env do próprio provider de fallback — misturar o env do
+ // provider ativo faria o attempt reutilizar a base URL/key erradas
+ // (ex.: fallback "omnirouter" chamando a NVIDIA de novo).
+ const fpEnvVars = { ...(fp.env_vars || {}) };
+ const fpBaseUrl = fpEnvVars.OPENAI_BASE_URL || fp.default_base_url;
+
+ if (fpModel) {
+ chain.push({
+ providerId: fallbackProviderId,
+ model: fpModel,
+ cliCommand: fpCliCommand,
+ envVars: { ...fpEnvVars, OPENAI_MODEL: fpModel },
+ baseUrl: fpBaseUrl,
+ });
+ }
+ for (const fbModel of (fp.fallback_models || [])) {
+ if (fbModel && fbModel !== fpModel) {
+ chain.push({
+ providerId: fallbackProviderId,
+ model: fbModel,
+ cliCommand: fpCliCommand,
+ envVars: { ...fpEnvVars, OPENAI_MODEL: fbModel },
+ baseUrl: fpBaseUrl,
+ });
+ }
+ }
+ }
+
+ // Final fallback: anthropic (native claude)
+ if (activeId !== 'anthropic') {
+ chain.push({
+ providerId: 'anthropic',
+ model: null,
+ cliCommand: 'claude',
+ envVars: {},
+ baseUrl: null,
+ });
+ }
+
+ return chain;
+}
+
+/**
+ * CLIs that implement the Agent SDK's subprocess wire protocol — the SDK
+ * always spawns pathToClaudeCodeExecutable with its own flags
+ * (--input-format stream-json --output-format stream-json --verbose,
+ * --permission-prompt-tool stdio, etc.) and talks to it over stdin/stdout
+ * with a control_request/control_response JSON protocol (see
+ * ProcessTransport in @anthropic-ai/claude-agent-sdk/sdk.mjs). claude and
+ * openclaude both speak that protocol; opencode does not — it has its own
+ * CLI shape (`opencode run -m / --format json`) and
+ * TUI, unrelated to Claude Code's. Spawning opencode through the SDK fails
+ * immediately (unrecognized args, no control protocol reply) — that's the
+ * "exit code 1 / chat does nothing" failure mode. Chat can't drive opencode
+ * this way (yet — provider_fallback.py's headless "opencode run" +
+ * ndjson-parsing and the Terminal's native PTY spawn are the two paths that
+ * do work today), so any attempt using a non-SDK CLI is skipped here and the
+ * configured fallback_providers carry the conversation instead.
+ */
+const SDK_COMPATIBLE_CLI = new Set(['claude', 'openclaude']);
+
+/**
+ * Check if an error is a retryable provider error (429, 503, quota, capacity).
+ * Includes detection for "Maximum combo retry limit reached".
+ */
+function isRetryableProviderError(error) {
+ if (!error) return false;
+ const msg = String(error.message || error).toLowerCase();
+
+ // "Maximum combo retry limit reached" — provider exhausted internal retries
+ if (msg.includes('maximum combo retry limit reached')) return true;
+
+ // Standard retryable patterns
+ const retryablePatterns = [
+ '429', 'rate limit', 'rate-limit', 'quota', 'too many requests',
+ 'resource exhausted', 'capacity', 'overloaded',
+ 'service unavailable', 'temporarily unavailable',
+ 'insufficient quota', 'billing limit', 'plan limit'
+ ];
+
+ return retryablePatterns.some(p => msg.includes(p));
+}
+
+/**
+ * Check if an error is fatal (auth/config) and should NOT trigger fallback.
+ */
+function isFatalProviderError(error) {
+ if (!error) return false;
+ const msg = String(error.message || error).toLowerCase();
+
+ const fatalPatterns = [
+ '401', '403', 'invalid api key', 'authentication',
+ 'unauthorized', 'forbidden', 'no usable api key'
+ ];
+
+ return fatalPatterns.some(p => msg.includes(p));
+}
+
/**
* Read chat.trustMode from config/workspace.yaml.
* Uses a targeted regex — no YAML dep needed.
@@ -43,6 +227,37 @@ const NEEDS_APPROVAL = new Set([
'Write', 'Edit', 'Bash', 'NotebookEdit', 'Agent',
]);
+/**
+ * Read an agent's persistent memory (.claude/agent-memory/{agent}/), if any.
+ *
+ * Native Claude Code sessions get CLAUDE.md + .claude/rules/*.md auto-loaded
+ * via the 'claude_code' systemPrompt preset, including memory-recall.md,
+ * which tells the agent to self-serve read its own agent-memory folder.
+ * External providers replace the system prompt entirely (see the
+ * isExternalProvider branch below), so they never get that instruction —
+ * every session starts cold. Mirrors ClaudeBridge.loadAgentMemory in
+ * claude-bridge.js (Terminal); kept as a separate copy since these two
+ * files don't share a util module today.
+ */
+function loadAgentMemory(agent) {
+ if (!agent) return '';
+ const dir = path.join(WORKSPACE_ROOT, '.claude', 'agent-memory', agent);
+ const parts = [];
+ for (const file of ['learnings.md', 'MEMORY.md']) {
+ try {
+ const content = fs.readFileSync(path.join(dir, file), 'utf8').trim();
+ if (content) parts.push(`### ${file}\n${content}`);
+ } catch {
+ // File doesn't exist yet — this agent has no persisted memory. Fine.
+ }
+ }
+ if (!parts.length) return '';
+ const MAX_CHARS = 4000;
+ let combined = parts.join('\n\n');
+ if (combined.length > MAX_CHARS) combined = combined.slice(-MAX_CHARS);
+ return combined;
+}
+
/**
* Parse a .claude/agents/{name}.md file into an AgentDefinition.
* Extracts YAML frontmatter for metadata and the body as the prompt.
@@ -157,6 +372,116 @@ function resolveClaudeExecutable() {
return _claudeExecutablePath;
}
+/**
+ * Resolve the CLI binary for external (non-Anthropic) providers.
+ * Mirrors ClaudeBridge.findClaudeCommand: shell `which` first, then hardcoded
+ * paths. Returns null when nothing is found — the SDK needs a real path, so
+ * the caller falls back to the plain chat-completion session.
+ * Hardcoded dispatch per command to satisfy semgrep.
+ */
+const _cliBinaryCache = new Map();
+function resolveCliBinary(cliCommand) {
+ if (_cliBinaryCache.has(cliCommand)) return _cliBinaryCache.get(cliCommand);
+
+ const { execSync } = require('child_process');
+ try {
+ // Hardcoded dispatch per command (not a template) to satisfy semgrep,
+ // mirroring ClaudeBridge.findClaudeCommand.
+ let resolved;
+ if (cliCommand === 'openclaude') {
+ resolved = execSync('which openclaude', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] }).trim();
+ } else if (cliCommand === 'opencode') {
+ resolved = execSync('which opencode', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] }).trim();
+ } else {
+ resolved = execSync('which claude', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] }).trim();
+ }
+ if (resolved) {
+ console.log(`[chat-bridge] Found ${cliCommand} at: ${resolved}`);
+ _cliBinaryCache.set(cliCommand, resolved);
+ return resolved;
+ }
+ } catch { /* which failed — try hardcoded paths */ }
+
+ const home = process.env.HOME || '/';
+ let candidates;
+ if (cliCommand === 'openclaude') {
+ candidates = [
+ path.join(home, '.local', 'bin', 'openclaude'),
+ '/usr/local/bin/openclaude',
+ '/usr/bin/openclaude',
+ ];
+ } else if (cliCommand === 'opencode') {
+ candidates = [
+ path.join(home, '.local', 'bin', 'opencode'),
+ '/usr/local/bin/opencode',
+ '/usr/bin/opencode',
+ ];
+ } else {
+ candidates = [
+ path.join(home, '.claude', 'local', 'claude'),
+ path.join(home, '.local', 'bin', 'claude'),
+ '/usr/local/bin/claude',
+ '/usr/bin/claude',
+ ];
+ }
+ for (const p of candidates) {
+ try {
+ if (fs.existsSync(p)) {
+ console.log(`[chat-bridge] Found ${cliCommand} at hardcoded path: ${p}`);
+ _cliBinaryCache.set(cliCommand, p);
+ return p;
+ }
+ } catch { continue; }
+ }
+
+ console.error(`[chat-bridge] ${cliCommand} not found anywhere`);
+ _cliBinaryCache.set(cliCommand, null);
+ return null;
+}
+
+/**
+ * Build the environment for an external-provider CLI process.
+ * Same approach as ClaudeBridge: whitelist essential system vars (no full
+ * process.env spread — stale OPENAI_API_KEY would override Codex OAuth) and
+ * layer the provider's env_vars on top. The SDK passes this object as the
+ * child process env verbatim.
+ */
+const PROVIDER_SYSTEM_VARS = [
+ 'HOME', 'USER', 'SHELL', 'PATH', 'LANG', 'LC_ALL', 'LC_CTYPE',
+ 'LOGNAME', 'HOSTNAME', 'XDG_RUNTIME_DIR', 'XDG_DATA_HOME',
+ 'XDG_CONFIG_HOME', 'XDG_CACHE_HOME', 'TMPDIR',
+ 'SSH_AUTH_SOCK', 'SSH_AGENT_PID',
+ 'NVM_DIR', 'NVM_BIN', 'NVM_INC',
+ 'CODEX_HOME', 'CLAUDE_CONFIG_DIR',
+ // Container marker exported by entrypoint.sh — required for the SDK CLI's
+ // --dangerously-skip-permissions/bypass mode to work as root.
+ 'IS_SANDBOX',
+];
+function buildProviderEnv(providerConfig) {
+ const env = {};
+ for (const key of PROVIDER_SYSTEM_VARS) {
+ if (process.env[key]) env[key] = process.env[key];
+ }
+ const providerEnv = { ...(providerConfig.env_vars || {}) };
+ // Same model defaults as ClaudeBridge — codexplan/codexspark route to the
+ // Codex backend; a raw gpt-5.x would silently bypass Codex OAuth.
+ if (!providerEnv.OPENAI_MODEL) {
+ if (providerConfig.active === 'codex_auth') providerEnv.OPENAI_MODEL = 'codexplan';
+ else if (providerConfig.active === 'openai') providerEnv.OPENAI_MODEL = 'gpt-4.1';
+ }
+ // O auto-updater do CLI migra a instalação npm pro instalador nativo no
+ // meio da sessão e mata o processo com exit 1.
+ // OPENCLAUDE_MAX_RETRIES: o default do CLI são 10 retries com backoff
+ // exponencial (até ~35s cada ≈ 2,5min por modelo) — quem rotaciona é a
+ // nossa fallback chain, então o retry interno fica curto.
+ return {
+ OPENCLAUDE_MAX_RETRIES: '3',
+ ...env,
+ ...providerEnv,
+ DISABLE_AUTOUPDATER: '1',
+ };
+}
+
/**
* Scan a tool_result text for a ticket-creation response.
* Returns the ticket id if a POST /api/tickets response is detected, else null.
@@ -241,6 +566,11 @@ function detectCreatedTicketId(text) {
class ChatBridge {
constructor() {
this.sessions = new Map(); // sessionId -> { query, abortController, active, sdkSessionId }
+ // Rate limiting state for OpenAI-compatible providers
+ this._lastRequestTime = 0;
+ this._minIntervalMs = parseInt(process.env.CHAT_MIN_INTERVAL_MS || '2000', 10);
+ this._maxRetries = parseInt(process.env.CHAT_MAX_RETRIES || '3', 10);
+ this._baseDelayMs = parseInt(process.env.CHAT_BASE_DELAY_MS || '5000', 10);
}
_buildChatCompletionSystemPrompt(agentName, cwd, sessionId) {
@@ -274,6 +604,70 @@ class ChatBridge {
return '';
}
+ /**
+ * Enforce minimum interval between requests, then fetch with retry/backoff.
+ * Retries on 429 (rate limited) and 503 (upstream unavailable).
+ * Does NOT retry internally on "Maximum combo retry limit reached" — lets
+ * outer fallback loop advance to next model/provider instead.
+ */
+ async _rateLimitedFetch(url, options) {
+ // Enforce minimum interval between requests
+ const now = Date.now();
+ const elapsed = now - this._lastRequestTime;
+ if (elapsed < this._minIntervalMs) {
+ const wait = this._minIntervalMs - elapsed;
+ console.log(`[chat-bridge] Rate limit: aguardando ${wait}ms antes do próximo request...`);
+ await new Promise((r) => setTimeout(r, wait));
+ }
+
+ for (let attempt = 0; attempt <= this._maxRetries; attempt++) {
+ this._lastRequestTime = Date.now();
+
+ const resp = await fetch(url, options);
+
+ if (resp.ok) return resp;
+
+ // Check for "Maximum combo retry limit reached" — provider-level
+ // combo exhaustion. Don't retry internally; let outer loop rotate
+ // to next model/provider in chain.
+ if (resp.status === 503) {
+ const bodyText = await resp.text().catch(() => '');
+ // resp.text() consumiu o body — qualquer retorno daqui pra frente
+ // precisa de um Response reconstruído pra o caller ler o erro.
+ const remake = () => new Response(bodyText, { status: resp.status, statusText: resp.statusText, headers: resp.headers });
+ if (bodyText.includes('Maximum combo retry limit reached')) {
+ console.warn(`[chat-bridge] Provider combo exhausted (503): ${bodyText.slice(0, 200)}`);
+ // Não faz retry interno — deixa o loop externo rotacionar o provider.
+ return remake();
+ }
+ if (attempt === this._maxRetries) return remake();
+ }
+
+ const isRetryable = resp.status === 429 || resp.status === 503;
+ if (!isRetryable || attempt === this._maxRetries) {
+ return resp; // let caller handle non-retryable errors or final failure
+ }
+
+ // Exponential backoff: base * 2^attempt (5s, 10s, 20s)
+ const delay = this._baseDelayMs * Math.pow(2, attempt);
+ let retryAfter = delay;
+ const retryAfterHeader = resp.headers.get('Retry-After');
+ if (retryAfterHeader) {
+ const parsed = parseInt(retryAfterHeader, 10);
+ if (!isNaN(parsed)) retryAfter = parsed * 1000;
+ }
+
+ console.warn(
+ `[chat-bridge] ${resp.status} no request (tentativa ${attempt + 1}/${this._maxRetries + 1}). ` +
+ `Aguardando ${Math.round(retryAfter / 1000)}s antes de tentar novamente...`
+ );
+ await new Promise((r) => setTimeout(r, retryAfter));
+ }
+
+ // Should not reach here, but return last error response
+ throw new Error(`_rateLimitedFetch: todas as ${this._maxRetries + 1} tentativas falharam`);
+ }
+
async _startOpenAICompatibleSession(sessionId, options, providerConfig) {
const {
agentName,
@@ -291,18 +685,6 @@ class ChatBridge {
const abortController = new AbortController();
const cwd = workingDir || process.cwd();
- const env = providerConfig.env_vars || {};
- const model = resolveProviderModel(providerConfig);
- const baseUrl = (env.OPENAI_BASE_URL || 'https://api.openai.com/v1').replace(/\/+$/, '');
- const apiKey = env.OPENAI_API_KEY || env.CODEX_API_KEY || '';
-
- if (!model) {
- throw new Error(`Provider "${providerConfig.active}" sem modelo configurado. Defina o campo Model em Providers.`);
- }
- if (!apiKey) {
- throw new Error(`Provider "${providerConfig.active}" sem API key configurada para Chat Completion.`);
- }
-
const runtimePrompt = this._buildChatCompletionSystemPrompt(agentName, cwd, sessionId);
let finalPrompt = prompt || '';
@@ -311,31 +693,81 @@ class ChatBridge {
finalPrompt += `\n\n[Attached files]\n${names}\n`;
}
+ const makeAttempts = () => {
+ const providers = providerConfig.providers || {};
+ const activeProvider = providers[providerConfig.active] || providerConfig;
+ const providerOrder = [
+ providerConfig.active,
+ ...(Array.isArray(providerConfig.fallback_providers) ? providerConfig.fallback_providers : []),
+ ].filter(Boolean);
+ const attempts = [];
+
+ for (const providerId of providerOrder) {
+ const p = providers[providerId] || (providerId === providerConfig.active ? activeProvider : null);
+ if (!p) continue;
+ const env = p.env_vars || {};
+ const baseUrl = (env.OPENAI_BASE_URL || 'https://api.openai.com/v1').replace(/\/+$/, '');
+ const apiKey = env.OPENAI_API_KEY || env.NVIDIA_API_KEY || env.CODEX_API_KEY || '';
+ const primaryModel = resolveProviderModel(p);
+ const models = [];
+ if (primaryModel) models.push(primaryModel);
+ for (const m of (Array.isArray(p.fallback_models) ? p.fallback_models : [])) {
+ if (m && !models.includes(m)) models.push(m);
+ }
+ for (const model of models) {
+ attempts.push({ providerId, baseUrl, apiKey, model });
+ }
+ }
+ return attempts;
+ };
+
+ const attempts = makeAttempts();
+ if (attempts.length === 0) {
+ throw new Error(`Provider "${providerConfig.active}" sem modelo configurado. Defina o campo Model em Providers.`);
+ }
+
const session = { active: true, abortController, sdkSessionId: null };
this.sessions.set(sessionId, session);
(async () => {
try {
- const resp = await fetch(`${baseUrl}/chat/completions`, {
- method: 'POST',
- headers: {
- Authorization: `Bearer ${apiKey}`,
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({
- model,
- stream: true,
- messages: [
- { role: 'system', content: runtimePrompt },
- { role: 'user', content: finalPrompt },
- ],
- }),
- signal: abortController.signal,
- });
+ let resp = null;
+ let selectedAttempt = null;
+ let lastBody = '';
+ for (const attempt of attempts) {
+ if (!attempt.apiKey) {
+ lastBody = `Provider "${attempt.providerId}" sem API key configurada para Chat Completion.`;
+ continue;
+ }
+ selectedAttempt = attempt;
+ console.log(`[chat-bridge] Chat fallback attempt ${attempt.providerId}:${attempt.model}`);
+ resp = await this._rateLimitedFetch(`${attempt.baseUrl}/chat/completions`, {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${attempt.apiKey}`,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ model: attempt.model,
+ stream: true,
+ messages: [
+ { role: 'system', content: runtimePrompt },
+ { role: 'user', content: finalPrompt },
+ ],
+ }),
+ signal: abortController.signal,
+ });
+
+ if (resp.ok && resp.body) break;
+
+ lastBody = await resp.text().catch(() => '');
+ if (![429, 503].includes(resp.status)) break;
+ console.warn(`[chat-bridge] ${resp.status} on ${attempt.providerId}:${attempt.model}; trying next fallback`);
+ }
- if (!resp.ok || !resp.body) {
- const body = await resp.text().catch(() => '');
- throw new Error(`Chat Completion falhou (${resp.status}): ${body.slice(0, 240)}`);
+ if (!resp || !resp.ok || !resp.body) {
+ const label = selectedAttempt ? `${selectedAttempt.providerId}:${selectedAttempt.model}` : providerConfig.active;
+ throw new Error(`Chat Completion falhou em ${label} (${resp?.status || 'no-response'}): ${lastBody.slice(0, 240)}`);
}
if (onMessage) {
@@ -408,8 +840,24 @@ class ChatBridge {
} = options;
const providerConfig = loadProviderConfig();
- if (providerConfig.active !== 'anthropic') {
- return this._startOpenAICompatibleSession(sessionId, options, providerConfig);
+ const isExternalProvider = !!providerConfig.active && providerConfig.active !== 'anthropic';
+ let externalCliPath = null;
+ if (isExternalProvider) {
+ // Code-mode providers go through the Agent SDK with the openclaude
+ // binary — full tool calling, streaming, session resume. Only genuinely
+ // chat-completion-only models keep the plain REST path.
+ if (getProviderMode(providerConfig) !== 'code') {
+ return this._startOpenAICompatibleSession(sessionId, options, providerConfig);
+ }
+ if (SDK_COMPATIBLE_CLI.has(providerConfig.cli_command)) {
+ externalCliPath = resolveCliBinary(providerConfig.cli_command || 'openclaude');
+ if (!externalCliPath) {
+ console.warn(`[chat-bridge] ${providerConfig.cli_command} binary not found — falling back to chat completion. Install with: npm install -g @gitlawb/openclaude`);
+ return this._startOpenAICompatibleSession(sessionId, options, providerConfig);
+ }
+ } else {
+ console.warn(`[chat-bridge] Active provider "${providerConfig.active}" uses cli_command "${providerConfig.cli_command}", which doesn't speak the Agent SDK protocol — Chat will use its fallback_providers instead`);
+ }
}
const { query: sdkQuery } = await loadSDK();
@@ -426,8 +874,14 @@ class ChatBridge {
abortController,
};
- const claudeExe = resolveClaudeExecutable();
- if (claudeExe) queryOptions.pathToClaudeCodeExecutable = claudeExe;
+ if (isExternalProvider && externalCliPath) {
+ queryOptions.pathToClaudeCodeExecutable = externalCliPath;
+ queryOptions.env = buildProviderEnv(providerConfig);
+ console.log(`[chat-bridge] External provider "${providerConfig.active}" via ${externalCliPath} (model: ${resolveProviderModel(providerConfig) || 'default'})`);
+ } else if (!isExternalProvider) {
+ const claudeExe = resolveClaudeExecutable();
+ if (claudeExe) queryOptions.pathToClaudeCodeExecutable = claudeExe;
+ }
// Load agent definition from .claude/agents/{name}.md
if (agentName) {
@@ -458,13 +912,30 @@ class ChatBridge {
if (systemPromptExtras && !sdkSessionId) {
promptAppend = promptAppend + '\n\n' + systemPromptExtras;
}
- queryOptions.systemPrompt = {
- type: 'preset',
- preset: 'claude_code',
- append: promptAppend,
- };
- if (agentDef.model) queryOptions.model = agentDef.model;
- console.log(`[chat-bridge] Loaded agent "${agentName}" via systemPrompt.append (${agentDef.prompt.length} chars, model: ${agentDef.model || 'inherit'})`);
+ if (isExternalProvider) {
+ // Mirror ClaudeBridge: --append-system-prompt is too weak for GPT
+ // models, so REPLACE the system prompt to force the agent persona.
+ // agentDef.model is a Claude model name — meaningless here; the
+ // model comes from the provider's OPENAI_MODEL env var.
+ const priorMemory = loadAgentMemory(agentName);
+ if (priorMemory) {
+ promptAppend += '\n\n## Previous Session Memory (yours — resume/summarize before acting)\n' + priorMemory;
+ }
+ queryOptions.systemPrompt = promptAppend + '\n\n' +
+ 'CRITICAL: You MUST fully embody this agent persona. ' +
+ 'You are NOT Claude, OpenClaude, or a generic assistant — you ARE ' + agentName + '. ' +
+ 'When asked who you are, ALWAYS respond as ' + agentName + '. ' +
+ 'Never break character. Follow ALL instructions above.';
+ console.log(`[chat-bridge] Loaded agent "${agentName}" via systemPrompt replace (${agentDef.prompt.length} chars, external provider)`);
+ } else {
+ queryOptions.systemPrompt = {
+ type: 'preset',
+ preset: 'claude_code',
+ append: promptAppend,
+ };
+ if (agentDef.model) queryOptions.model = agentDef.model;
+ console.log(`[chat-bridge] Loaded agent "${agentName}" via systemPrompt.append (${agentDef.prompt.length} chars, model: ${agentDef.model || 'inherit'})`);
+ }
} else {
console.warn(`[chat-bridge] Agent "${agentName}" not found, running without agent`);
}
@@ -585,75 +1056,254 @@ class ChatBridge {
abortController,
agentName,
sdkSessionId: sdkSessionId || null,
+ lastHudProviderId: null,
+ lastHudProviderModel: null,
};
this.sessions.set(sessionId, session);
- // Run query in background
- (async () => {
- try {
- console.log(`[chat-bridge] Starting query for session ${sessionId}, agent: ${agentName}, resume: ${sdkSessionId || 'new'}`);
- console.log(`[chat-bridge] Query options:`, JSON.stringify({ cwd: queryOptions.cwd, agent: queryOptions.agent, resume: queryOptions.resume, allowedTools: queryOptions.allowedTools?.length }, null, 2));
- const q = sdkQuery({ prompt: finalPrompt, options: queryOptions });
- console.log(`[chat-bridge] Query created, starting iteration...`);
+ // Build provider fallback chain for external providers
+ let fallbackChain = [];
+ let currentFallbackIndex = 0;
+ if (isExternalProvider) {
+ // Drop attempts whose cli_command can't speak the Agent SDK protocol
+ // (see SDK_COMPATIBLE_CLI) — e.g. opencode. buildProviderFallbackChain
+ // always appends a trailing native-anthropic entry when the active
+ // provider isn't anthropic, so this never filters down to empty.
+ fallbackChain = buildProviderFallbackChain(providerConfig)
+ .filter((attempt) => SDK_COMPATIBLE_CLI.has(attempt.cliCommand));
+ console.log(`[chat-bridge] Built fallback chain with ${fallbackChain.length} attempts:`,
+ fallbackChain.map(c => `${c.providerId}:${c.model || 'native'}`).join(' → '));
+ }
- for await (const message of q) {
- if (!session.active) break;
+ // Run query with provider fallback
+ const runQueryWithFallback = async () => {
+ while (currentFallbackIndex < (fallbackChain.length || 1)) {
+ if (!session.active) return;
+
+ // Apply current fallback config if using external provider
+ if (isExternalProvider && fallbackChain.length > 0) {
+ const attempt = fallbackChain[currentFallbackIndex];
+ console.log(`[chat-bridge] Attempt ${currentFallbackIndex + 1}/${fallbackChain.length}: ${attempt.providerId}:${attempt.model || 'native'}`);
+
+ // AbortController novo por attempt — abortar o anterior mata um
+ // child preso no backoff de retry sem derrubar a sessão nova.
+ if (currentFallbackIndex > 0) {
+ const freshAbort = new AbortController();
+ queryOptions.abortController = freshAbort;
+ session.abortController = freshAbort;
+ }
- const eventDetail = message.type === 'stream_event' ? ` event=${message.event?.type} cb=${message.event?.content_block?.type || message.event?.delta?.type || ''}` : '';
- if (message.type === 'system') {
- console.log(`[chat-bridge] System message: subtype=${message.subtype}, agent=${message.agent || 'none'}, data=${JSON.stringify(message).slice(0, 200)}`);
+ // Env do attempt montado do zero (vars de sistema + env do provider
+ // do attempt). NUNCA herdar o env do provider que falhou — uma
+ // OPENAI_BASE_URL/NVIDIA_API_KEY antiga sequestraria a chamada.
+ if (attempt.providerId === 'anthropic' && attempt.cliCommand === 'claude') {
+ // Claude nativo: env limpo, credenciais vêm de ~/.claude via HOME.
+ delete queryOptions.env;
} else {
- console.log(`[chat-bridge] Message received: type=${message.type}${eventDetail}`);
+ queryOptions.env = buildProviderEnv({
+ active: attempt.providerId,
+ env_vars: { ...attempt.envVars },
+ });
+ if (attempt.baseUrl) {
+ queryOptions.env.OPENAI_BASE_URL = attempt.baseUrl;
+ }
}
- // Capture SDK session ID from any message that has it
- if (message.session_id && !session.sdkSessionId) {
- session.sdkSessionId = message.session_id;
- if (onMessage) {
- onMessage({ type: 'session_id', sdkSessionId: message.session_id });
+ // Update CLI path if provider changed
+ if (attempt.cliCommand && attempt.cliCommand !== providerConfig.cli_command) {
+ const cliPath = resolveCliBinary(attempt.cliCommand);
+ if (cliPath) {
+ queryOptions.pathToClaudeCodeExecutable = cliPath;
}
}
- // Auto-detect ticket creation in tool_result blocks.
- if (message.type === 'user') {
- const content = message.message?.content || message.content;
- if (Array.isArray(content)) {
- for (const block of content) {
- if (block.type !== 'tool_result') continue;
- const raw = Array.isArray(block.content)
- ? block.content.map(c => (typeof c === 'string' ? c : c?.text || '')).join('\n')
- : (typeof block.content === 'string' ? block.content : '');
- const ticketId = detectCreatedTicketId(raw);
- if (ticketId) {
- console.log(`[chat-bridge] ✓ Detected ticket creation: ${ticketId} — auto-binding to session ${sessionId}`);
- if (onMessage) {
- onMessage({ type: 'ticket_detected', ticketId });
+ // Cada attempt começa uma run nova — não deixar o session_id da
+ // tentativa que falhou grudar no cliente.
+ session.sdkSessionId = sdkSessionId || null;
+ }
+
+ const hudProviderId = (isExternalProvider && fallbackChain.length > 0)
+ ? fallbackChain[currentFallbackIndex].providerId
+ : (providerConfig.active || 'anthropic');
+ const hudProviderModel = (isExternalProvider && fallbackChain.length > 0)
+ ? (fallbackChain[currentFallbackIndex].model || 'native')
+ : (resolveProviderModel(providerConfig) || 'default');
+ const turnStartedAt = Date.now();
+ _emitChatHud(session, onMessage, { busy: true, providerId: hudProviderId, providerModel: hudProviderModel });
+
+ let advanceAfterErrorResult = false;
+ try {
+ console.log(`[chat-bridge] Starting query for session ${sessionId}, agent: ${agentName}, resume: ${sdkSessionId || 'new'}`);
+ console.log(`[chat-bridge] Query options:`, JSON.stringify({ cwd: queryOptions.cwd, agent: queryOptions.agent, resume: queryOptions.resume, allowedTools: queryOptions.allowedTools?.length }, null, 2));
+ const q = sdkQuery({ prompt: finalPrompt, options: queryOptions });
+ console.log(`[chat-bridge] Query created, starting iteration...`);
+
+ for await (const message of q) {
+ if (!session.active) break;
+
+ const eventDetail = message.type === 'stream_event' ? ` event=${message.event?.type} cb=${message.event?.content_block?.type || message.event?.delta?.type || ''}` : '';
+ if (message.type === 'system') {
+ console.log(`[chat-bridge] System message: subtype=${message.subtype}, agent=${message.agent || 'none'}, data=${JSON.stringify(message).slice(0, 200)}`);
+ } else {
+ console.log(`[chat-bridge] Message received: type=${message.type}${eventDetail}`);
+ }
+
+ // O CLI faz retry interno com backoff exponencial e anuncia cada
+ // tentativa como system/api_retry. Em erro de capacidade (5xx/429)
+ // com fallback disponível, não vale esperar o backoff — corta e
+ // rotaciona o provider imediatamente.
+ if (message.type === 'system' && message.subtype === 'api_retry') {
+ const status = Number(message.error_status) || 0;
+ const attemptNum = Number(message.attempt) || 0;
+ const retryableStatus = status === 429 || (status >= 500 && status <= 599);
+ const hasMoreFallbacks = isExternalProvider &&
+ currentFallbackIndex < fallbackChain.length - 1;
+ if (hasMoreFallbacks && retryableStatus && attemptNum >= 2) {
+ console.warn(`[chat-bridge] api_retry ${attemptNum}x status=${status} on attempt ${currentFallbackIndex + 1} — advancing fallback chain early`);
+ advanceAfterErrorResult = true;
+ // Aborta ANTES do break: o child pode estar dormindo dezenas
+ // de segundos no backoff e o cleanup do iterator esperaria ele.
+ try { queryOptions.abortController.abort(); } catch {}
+ break;
+ }
+ }
+
+ // Erro de provider (503/429 do OmniRoute, "Maximum combo retry
+ // limit reached", etc.) NÃO vira exceção no Agent SDK — a query
+ // termina "normal" com um result de erro. Detectar aqui e avançar
+ // a cadeia de fallback em vez de entregar o erro pro chat.
+ if (message.type === 'result') {
+ // Real usage — same fields _transformMessage's 'result' case
+ // already reads (msg.usage, msg.duration_ms). tokensPerSec is
+ // a rough turn-average (total tokens / wall time), not a live
+ // streaming rate like the Terminal's opencode path gets from
+ // per-line NDJSON ticks — the Agent SDK doesn't expose partial
+ // usage, only the final total.
+ const usage = message.usage || {};
+ const totalTokens = (usage.input_tokens || 0) + (usage.output_tokens || 0) +
+ (usage.cache_creation_input_tokens || 0) + (usage.cache_read_input_tokens || 0);
+ const elapsedSec = Math.max(0.001, (Date.now() - turnStartedAt) / 1000);
+ _emitChatHud(session, onMessage, {
+ busy: false,
+ tokensPerSec: totalTokens > 0 ? Math.round(totalTokens / elapsedSec) : 0,
+ totalTokens: totalTokens || null,
+ heavy: totalTokens > CHAT_HUD_HEAVY_TOKEN_THRESHOLD,
+ });
+
+ const isErrorResult = message.is_error === true ||
+ (message.subtype && message.subtype !== 'success');
+ if (isErrorResult) {
+ const errText = [message.result, ...(Array.isArray(message.errors) ? message.errors : [])]
+ .filter((x) => typeof x === 'string' && x)
+ .join(' ') || JSON.stringify(message).slice(0, 500);
+ const hasMoreFallbacks = isExternalProvider &&
+ currentFallbackIndex < fallbackChain.length - 1;
+ if (hasMoreFallbacks && session.active && !isFatalProviderError(errText)) {
+ console.warn(`[chat-bridge] Error result (subtype=${message.subtype}) on attempt ${currentFallbackIndex + 1}: ${errText.slice(0, 300)} — advancing fallback chain`);
+ advanceAfterErrorResult = true;
+ break; // sai do for-await; o while tenta o próximo provider/modelo
+ }
+ }
+ }
+
+ // Capture SDK session ID from any message that has it
+ if (message.session_id && !session.sdkSessionId) {
+ session.sdkSessionId = message.session_id;
+ if (onMessage) {
+ onMessage({ type: 'session_id', sdkSessionId: message.session_id });
+ }
+ }
+
+ // Auto-detect ticket creation in tool_result blocks.
+ if (message.type === 'user') {
+ const content = message.message?.content || message.content;
+ if (Array.isArray(content)) {
+ for (const block of content) {
+ if (block.type !== 'tool_result') continue;
+ const raw = Array.isArray(block.content)
+ ? block.content.map(c => (typeof c === 'string' ? c : c?.text || '')).join('\n')
+ : (typeof block.content === 'string' ? block.content : '');
+ const ticketId = detectCreatedTicketId(raw);
+ if (ticketId) {
+ console.log(`[chat-bridge] ✓ Detected ticket creation: ${ticketId} — auto-binding to session ${sessionId}`);
+ if (onMessage) {
+ onMessage({ type: 'ticket_detected', ticketId });
+ }
}
}
}
}
- }
- if (onMessage) {
- onMessage(this._transformMessage(message));
+ if (onMessage) {
+ onMessage(this._transformMessage(message));
+ }
+ }
+ console.log(`[chat-bridge] Query iteration finished for session ${sessionId}`);
+
+ // Result de erro detectado no meio da iteração — tenta o próximo
+ // provider/modelo da cadeia em vez de encerrar a sessão.
+ if (advanceAfterErrorResult && session.active) {
+ // Mata o child da tentativa que falhou (pode estar dormindo no
+ // backoff de retry) — a próxima iteração cria um controller novo.
+ try { queryOptions.abortController.abort(); } catch {}
+ currentFallbackIndex++;
+ const next = fallbackChain[currentFallbackIndex];
+ console.warn(`[chat-bridge] Advancing to fallback ${currentFallbackIndex + 1}/${fallbackChain.length}: ${next.providerId}:${next.model || 'native'}`);
+ continue;
}
- }
- console.log(`[chat-bridge] Query iteration finished for session ${sessionId}`);
- session.active = false;
- this.sessions.delete(sessionId);
- if (onComplete) onComplete({ sdkSessionId: session.sdkSessionId });
- } catch (err) {
- console.error(`[chat-bridge] Error in session ${sessionId}:`, err.message || err);
- session.active = false;
- this.sessions.delete(sessionId);
- if (err.name === 'AbortError') {
+ session.active = false;
+ this.sessions.delete(sessionId);
if (onComplete) onComplete({ sdkSessionId: session.sdkSessionId });
- } else {
- if (onError) onError(err);
+ return; // Success - exit fallback loop
+
+ } catch (err) {
+ // Abort interno disparado pelo avanço da cadeia (api_retry) — não é
+ // cancelamento do usuário; segue pro próximo provider/modelo.
+ if (advanceAfterErrorResult && session.active) {
+ currentFallbackIndex++;
+ const next = fallbackChain[currentFallbackIndex];
+ console.warn(`[chat-bridge] Advancing to fallback ${currentFallbackIndex + 1}/${fallbackChain.length} after internal abort: ${next.providerId}:${next.model || 'native'}`);
+ continue;
+ }
+
+ console.error(`[chat-bridge] Error in session ${sessionId} (attempt ${currentFallbackIndex + 1}):`, err.message || err);
+
+ // Avança a cadeia em qualquer erro não-fatal enquanto houver
+ // fallback — erros do CLI chegam como "process exited with code N"
+ // sem o texto 503/429, então filtrar só por padrão retryable
+ // deixaria a sessão morrer exatamente no caso que queremos cobrir.
+ const isRetryable = isExternalProvider && isRetryableProviderError(err);
+ const isFatal = isExternalProvider && isFatalProviderError(err);
+ const isAbort = err.name === 'AbortError';
+
+ if (!isFatal && !isAbort && isExternalProvider && session.active &&
+ currentFallbackIndex < fallbackChain.length - 1) {
+ currentFallbackIndex++;
+ console.warn(`[chat-bridge] ${isRetryable ? 'Retryable' : 'Non-fatal'} error, advancing to fallback ${currentFallbackIndex + 1}/${fallbackChain.length}: ${fallbackChain[currentFallbackIndex].providerId}:${fallbackChain[currentFallbackIndex].model || 'native'}`);
+ // Continue loop to try next fallback
+ continue;
+ }
+
+ if (isFatal) {
+ console.error(`[chat-bridge] Fatal provider error, not falling back: ${err.message}`);
+ }
+
+ // No more fallbacks or fatal error - propagate error
+ _emitChatHud(session, onMessage, { busy: false, tokensPerSec: 0 });
+ session.active = false;
+ this.sessions.delete(sessionId);
+ if (err.name === 'AbortError') {
+ if (onComplete) onComplete({ sdkSessionId: session.sdkSessionId });
+ } else {
+ if (onError) onError(err);
+ }
+ return;
}
}
- })();
+ };
+
+ runQueryWithFallback();
return { sessionId, sdkSessionId: session.sdkSessionId };
}
@@ -866,4 +1516,11 @@ class ChatBridge {
}
}
-module.exports = { ChatBridge };
+module.exports = {
+ ChatBridge,
+ // Exportados para testes
+ buildProviderFallbackChain,
+ isRetryableProviderError,
+ isFatalProviderError,
+ SDK_COMPATIBLE_CLI,
+};
diff --git a/dashboard/terminal-server/src/claude-bridge.js b/dashboard/terminal-server/src/claude-bridge.js
index baa68ae4..27fee7d1 100644
--- a/dashboard/terminal-server/src/claude-bridge.js
+++ b/dashboard/terminal-server/src/claude-bridge.js
@@ -1,12 +1,344 @@
const { spawn } = require('node-pty');
+const cp = require('child_process');
const path = require('path');
const fs = require('fs');
+const os = require('os');
+
+// chalk (used inside marked-terminal) auto-detects color support from THIS
+// process's own stdout — but this process is a backend service with no real
+// tty of its own; the actual renderer is the browser's xterm.js, which
+// supports full color regardless. Without this, chalk sees "no tty" and
+// silently strips every ANSI code before marked-terminal's output ever
+// leaves this process. Must be set before requiring marked-terminal —
+// chalk reads it once at import time.
+process.env.FORCE_COLOR = process.env.FORCE_COLOR || '1';
+
+const { marked } = require('marked');
+const TerminalRenderer = require('marked-terminal').default;
+
+// Renders the opencode REPL's response markdown (headers, bold, lists, code
+// blocks) as ANSI-styled terminal output instead of dumping raw markdown
+// syntax. reflowText:false — the pty/xterm.js already wraps at the real
+// terminal width; re-wrapping here at a fixed guess would fight that.
+marked.setOptions({
+ renderer: new TerminalRenderer({ reflowText: false }),
+});
+
+// Workspace root is three levels up from this file (dashboard/terminal-server/src/).
+const WORKSPACE_ROOT = path.resolve(__dirname, '..', '..', '..');
const {
loadProviderConfig,
resolveProviderModel,
getProviderMode,
} = require('./provider-config');
+
+function readTerminalTrustMode() {
+ try {
+ const yaml = fs.readFileSync(path.join(WORKSPACE_ROOT, 'config', 'workspace.yaml'), 'utf8');
+ const m = yaml.match(/^chat:\s*\n(?:[ \t]+[^\n]*\n)*?[ \t]+trustMode:\s*(true|false)/m);
+ return m ? m[1] === 'true' : false;
+ } catch {
+ return false;
+ }
+}
+
+function terminalPromptAcceptInput(buffer) {
+ const clean = String(buffer || '').replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '');
+ if (/Do you trust the files in this folder\?/i.test(clean)) return '\r';
+ // First-run bypass-permissions confirmation dialog (Claude Code /
+ // OpenClaude v0.22+): "1. No, exit / 2. Yes, I accept" — accept is option 2.
+ if (/\b2\.\s*Yes, I accept/i.test(clean)) return '2\r';
+ if (/Do you want to proceed\?/i.test(clean)
+ || /Allow (this )?(command|tool|operation)/i.test(clean)
+ || /permission (request|required|to use|to run)/i.test(clean)
+ || /\b1\.\s*(Yes|Allow|Proceed)/i.test(clean)) {
+ return '1\r';
+ }
+ return null;
+}
+
+
+// A real pty (node-pty) has a line discipline that turns \n into \r\n on
+// output; a plain string piped to onOutput doesn't. Terminals move down one
+// row on \n but don't return to column 0 without an explicit \r, so raw
+// multi-line text from opencode's NDJSON `text` events renders as a
+// staircase (each line starting one column further right than the last).
+function toTerminalText(text) {
+ return text.replace(/\r?\n/g, '\r\n');
+}
+
+// marked-terminal throws on some malformed/edge-case markdown rather than
+// degrading gracefully — a rendering bug shouldn't ever hide the model's
+// actual answer, so fall back to the raw text if parsing fails.
+function renderMarkdown(text) {
+ try {
+ return marked(text).replace(/\n+$/, '');
+ } catch {
+ return text;
+ }
+}
+
+function isPtyEio(error) {
+ return error?.code === 'EIO' || /\bEIO\b|read EIO|write EIO/i.test(error?.message || '');
+}
+
+// Max time a single `opencode run` turn may take before we kill it and
+// report a timeout instead of leaving the user waiting forever. Confirmed
+// live (2026-07-13) that 120s was too short for real multi-step agentic
+// work (reviewing drafts, reading multiple files, rewriting content) — the
+// watchdog killed a turn mid-task that was doing legitimate work, not
+// hanging. 10 minutes is still provisional; Fase 3 burn-in
+// (runtime-harness-agnostic-eval feature folder) should replace it with an
+// evidence-based number once real task latencies are observed.
+const OPENCODE_TURN_TIMEOUT_MS = 600_000;
+
+// Above this many tokens in a single step, the terminal HUD (see
+// terminal-hud feature folder) marks the turn "heavy" (red semaphore
+// light). Confirmed live 2026-07-14: a trivial "ping" already used
+// ~32k input tokens — `opencode run` is a fresh headless process per
+// turn with no prompt caching across calls, so the full system
+// prompt + tool definitions get resent every single time. 20k made
+// the semaphore red on essentially every turn, not just genuinely
+// large ones. Raised well above that per-turn baseline so "heavy"
+// means an unusually large turn again, not the opencode norm.
+const HUD_HEAVY_TOKEN_THRESHOLD = 60_000;
+
+// Confirmed live 2026-07-14 (opencode --print-logs --log-level DEBUG, plus
+// `opencode export `): the NDJSON stream, the debug logs and the
+// exported session JSON all only ever carry the *requested* alias
+// (providerID=opencode modelID=auto) — opencode never records which
+// concrete model OmniRoute actually routed "auto" to. That data only exists
+// in the HTTP response OmniRoute itself sends back (`x-omniroute-model` /
+// `x-omniroute-provider` headers, confirmed via direct curl), and opencode's
+// own HTTP client swallows it. So instead of parsing it out of opencode,
+// _probeOmniRouteRoute below asks OmniRoute directly with a throwaway
+// 1-token call and reads those headers.
+const OMNIROUTE_PROBE_MIN_INTERVAL_MS = 20_000;
+const OMNIROUTE_PROBE_TIMEOUT_MS = 6_000;
+
+// Only meaningful while the session is on an "auto" alias — a pinned model
+// (e.g. nvidia's fixed model) is already the real model, nothing to probe.
+// Throttled per-session so rapid-fire messages don't double the request
+// volume against OmniRoute just to refresh a cosmetic HUD label.
+async function _probeOmniRouteRoute(session) {
+ if (session.providerModel !== 'auto-routing' && session.providerModel !== 'auto') return null;
+ const baseUrl = session.providerEnvVars && session.providerEnvVars.OPENAI_BASE_URL;
+ const apiKey = session.providerEnvVars && session.providerEnvVars.OPENAI_API_KEY;
+ if (!baseUrl || !apiKey) return null;
+ const now = Date.now();
+ if (session.lastRouteProbeAt && now - session.lastRouteProbeAt < OMNIROUTE_PROBE_MIN_INTERVAL_MS) {
+ return null;
+ }
+ session.lastRouteProbeAt = now;
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), OMNIROUTE_PROBE_TIMEOUT_MS);
+ try {
+ const resp = await fetch(`${baseUrl.replace(/\/$/, '')}/chat/completions`, {
+ method: 'POST',
+ headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
+ body: JSON.stringify({ model: 'auto', messages: [{ role: 'user', content: '.' }], max_tokens: 1, stream: false }),
+ signal: controller.signal,
+ });
+ const realModel = resp.headers.get('x-omniroute-model');
+ const realProvider = resp.headers.get('x-omniroute-provider');
+ if (!realModel && !realProvider) return null;
+ return { providerId: realProvider || null, providerModel: realModel || null };
+ } catch (err) {
+ // Cosmetic HUD data only — logged for diagnosability, never lets a
+ // probe failure affect the real turn.
+ console.warn('[bridge] OmniRoute route probe request failed:', err && err.message);
+ return null;
+ } finally {
+ clearTimeout(timer);
+ }
+}
+
+// Sprint 3 (terminal-ux-upgrade): the tok/s figure used to be recomputed
+// live from chars-streamed-so-far ÷ elapsed-time on every 'text' fragment,
+// which swings wildly early in a turn (a 3-char fragment after 40ms reads
+// as a nonsense instantaneous rate) — that's the "varia demais" the HUD
+// needle was chasing. Replaced with a fixed typical-throughput-per-model
+// baseline: stable during a turn, changes only when the model changes.
+// totalTokens (the real step_finish count) stays live/accurate — only the
+// speed readout is now a constant. Matched by substring against whatever
+// providerModel string is active, since it may be the config's raw model
+// id ("auto-routing") or a real model name if Sprint 2 ever recovers one.
+const MODEL_AVG_TOKENS_PER_SEC = {
+ 'opus': 35,
+ 'sonnet': 55,
+ 'haiku': 95,
+ 'fable': 70,
+ 'gpt-5': 60,
+ 'gpt-4': 70,
+ 'gemini': 65,
+ 'deepseek': 50,
+ 'glm': 45,
+ 'qwen': 55,
+ 'kimi': 50,
+ 'nemotron': 45,
+ 'minimax': 55,
+ 'grok': 60,
+ 'pickle': 60,
+};
+const DEFAULT_AVG_TOKENS_PER_SEC = 50;
+
+function _avgTokensPerSecFor(providerModel) {
+ const m = (providerModel || '').toLowerCase();
+ for (const key in MODEL_AVG_TOKENS_PER_SEC) {
+ if (m.includes(key)) return MODEL_AVG_TOKENS_PER_SEC[key];
+ }
+ return DEFAULT_AVG_TOKENS_PER_SEC;
+}
+
+// ~4 chars/token is the usual rough English/Portuguese estimate — opencode
+// doesn't stream incremental token counts, only a total per completed step,
+// so this is what drives the live tokens/s readout while a turn is still
+// running. Reconciled with the real total from `step_finish` as soon as
+// it's available.
+const HUD_CHARS_PER_TOKEN_ESTIMATE = 4;
+
+/**
+ * Read an agent's persistent memory (.claude/agent-memory/{agent}/), if any.
+ *
+ * Native Claude Code sessions get CLAUDE.md + .claude/rules/*.md auto-loaded
+ * on startup, including memory-recall.md, which tells the agent to go read
+ * its own agent-memory folder before acting. Non-Anthropic providers
+ * (openclaude/opencode) don't go through that auto-load — the persona
+ * embedding here only injects the agent's own .md file — so without this,
+ * every non-Anthropic session starts cold with zero memory of prior
+ * sessions. Reads learnings.md (dated lessons) and MEMORY.md (curated
+ * summary) when present and concatenates them; learnings.md is append-only
+ * so only the tail (most recent entries) is kept to bound prompt size.
+ */
+function loadAgentMemory(agent) {
+ if (!agent) return '';
+ const dir = path.join(WORKSPACE_ROOT, '.claude', 'agent-memory', agent);
+ const parts = [];
+ for (const file of ['learnings.md', 'MEMORY.md']) {
+ try {
+ const content = fs.readFileSync(path.join(dir, file), 'utf8').trim();
+ if (content) parts.push(`### ${file}\n${content}`);
+ } catch {
+ // File doesn't exist yet — this agent has no persisted memory. Fine.
+ }
+ }
+ if (!parts.length) return '';
+ const MAX_CHARS = 4000;
+ let combined = parts.join('\n\n');
+ if (combined.length > MAX_CHARS) combined = combined.slice(-MAX_CHARS);
+ return combined;
+}
+
+/**
+ * Build the agent persona block (persona .md + enforce-character text +
+ * prior-session memory) shared by the openclaude --system-prompt path and
+ * the opencode REPL path below.
+ */
+/**
+ * Keep the global opencode config (~/.config/opencode/opencode.json) in
+ * sync with WHATEVER provider is currently active, instead of relying on a
+ * static file that only ever defined one hardcoded "opencode" provider
+ * block. Before this, switching config/providers.json's active_provider to
+ * anything else with cli_command "opencode" would reproduce the exact same
+ * ProviderModelNotFoundError bug already fixed for the "opencode" entry —
+ * `opencode run -m /` only resolves if opencode.json has
+ * a provider block under that exact key. Runs once per session creation
+ * (cheap — a few KB JSON read/write), merges into the existing file rather
+ * than overwriting it, so manually-added provider/model entries (e.g. a
+ * test xai/grok-4.3 model) survive.
+ *
+ * Also where the "build" agent's `skill` tool gets disabled — see the
+ * comment on that line for why (94% token reduction, measured live).
+ */
+function _ensureOpencodeProviderConfig(providerId, modelArg, providerEnvVars) {
+ if (!providerId || !providerEnvVars?.OPENAI_BASE_URL || !providerEnvVars?.OPENAI_API_KEY) return;
+ const configDir = path.join(os.homedir(), '.config', 'opencode');
+ const configPath = path.join(configDir, 'opencode.json');
+ let config = { $schema: 'https://opencode.ai/config.json', provider: {}, agent: {} };
+ try {
+ const existing = JSON.parse(fs.readFileSync(configPath, 'utf8'));
+ if (existing && typeof existing === 'object') config = existing;
+ } catch {
+ // No file yet, or it's malformed — start fresh rather than fail the turn.
+ }
+ if (!config.provider || typeof config.provider !== 'object') config.provider = {};
+ const existingProvider = config.provider[providerId] || {};
+ config.provider[providerId] = {
+ ...existingProvider,
+ npm: '@ai-sdk/openai-compatible',
+ name: existingProvider.name || providerId,
+ options: {
+ baseURL: providerEnvVars.OPENAI_BASE_URL,
+ apiKey: providerEnvVars.OPENAI_API_KEY,
+ },
+ models: {
+ ...(existingProvider.models || {}),
+ [modelArg]: (existingProvider.models && existingProvider.models[modelArg]) || { name: modelArg },
+ },
+ };
+ if (!config.agent || typeof config.agent !== 'object') config.agent = {};
+ // Confirmed live 2026-07-14: with 194+ skills under .claude/skills/, the
+ // "build" agent's auto-discovered tool description
+ // alone cost ~30k tokens on every single turn — an isolated dir with no
+ // skills used 2,076 input tokens for "ping"; the repo root (same prompt,
+ // same model) used ~32,000-37,000. Skills are loaded on-demand by
+ // opencode's own `skill` tool, not needed for ordinary agent chat/REPL
+ // turns — this workspace's actual skill invocation flow is Claude Code's
+ // native slash commands, unrelated to this tool. Disabling it here cuts
+ // routine turn cost by roughly 15x with no loss of the slash-command flow.
+ config.agent.build = {
+ ...(config.agent.build || {}),
+ tools: { ...(config.agent.build?.tools || {}), skill: false },
+ };
+ try {
+ fs.mkdirSync(configDir, { recursive: true });
+ const tmpPath = `${configPath}.tmp-${process.pid}`;
+ fs.writeFileSync(tmpPath, JSON.stringify(config, null, 2));
+ fs.renameSync(tmpPath, configPath);
+ } catch (err) {
+ console.warn('[bridge] failed to write dynamic opencode provider config:', err && err.message);
+ }
+}
+
+function buildAgentPersona(agent, workingDir) {
+ const rootAgentFile = path.join(WORKSPACE_ROOT, '.claude', 'agents', `${agent}.md`);
+ const cwdAgentFile = path.join(workingDir, '.claude', 'agents', `${agent}.md`);
+ const agentFile = fs.existsSync(rootAgentFile) ? rootAgentFile : cwdAgentFile;
+ let agentPrompt = '';
+ let agentTier = null;
+ try {
+ const content = fs.readFileSync(agentFile, 'utf8');
+ const match = content.match(/^---\n[\s\S]*?\n---\n([\s\S]*)$/);
+ agentPrompt = match ? match[1].trim() : content;
+ for (const marker of ['\n# Persistent Agent Memory', '\n## MEMORY.md']) {
+ if (agentPrompt.includes(marker)) {
+ agentPrompt = agentPrompt.split(marker, 1)[0].trim();
+ }
+ }
+ const fmMatch = content.match(/^---\n([\s\S]*?)\n---\n/);
+ if (fmMatch) {
+ const tierMatch = fmMatch[1].match(/^model:\s*["']?([a-z0-9.-]+)["']?\s*$/mi);
+ if (tierMatch) agentTier = tierMatch[1].toLowerCase();
+ }
+ } catch {
+ agentPrompt = `You are the ${agent} agent.`;
+ }
+
+ const priorMemory = loadAgentMemory(agent);
+ if (priorMemory) {
+ agentPrompt += '\n\n## Previous Session Memory (yours — resume/summarize before acting)\n' + priorMemory;
+ }
+
+ const enforcePrompt = agentPrompt + '\n\n' +
+ 'CRITICAL: You MUST fully embody this agent persona. ' +
+ 'You are NOT Claude, OpenClaude, or a generic assistant — you ARE ' + agent + '. ' +
+ 'When asked who you are, ALWAYS respond as ' + agent + '. ' +
+ 'Never break character. Follow ALL instructions above.';
+
+ return { prompt: enforcePrompt, agentTier };
+}
+
class ClaudeBridge {
constructor() {
this.sessions = new Map();
@@ -30,6 +362,8 @@ class ClaudeBridge {
let resolved;
if (cliCommand === 'openclaude') {
resolved = execSync('which openclaude', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] }).trim();
+ } else if (cliCommand === 'opencode') {
+ resolved = execSync('which opencode', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] }).trim();
} else {
resolved = execSync('which claude', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] }).trim();
}
@@ -43,18 +377,27 @@ class ClaudeBridge {
// Fallback: check common hardcoded paths
const home = process.env.HOME || '/';
- const paths = cliCommand === 'openclaude'
- ? [
- path.join(home, '.local', 'bin', 'openclaude'),
- '/usr/local/bin/openclaude',
- '/usr/bin/openclaude',
- ]
- : [
- path.join(home, '.claude', 'local', 'claude'),
- path.join(home, '.local', 'bin', 'claude'),
- '/usr/local/bin/claude',
- '/usr/bin/claude',
- ];
+ let paths;
+ if (cliCommand === 'openclaude') {
+ paths = [
+ path.join(home, '.local', 'bin', 'openclaude'),
+ '/usr/local/bin/openclaude',
+ '/usr/bin/openclaude',
+ ];
+ } else if (cliCommand === 'opencode') {
+ paths = [
+ path.join(home, '.local', 'bin', 'opencode'),
+ '/usr/local/bin/opencode',
+ '/usr/bin/opencode',
+ ];
+ } else {
+ paths = [
+ path.join(home, '.claude', 'local', 'claude'),
+ path.join(home, '.local', 'bin', 'claude'),
+ '/usr/local/bin/claude',
+ '/usr/bin/claude',
+ ];
+ }
for (const p of paths) {
try {
@@ -71,6 +414,30 @@ class ClaudeBridge {
return cliCommand;
}
+ /**
+ * True when the CLI has a persisted conversation file for this session id
+ * in this workingDir — i.e. a previous run got far enough to save state,
+ * so `--resume ` will succeed. Checks the config dirs used by both
+ * claude (~/.claude) and openclaude (~/.openclaude), plus
+ * CLAUDE_CONFIG_DIR when set.
+ */
+ _hasPersistedConversation(sessionId, workingDir) {
+ const home = process.env.HOME || '/';
+ const slug = String(workingDir).replace(/[^a-zA-Z0-9]/g, '-');
+ const configDirs = [
+ process.env.CLAUDE_CONFIG_DIR,
+ path.join(home, '.openclaude'),
+ path.join(home, '.claude'),
+ ].filter(Boolean);
+ return configDirs.some((dir) => {
+ try {
+ return fs.existsSync(path.join(dir, 'projects', slug, `${sessionId}.jsonl`));
+ } catch {
+ return false;
+ }
+ });
+ }
+
async startSession(sessionId, options = {}) {
if (this.sessions.has(sessionId)) {
const existing = this.sessions.get(sessionId);
@@ -88,6 +455,9 @@ class ClaudeBridge {
if (existing.process) {
try { existing.process.kill('SIGKILL'); } catch (_) {}
}
+ if (existing.currentChild) {
+ try { existing.currentChild.kill('SIGKILL'); } catch (_) {}
+ }
this.sessions.delete(sessionId);
}
@@ -98,6 +468,7 @@ class ClaudeBridge {
onOutput = () => {},
onExit = () => {},
onError = () => {},
+ onHudUpdate = () => {},
cols = 80,
rows = 24
} = options;
@@ -122,6 +493,20 @@ class ClaudeBridge {
);
}
+ // opencode's own interactive TUI (default full-screen and --mini) don't
+ // render reliably inside this embedded pty — confirmed live on the VPS
+ // (2026-07-13, see runtime-harness-agnostic-eval feature folder):
+ // default mode overlaps frames from resize/redraw, --mini hides
+ // response text in a self-overwriting status area. Drive it headlessly
+ // instead — `opencode run --format json` + NDJSON parsing, the same
+ // approach already proven in provider_fallback.py for every heartbeat
+ // — and render the text ourselves.
+ if (providerConfig.cli_command === 'opencode') {
+ return this._startOpencodeReplSession(sessionId, {
+ workingDir, agent, onOutput, onExit, onError, onHudUpdate,
+ }, providerConfig.active || 'anthropic', providerModel, providerConfig.env_vars || {});
+ }
+
const cliCommand = this.findClaudeCommand(providerConfig.cli_command);
console.log(`Starting session ${sessionId} with ${providerConfig.cli_command}`);
@@ -129,16 +514,29 @@ class ClaudeBridge {
console.log(`Working directory: ${workingDir}`);
console.log(`Agent: ${agent || 'none'}`);
console.log(`Terminal size: ${cols}x${rows}`);
- if (dangerouslySkipPermissions) {
- console.log(`⚠️ WARNING: Skipping permissions with --dangerously-skip-permissions flag`);
+ const terminalTrustMode = dangerouslySkipPermissions || readTerminalTrustMode();
+ if (terminalTrustMode) {
+ console.log(`⚠️ WARNING: Terminal trust mode enabled`);
}
- // Don't use --dangerously-skip-permissions when running as root —
- // Claude/OpenClaude block this flag for root users.
- // The trust prompt is auto-accepted via PTY detection below instead.
+ // Claude Code and OpenClaude v0.22+ refuse --dangerously-skip-permissions
+ // as root unless IS_SANDBOX=1 marks a containerized environment — the
+ // --allow-dangerously-skip-permissions flag does NOT lift that check (it
+ // only makes bypass mode available as an option). So always pass the skip
+ // flag in trust mode and inject IS_SANDBOX=1 into the child env when
+ // running as root (the clean-env whitelist below would otherwise drop it).
const isRoot = process.getuid && process.getuid() === 0;
- const args = (dangerouslySkipPermissions && !isRoot) ? ['--dangerously-skip-permissions'] : [];
- if (agent) {
+ const active = providerConfig.active || 'anthropic';
+ const args = [];
+ let agentTier = null;
+
+ if (terminalTrustMode) {
+ args.push('--dangerously-skip-permissions');
+ if (isRoot) {
+ console.log('[permissions] Running as root in trust mode — injecting IS_SANDBOX=1 for the CLI root check');
+ }
+ }
+ if (agent && active === 'anthropic') {
args.push('--agent', agent);
}
@@ -146,29 +544,53 @@ class ClaudeBridge {
// --append-system-prompt is too weak — GPT models ignore appended instructions.
// --system-prompt REPLACES the default system prompt, ensuring the agent persona
// takes priority over CLAUDE.md and other context that mentions "Claude".
- const active = providerConfig.active || 'anthropic';
if (active !== 'anthropic' && agent) {
- // Read the agent definition file to build a strong system prompt
- const agentFile = path.join(workingDir, '.claude', 'agents', `${agent}.md`);
- let agentPrompt = '';
- try {
- const content = fs.readFileSync(agentFile, 'utf8');
- // Extract body (after YAML frontmatter ---)
- const match = content.match(/^---\n[\s\S]*?\n---\n([\s\S]*)$/);
- agentPrompt = match ? match[1].trim() : content;
- } catch {
- agentPrompt = `You are the ${agent} agent.`;
+ const persona = buildAgentPersona(agent, workingDir);
+ agentTier = persona.agentTier;
+ args.push('--system-prompt', persona.prompt);
+ }
+
+ // Pin the CLI conversation to the terminal-server session UUID so a
+ // crash, provider error, or terminal-server restart doesn't lose the
+ // conversation: the first start registers the id with --session-id,
+ // and any later start of the same session resumes it with --resume.
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+ if (UUID_RE.test(sessionId)) {
+ if (this._hasPersistedConversation(sessionId, workingDir)) {
+ console.log(`[bridge] Resuming persisted conversation for session ${sessionId}`);
+ args.push('--resume', sessionId);
+ } else {
+ args.push('--session-id', sessionId);
}
+ }
- const enforcePrompt = agentPrompt + '\n\n' +
- 'CRITICAL: You MUST fully embody this agent persona. ' +
- 'You are NOT Claude, OpenClaude, or a generic assistant — you ARE ' + agent + '. ' +
- 'When asked who you are, ALWAYS respond as ' + agent + '. ' +
- 'Never break character. Follow ALL instructions above.';
+ // Copy so per-session overrides don't leak into the shared config object.
+ const providerEnv = { ...(providerConfig.env_vars || {}) };
- args.push('--system-prompt', enforcePrompt);
+ // Per-agent model tier: agents declare model: opus|sonnet|haiku in
+ // their frontmatter; providers.json maps each tier to a provider model
+ // via the provider's "model_tiers" field.
+ const tierModel = agentTier && providerConfig.model_tiers
+ ? providerConfig.model_tiers[agentTier]
+ : null;
+ if (active !== 'anthropic' && tierModel) {
+ providerEnv['OPENAI_MODEL'] = tierModel;
+ console.log(`[provider] Agent ${agent} tier "${agentTier}" → model ${tierModel}`);
+ }
+
+ // Automatic model fallback when the primary is overloaded. The CLI
+ // accepts a single fallback — pass the first configured entry that
+ // differs from the primary model.
+ const fallbackModels = Array.isArray(providerConfig.fallback_models)
+ ? providerConfig.fallback_models
+ : [];
+ if (active !== 'anthropic') {
+ const primary = providerEnv['OPENAI_MODEL'] || '';
+ const fallback = fallbackModels.find((m) => m && m !== primary);
+ if (fallback) {
+ args.push('--fallback-model', fallback);
+ }
}
- const providerEnv = providerConfig.env_vars || {};
// Build a CLEAN environment for the spawned CLI process.
// We DON'T spread process.env — it may contain stale/cached vars
@@ -181,6 +603,9 @@ class ClaudeBridge {
'SSH_AUTH_SOCK', 'SSH_AGENT_PID',
'NVM_DIR', 'NVM_BIN', 'NVM_INC',
'CODEX_HOME', 'CLAUDE_CONFIG_DIR',
+ // Container marker exported by entrypoint.sh — required for
+ // --dangerously-skip-permissions to work as root.
+ 'IS_SANDBOX',
];
const cleanEnv = {};
for (const key of SYSTEM_VARS) {
@@ -192,7 +617,7 @@ class ClaudeBridge {
// to route to the Codex backend — a raw 'gpt-5.x' falls back to the
// regular chat completions API, which bypasses Codex OAuth entirely.
//
- // codexplan → GPT-5.4 on Codex backend (high reasoning)
+ // codexplan → GPT-5.5 on Codex backend (high reasoning)
// codexspark → GPT-5.3 Codex Spark (faster)
//
// For the plain 'openai' provider (API key mode), default to gpt-4.1.
@@ -212,6 +637,13 @@ class ClaudeBridge {
env: {
...cleanEnv,
...providerEnv,
+ // Lift the CLI's root/sudo guard for --dangerously-skip-permissions
+ // inside the container (see comment above spawn args).
+ ...(terminalTrustMode && isRoot ? { IS_SANDBOX: '1' } : {}),
+ // O auto-updater do CLI migra a instalação npm pro instalador
+ // nativo no meio da sessão e mata o processo com exit 1
+ // ("OpenClaude has switched from npm to the native installer").
+ DISABLE_AUTOUPDATER: '1',
TERM: 'xterm-256color',
FORCE_COLOR: '1',
COLORTERM: 'truecolor'
@@ -226,45 +658,59 @@ class ClaudeBridge {
workingDir,
created: new Date(),
active: true,
- killTimeout: null
+ killTimeout: null,
+ // Tracks whether onExit has already fired so the late-arriving
+ // 'error' (EIO) event from node-pty doesn't double-fire onExit
+ // or surface a misleading "read EIO" toast to the user.
+ exited: false,
};
this.sessions.set(sessionId, session);
- // Track if we've seen the trust prompt
- let trustPromptHandled = false;
let dataBuffer = '';
+ let lastAutoAcceptAt = 0;
claudeProcess.onData((data) => {
if (process.env.DEBUG) {
console.log(`Session ${sessionId} output:`, data);
}
- // Buffer data to check for trust prompt
+ // Buffer data to check for interactive trust / permission prompts.
dataBuffer += data;
-
- // Check for trust prompt and auto-accept it
- if (!trustPromptHandled && dataBuffer.includes('Do you trust the files in this folder?')) {
- trustPromptHandled = true;
- console.log(`Auto-accepting trust prompt for session ${sessionId}`);
- // The prompt shows "Enter to confirm" which means option 1 is already selected
- // Just send Enter to confirm
- setTimeout(() => {
- claudeProcess.write('\r');
- console.log(`Sent Enter to accept trust prompt for session ${sessionId}`);
- }, 500);
+
+ const acceptInput = terminalTrustMode ? terminalPromptAcceptInput(dataBuffer) : null;
+ if (acceptInput) {
+ const now = Date.now();
+ if (now - lastAutoAcceptAt > 1200) {
+ lastAutoAcceptAt = now;
+ dataBuffer = '';
+ console.log(`Auto-accepting terminal permission prompt for session ${sessionId}`);
+ setTimeout(() => {
+ claudeProcess.write(acceptInput);
+ }, 250);
+ }
}
-
+
// Clear buffer periodically to prevent memory issues
if (dataBuffer.length > 10000) {
dataBuffer = dataBuffer.slice(-5000);
}
-
+
onOutput(data);
});
claudeProcess.onExit((exitCode, signal) => {
+ // node-pty 1.1.0+ passes exitCode as an object {exitCode, signal}
+ // in some code paths. Normalize so the rest of the pipeline always
+ // sees a number (or null).
+ if (exitCode && typeof exitCode === 'object') {
+ signal = exitCode.signal != null ? exitCode.signal : signal;
+ exitCode = exitCode.exitCode != null ? exitCode.exitCode : exitCode;
+ }
console.log(`Claude session ${sessionId} exited with code ${exitCode}, signal ${signal}`);
+ // Mark as exited so the late-arriving 'error' (EIO) handler
+ // knows the exit has already been reported and stays silent.
+ session.exited = true;
// Clear kill timeout if process exited naturally
if (session.killTimeout) {
clearTimeout(session.killTimeout);
@@ -276,15 +722,36 @@ class ClaudeBridge {
});
claudeProcess.on('error', (error) => {
- console.error(`Claude session ${sessionId} error:`, error);
+ if (isPtyEio(error)) {
+ // EIO on the master side of the PTY is a known artifact when the
+ // child process has already exited — node-pty's read loop hits
+ // the closed slave FD and emits 'error' before (or alongside)
+ // the 'exit' event. If onExit already fired, we're done — don't
+ // double-report. If not, treat it as a normal exit (code 1,
+ // signal null) instead of an error so the frontend shows a
+ // clean "[Process exited]" rather than a scary "read EIO".
+ if (session.exited) {
+ console.warn(`Claude session ${sessionId} EIO after exit — suppressed`);
+ return;
+ }
+ console.warn(`Claude session ${sessionId} PTY closed with EIO; treating as exit`);
+ } else {
+ console.error(`Claude session ${sessionId} error:`, error);
+ }
// Clear kill timeout if process errored
if (session.killTimeout) {
clearTimeout(session.killTimeout);
session.killTimeout = null;
}
+ session.exited = true;
session.active = false;
this.sessions.delete(sessionId);
- onError(error);
+ if (isPtyEio(error)) {
+ // Signal null (not 'EIO') so the frontend doesn't restart-loop
+ onExit(1, null);
+ } else {
+ onError(error);
+ }
});
console.log(`Claude session ${sessionId} started successfully`);
@@ -296,15 +763,441 @@ class ClaudeBridge {
}
}
+ /**
+ * Start an opencode session as a headless REPL instead of an interactive
+ * pty process. No long-lived child process — each submitted line spawns
+ * its own `opencode run` call (see _runOpencodeTurn), so there's no CLI
+ * "process" for this session to crash or hang mid-conversation; a turn
+ * that fails or times out just reports an error and leaves the session
+ * ready for the next message.
+ */
+ async _startOpencodeReplSession(sessionId, options, providerId, providerModel, providerEnvVars = {}) {
+ const { workingDir, agent, onOutput, onExit, onError, onHudUpdate } = options;
+ const cliBin = this.findClaudeCommand('opencode');
+
+ // Keeps opencode.json's provider block in sync with whichever provider
+ // is actually active — see _ensureOpencodeProviderConfig for why this
+ // is required for any provider other than the original hardcoded
+ // "opencode" one to work at all.
+ _ensureOpencodeProviderConfig(providerId, providerModel || 'auto', providerEnvVars);
+
+ let personaPrefix = null;
+ if (agent) {
+ const persona = buildAgentPersona(agent, workingDir);
+ personaPrefix = persona.prompt;
+ }
+
+ const session = {
+ workingDir,
+ created: new Date(),
+ active: true,
+ exited: false,
+ isOpencode: true,
+ process: null,
+ currentChild: null,
+ busy: false,
+ lineBuffer: '',
+ opencodeSessionId: null,
+ // Persona slug (e.g. "atlas-project") — lets a global status endpoint
+ // (GET /api/sessions/hud-status, see server.js) report which agents
+ // are working without touching any per-session terminal socket.
+ agentName: agent || null,
+ personaPrefix,
+ // cliProviderId/cliModelArg are the ONLY values ever passed to `opencode
+ // run -m <...>` — fixed at session creation, never touched again.
+ // providerId/providerModel below are a SEPARATE pair used purely for
+ // the HUD display (rewritten to "auto-routing" for a nicer label, and
+ // later to the real routed model/provider by _probeOmniRouteRoute).
+ // Confirmed live 2026-07-14 (docker exec into the deployed container):
+ // reusing one pair for both purposes meant the HUD's "auto-routing"
+ // display label was also getting sent as the actual `-m` argument —
+ // `-m opencode/auto-routing` fails with ProviderModelNotFoundError
+ // ("auto-routing" isn't a model opencode.json defines; only "auto" is)
+ // — masked by opencode as a generic "Unexpected server error". Manual
+ // `-m opencode/auto` in the same container succeeded immediately.
+ cliProviderId: providerId,
+ cliModelArg: providerModel || 'auto',
+ providerId,
+ // "auto-routing" instead of the bare "auto" from providers.json's
+ // default_model — that string means "OmniRoute decides server-side",
+ // not a model name, and showing it unlabeled on the HUD reads as a
+ // broken/generic value. Overwritten with the real provider/model the
+ // moment the OmniRoute probe resolves one (see _probeOmniRouteRoute).
+ providerModel: providerModel && providerModel !== 'auto' ? providerModel : 'auto-routing',
+ // OPENAI_BASE_URL/OPENAI_API_KEY come from this provider's own
+ // dashboard-editable config (same fields every other provider uses),
+ // already resolved from placeholders to real secrets by
+ // loadProviderConfig() before reaching here — opencode.json points
+ // its "opencode" provider at {env:OPENAI_BASE_URL}/{env:OPENAI_API_KEY}.
+ providerEnvVars,
+ cliBin,
+ onOutput,
+ onExit,
+ onError,
+ onHudUpdate: onHudUpdate || (() => {}),
+ // Tracks the provider/model shown in the last hud_update sent — lets
+ // us tell the frontend "this specific update is a change" (fires the
+ // gear-shift animation) vs. just a routine tick.
+ lastHudProviderId: null,
+ lastHudProviderModel: null,
+ bestTokensPerSec: 0,
+ };
+ this.sessions.set(sessionId, session);
+
+ onOutput(
+ `\x1b[36mopencode (${providerId}/${session.providerModel}) — modo REPL headless.\x1b[0m\r\n` +
+ `Digite sua mensagem e pressione Enter.\r\n\r\n`
+ );
+
+ console.log(`[bridge] opencode REPL session ${sessionId} ready (agent: ${agent || 'none'})`);
+ return session;
+ }
+
+ /**
+ * Build the environment for an `opencode run` child process. Mirrors the
+ * SYSTEM_VARS whitelist used for the pty-spawned CLIs above — no full
+ * process.env spread, so a stale OPENAI_API_KEY etc. can't hijack the
+ * call. OPENAI_BASE_URL/OPENAI_API_KEY come from providerEnvVars (the
+ * "opencode" provider's own dashboard-editable config, same fields every
+ * other provider uses) — opencode.json resolves them via
+ * {env:OPENAI_BASE_URL}/{env:OPENAI_API_KEY}.
+ */
+ _buildOpencodeEnv(providerEnvVars = {}) {
+ const SYSTEM_VARS = [
+ 'HOME', 'USER', 'SHELL', 'PATH', 'LANG', 'LC_ALL', 'LC_CTYPE',
+ 'LOGNAME', 'HOSTNAME', 'XDG_RUNTIME_DIR', 'XDG_DATA_HOME',
+ 'XDG_CONFIG_HOME', 'XDG_CACHE_HOME', 'TMPDIR',
+ 'SSH_AUTH_SOCK', 'SSH_AGENT_PID',
+ 'NVM_DIR', 'NVM_BIN', 'NVM_INC',
+ 'CODEX_HOME', 'CLAUDE_CONFIG_DIR', 'IS_SANDBOX',
+ ];
+ const env = {};
+ for (const key of SYSTEM_VARS) {
+ if (process.env[key]) env[key] = process.env[key];
+ }
+ if (providerEnvVars.OPENAI_BASE_URL) env.OPENAI_BASE_URL = providerEnvVars.OPENAI_BASE_URL;
+ if (providerEnvVars.OPENAI_API_KEY) env.OPENAI_API_KEY = providerEnvVars.OPENAI_API_KEY;
+ env.DISABLE_AUTOUPDATER = '1';
+ return env;
+ }
+
+ /**
+ * Handle raw keystrokes for an opencode REPL session: echo printable
+ * characters, support backspace and Ctrl+C (clears the pending line —
+ * can't interrupt an in-flight turn without killing it), and submit the
+ * buffered line on Enter. No cursor movement / history — MVP REPL, not a
+ * full line editor.
+ */
+ _handleOpencodeKeystrokes(sessionId, session, data) {
+ // xterm sends escape sequences (arrows, function keys, etc.) as a
+ // single multi-byte chunk starting with ESC. Swallow the whole thing
+ // instead of leaking its raw bytes into the message buffer.
+ if (data.length > 1 && data.charCodeAt(0) === 0x1b) return;
+
+ for (const ch of data) {
+ if (ch === '\r' || ch === '\n') {
+ session.onOutput('\r\n');
+ const line = session.lineBuffer;
+ session.lineBuffer = '';
+ if (!line.trim()) continue;
+ if (session.busy) {
+ session.onOutput('\x1b[33m(ainda processando a mensagem anterior — aguarde)\x1b[0m\r\n');
+ continue;
+ }
+ this._runOpencodeTurn(sessionId, session, line);
+ } else if (ch === '\x7f' || ch === '\b') {
+ if (session.lineBuffer.length > 0) {
+ session.lineBuffer = session.lineBuffer.slice(0, -1);
+ session.onOutput('\b \b');
+ }
+ } else if (ch === '\x03') {
+ session.lineBuffer = '';
+ session.onOutput('^C\r\n');
+ } else if (ch >= ' ') {
+ session.lineBuffer += ch;
+ session.onOutput(ch);
+ }
+ }
+ }
+
+ /**
+ * Push a status snapshot to the terminal HUD (see the terminal-hud
+ * feature folder — semaphore + gear/LCD panel). `shift` is computed here,
+ * not passed in: true only on the specific update where providerId or
+ * providerModel actually changed since the last one sent, so the frontend
+ * knows to play the gear-shift animation instead of a routine tick.
+ */
+ _emitHudUpdate(session, patch = {}) {
+ const providerId = patch.providerId || session.providerId;
+ const providerModel = patch.providerModel || session.providerModel;
+ const shift = providerId !== session.lastHudProviderId || providerModel !== session.lastHudProviderModel;
+ session.lastHudProviderId = providerId;
+ session.lastHudProviderModel = providerModel;
+ if (typeof patch.tokensPerSec === 'number' && patch.tokensPerSec > session.bestTokensPerSec) {
+ session.bestTokensPerSec = patch.tokensPerSec;
+ }
+ const payload = {
+ busy: session.busy,
+ providerId,
+ providerModel,
+ tokensPerSec: null,
+ totalTokens: null,
+ heavy: false,
+ ...patch,
+ bestTokensPerSec: session.bestTokensPerSec,
+ shift,
+ };
+ // Stashed on the session so GET /api/sessions/hud-status can report
+ // this turn's state to callers with no open WebSocket (the /agents
+ // page) — onHudUpdate above only reaches whoever has this one
+ // terminal tab open right now.
+ session.lastHud = payload;
+ session.onHudUpdate(payload);
+ }
+
+ /**
+ * Run one `opencode run` turn headlessly and stream its NDJSON `text`
+ * events into session.onOutput as they arrive. Same event shape already
+ * parsed in provider_fallback.py::_parse_opencode_ndjson — kept as a
+ * separate JS implementation since these two bridges don't share a util
+ * module today.
+ */
+ _runOpencodeTurn(sessionId, session, message) {
+ session.busy = true;
+
+ let fullMessage = message;
+ if (session.personaPrefix) {
+ fullMessage = `${session.personaPrefix}\n\n---\n\nTask:\n${message}`;
+ session.personaPrefix = null; // embed only once — -s keeps the rest of the context
+ }
+
+ this._attemptOpencodeTurn(sessionId, session, fullMessage, 0);
+ }
+
+ /**
+ * One `opencode run` attempt for a turn. On timeout, retries once with the
+ * same message instead of failing outright — a stuck attempt (like the
+ * OmniRoute "auggie" LKGP hang from 2026-07-13) is often transient, and a
+ * silent retry costs nothing the user wasn't already waiting for. Only
+ * gives up and reports an error after the retry ALSO times out.
+ */
+ _attemptOpencodeTurn(sessionId, session, fullMessage, attemptNumber) {
+ const modelRef = `${session.cliProviderId}/${session.cliModelArg}`;
+ // No --agent flag — defaults to opencode's "build" agent, which is what
+ // the original spike validated for real tool-use (bash calls + reported
+ // text). The zero-token/no-response symptom seen live on 2026-07-13
+ // turned out to be unrelated to agent choice (--agent plan didn't fix
+ // it either) — root cause was the OmniRoute "auto" combo routing to a
+ // stuck "auggie" candidate (see runtime-harness-agnostic-eval feature
+ // folder + omniroute-gateway memory). Fixed on the gateway side by
+ // removing "auggie" from the auto/* combo pools.
+ const args = ['run', fullMessage, '-m', modelRef, '--format', 'json', '--auto'];
+ if (session.opencodeSessionId) {
+ args.push('-s', session.opencodeSessionId);
+ }
+
+ if (attemptNumber === 0) {
+ session.onOutput('\x1b[90m…\x1b[0m');
+ }
+
+ let child;
+ try {
+ child = cp.spawn(session.cliBin, args, {
+ cwd: session.workingDir,
+ env: this._buildOpencodeEnv(session.providerEnvVars),
+ // Node leaves a child's stdin as an open, never-EOF'd pipe by
+ // default — opencode blocks reading it before ever calling the
+ // model, so the call just hangs forever with no output, no exit,
+ // no error (confirmed locally, 2026-07-13: identical spawn args
+ // via bash with stdin inherited from a real TTY complete in
+ // under a second; the same spawn() call in Node without this
+ // hangs indefinitely). `run` never reads stdin for anything we
+ // use here, so closing it costs nothing.
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+ } catch (error) {
+ session.busy = false;
+ session.onOutput(`\r\x1b[K\r\n\x1b[31m[opencode] falha ao executar: ${error.message}\x1b[0m\r\n\r\n`);
+ return;
+ }
+ session.currentChild = child;
+ if (attemptNumber === 0) {
+ this._emitHudUpdate(session);
+ }
+
+ let stdoutBuffer = '';
+ let stderrBuffer = '';
+ let textBuffer = '';
+ let sawText = false;
+ let charsSoFar = 0;
+ let realTotalTokens = null;
+ let sawError = false;
+ let errorMessage = '';
+ let timedOut = false;
+
+ const killTimer = setTimeout(() => {
+ timedOut = true;
+ console.warn(`[bridge] opencode turn for session ${sessionId} exceeded ${OPENCODE_TURN_TIMEOUT_MS}ms (attempt ${attemptNumber + 1}) — killing`);
+ try { child.kill('SIGKILL'); } catch (_) {}
+ }, OPENCODE_TURN_TIMEOUT_MS);
+
+ const processLine = (rawLine) => {
+ const line = rawLine.trim();
+ if (!line) return;
+ let event;
+ try {
+ event = JSON.parse(line);
+ } catch {
+ return;
+ }
+ const sid = event.sessionID || event.session_id;
+ if (sid && !session.opencodeSessionId) {
+ session.opencodeSessionId = sid;
+ }
+ const part = event.part || {};
+ if (event.type === 'text') {
+ const text = part.text;
+ if (text) {
+ // Buffered, not streamed straight to onOutput — the response is
+ // markdown (headers, bold, lists, code blocks) and arrives in
+ // arbitrary-sized fragments; parsing each fragment as markdown on
+ // its own would mangle syntax split across two chunks (e.g.
+ // "**bo" + "ld**"). Rendered whole once the turn closes.
+ sawText = true;
+ textBuffer += text;
+ charsSoFar += text.length;
+ const estTokens = charsSoFar / HUD_CHARS_PER_TOKEN_ESTIMATE;
+ this._emitHudUpdate(session, {
+ tokensPerSec: _avgTokensPerSecFor(session.providerModel),
+ totalTokens: Math.round(estTokens),
+ });
+ }
+ } else if (event.type === 'error') {
+ sawError = true;
+ const err = event.error || {};
+ errorMessage = (err.data && err.data.message) || err.name || 'erro desconhecido';
+ } else if (event.type === 'step_finish') {
+ // Real token count for the step that just closed — reconciles the
+ // char-based live estimate above. Multiple steps can happen in one
+ // turn (tool calls between text replies); keep the running total.
+ const tokens = part.tokens;
+ if (tokens) {
+ const stepTotal = (tokens.input || 0) + (tokens.output || 0);
+ realTotalTokens = (realTotalTokens || 0) + stepTotal;
+ this._emitHudUpdate(session, {
+ tokensPerSec: _avgTokensPerSecFor(session.providerModel),
+ totalTokens: realTotalTokens,
+ heavy: realTotalTokens > HUD_HEAVY_TOKEN_THRESHOLD,
+ });
+ }
+ }
+ };
+
+ child.stdout.setEncoding('utf8');
+ child.stdout.on('data', (chunk) => {
+ stdoutBuffer += chunk;
+ let idx;
+ while ((idx = stdoutBuffer.indexOf('\n')) !== -1) {
+ processLine(stdoutBuffer.slice(0, idx));
+ stdoutBuffer = stdoutBuffer.slice(idx + 1);
+ }
+ });
+
+ child.stderr.setEncoding('utf8');
+ child.stderr.on('data', (chunk) => {
+ stderrBuffer += chunk;
+ });
+
+ child.on('close', (code) => {
+ clearTimeout(killTimer);
+ if (stdoutBuffer.trim()) processLine(stdoutBuffer);
+ session.currentChild = null;
+
+ if (timedOut && !sawText && attemptNumber < 1) {
+ console.warn(`[bridge] opencode turn timed out for session ${sessionId}, retrying once`);
+ session.onOutput(`\r\x1b[K\r\n\x1b[33m[opencode] sem resposta em ${Math.round(OPENCODE_TURN_TIMEOUT_MS / 1000)}s — tentando de novo\x1b[0m\r\n`);
+ this._attemptOpencodeTurn(sessionId, session, fullMessage, attemptNumber + 1);
+ return; // stays busy — the retry owns finishing the turn
+ }
+
+ session.busy = false;
+ this._emitHudUpdate(session, {
+ tokensPerSec: 0,
+ totalTokens: realTotalTokens,
+ heavy: (realTotalTokens || 0) > HUD_HEAVY_TOKEN_THRESHOLD,
+ });
+
+ // Only *after* the real turn's own child process/network work is
+ // fully done — not concurrently with it (see _probeOmniRouteRoute).
+ // Firing this in parallel with the turn made the VPS visibly slower
+ // on live testing (extra TLS handshake + request competing for
+ // CPU/bandwidth right when the real turn needed it) and the label
+ // update would frequently land after the user had stopped watching
+ // anyway. Fire-and-forget: just refreshes the HUD label whenever it
+ // resolves, doesn't block or affect the next turn.
+ _probeOmniRouteRoute(session)
+ .then((route) => {
+ if (!route || (!route.providerId && !route.providerModel)) return;
+ if (route.providerId) session.providerId = route.providerId;
+ if (route.providerModel) session.providerModel = route.providerModel;
+ this._emitHudUpdate(session, { tokensPerSec: 0 });
+ })
+ .catch((err) => {
+ console.warn(`[bridge] OmniRoute route probe failed for session ${sessionId}:`, err && err.message);
+ });
+
+ session.onOutput('\r\x1b[K'); // clear the "…" placeholder either way
+
+ if (textBuffer) {
+ session.onOutput(toTerminalText(renderMarkdown(textBuffer)));
+ }
+
+ if (timedOut) {
+ session.onOutput(`\r\n\x1b[31m[opencode] sem resposta em ${Math.round(OPENCODE_TURN_TIMEOUT_MS / 1000)}s, de novo mesmo depois de tentar outra vez — desisti\x1b[0m\r\n`);
+ } else if (sawError) {
+ session.onOutput(`\r\n\x1b[31m[opencode] ${toTerminalText(errorMessage)}\x1b[0m\r\n`);
+ } else if (code !== 0) {
+ const stderrTail = stderrBuffer.trim().slice(0, 300);
+ session.onOutput(`\r\n\x1b[31m[opencode] processo saiu com código ${code}${stderrTail ? ': ' + toTerminalText(stderrTail) : ''}\x1b[0m\r\n`);
+ } else if (!sawText) {
+ session.onOutput('\r\n\x1b[33m[opencode] sem resposta de texto nesse turno.\x1b[0m\r\n');
+ }
+ session.onOutput('\r\n');
+ });
+
+ child.on('error', (error) => {
+ clearTimeout(killTimer);
+ session.currentChild = null;
+ session.busy = false;
+ this._emitHudUpdate(session, { tokensPerSec: 0 });
+ session.onOutput(`\r\x1b[K\r\n\x1b[31m[opencode] falha ao executar: ${error.message}\x1b[0m\r\n\r\n`);
+ });
+ }
+
async sendInput(sessionId, data) {
const session = this.sessions.get(sessionId);
if (!session || !session.active) {
throw new Error(`Session ${sessionId} not found or not active`);
}
+ if (session.isOpencode) {
+ this._handleOpencodeKeystrokes(sessionId, session, data);
+ return true;
+ }
+
try {
session.process.write(data);
+ return true;
} catch (error) {
+ if (isPtyEio(error)) {
+ // Process already exited — don't surface EIO; treat as silent exit
+ if (!session.exited) {
+ session.exited = true;
+ }
+ session.active = false;
+ this.sessions.delete(sessionId);
+ return false;
+ }
throw new Error(`Failed to send input to session ${sessionId}: ${error.message}`);
}
}
@@ -315,9 +1208,16 @@ class ClaudeBridge {
throw new Error(`Session ${sessionId} not found or not active`);
}
+ if (session.isOpencode) return; // no real pty to resize in REPL mode
+
try {
session.process.resize(cols, rows);
} catch (error) {
+ if (isPtyEio(error)) {
+ session.active = false;
+ this.sessions.delete(sessionId);
+ return;
+ }
console.warn(`Failed to resize session ${sessionId}:`, error.message);
}
}
@@ -328,6 +1228,21 @@ class ClaudeBridge {
return;
}
+ if (session.isOpencode) {
+ if (session.currentChild) {
+ try { session.currentChild.kill('SIGKILL'); } catch (_) {}
+ }
+ // Sprint 7 (terminal-ux-upgrade): attachments uploaded during this
+ // session (see POST /api/upload) live in a per-session dir under the
+ // workingDir — best-effort cleanup on stop (Q6). Not awaited: a slow
+ // FS shouldn't hold up session teardown, and a leftover .uploads dir
+ // from a failed rm is not a correctness problem, just tidiness.
+ fs.rm(path.join(session.workingDir, '.uploads', sessionId), { recursive: true, force: true }, () => {});
+ session.active = false;
+ this.sessions.delete(sessionId);
+ return;
+ }
+
try {
// Clear any existing kill timeout
if (session.killTimeout) {
@@ -337,7 +1252,7 @@ class ClaudeBridge {
if (session.active && session.process) {
session.process.kill('SIGTERM');
-
+
session.killTimeout = setTimeout(() => {
if (session.active && session.process) {
session.process.kill('SIGKILL');
diff --git a/dashboard/terminal-server/src/provider-config.js b/dashboard/terminal-server/src/provider-config.js
index 2065d480..ccb82270 100644
--- a/dashboard/terminal-server/src/provider-config.js
+++ b/dashboard/terminal-server/src/provider-config.js
@@ -3,8 +3,11 @@ const path = require('path');
const WORKSPACE_ROOT = path.resolve(__dirname, '..', '..', '..');
const PROVIDERS_PATH = path.join(WORKSPACE_ROOT, 'config', 'providers.json');
+const PROVIDERS_EXAMPLE_PATH = path.join(WORKSPACE_ROOT, 'config', 'providers.example.json');
-const ALLOWED_CLI = new Set(['claude', 'openclaude']);
+const ALLOWED_CLI = new Set(['claude', 'openclaude', 'opencode']);
+const ALLOWED_MODES = new Set(['code', 'chat']);
+const DEFAULT_CODE_PROVIDERS = new Set(['openrouter', 'omnirouter', 'nvidia', 'codex_auth']);
const ALLOWED_ENV_VARS = new Set([
'ANTHROPIC_API_KEY',
'CLAUDE_CODE_USE_OPENAI',
@@ -18,12 +21,69 @@ const ALLOWED_ENV_VARS = new Set([
'CODEX_API_KEY',
'GEMINI_API_KEY',
'GEMINI_MODEL',
+ 'NVIDIA_API_KEY',
'AWS_REGION',
'AWS_BEARER_TOKEN_BEDROCK',
'ANTHROPIC_VERTEX_PROJECT_ID',
'CLOUD_ML_REGION',
]);
+// providers.json stores API keys as "[REDACTED]" placeholders; the real secrets
+// live in .env. The terminal-server doesn't load dotenv, so read them here and
+// swap placeholders for the real value — otherwise openclaude gets a bogus key
+// and every request 401s.
+const _SECRET_KEYS = ['OPENAI_API_KEY', 'NVIDIA_API_KEY', 'GEMINI_API_KEY', 'ANTHROPIC_API_KEY', 'CODEX_API_KEY'];
+
+function _isPlaceholderSecret(v) {
+ return !v || /redact/i.test(v) || v.trim().startsWith('[');
+}
+
+let _envSecretsCache = null;
+function _readEnvSecrets() {
+ if (_envSecretsCache) return _envSecretsCache;
+ const out = {};
+ try {
+ const txt = fs.readFileSync(path.join(WORKSPACE_ROOT, '.env'), 'utf8');
+ for (const line of txt.split('\n')) {
+ const t = line.trim();
+ if (!t || t.startsWith('#') || !t.includes('=')) continue;
+ const i = t.indexOf('=');
+ out[t.slice(0, i).trim()] = t.slice(i + 1).trim();
+ }
+ } catch (_) { /* no .env file */ }
+ _envSecretsCache = out;
+ return out;
+}
+
+// For each secret, the .env may hold it under a related name (NVIDIA uses an
+// OpenAI-compatible endpoint, so OPENAI_API_KEY is fed by NVIDIA_API_KEY).
+const _SECRET_FALLBACKS = {
+ OPENAI_API_KEY: ['OPENAI_API_KEY', 'NVIDIA_API_KEY'],
+ NVIDIA_API_KEY: ['NVIDIA_API_KEY', 'OPENAI_API_KEY'],
+ GEMINI_API_KEY: ['GEMINI_API_KEY'],
+ ANTHROPIC_API_KEY: ['ANTHROPIC_API_KEY'],
+ CODEX_API_KEY: ['CODEX_API_KEY'],
+};
+
+function _resolveSecrets(envObj) {
+ const secrets = _readEnvSecrets();
+ const lookup = (cands) => {
+ for (const c of cands) {
+ const v = process.env[c] || secrets[c];
+ if (v && !_isPlaceholderSecret(v)) return v;
+ }
+ return null;
+ };
+ for (const k of _SECRET_KEYS) {
+ if (k in envObj && _isPlaceholderSecret(envObj[k])) {
+ const real = lookup(_SECRET_FALLBACKS[k] || [k]);
+ if (real) envObj[k] = real;
+ else delete envObj[k]; // no real value → drop placeholder so it can't 401
+ }
+ }
+ return envObj;
+}
+
function _normalizeModel(model) {
return (model || '').trim().toLowerCase();
}
@@ -57,42 +117,156 @@ function resolveProviderModel(providerConfig) {
function getProviderMode(providerConfig) {
const active = providerConfig?.active || 'anthropic';
if (active === 'anthropic') return 'anthropic';
+ // Explicit per-provider mode in providers.json wins over the name heuristic —
+ // model names like "openrouter/owl-alpha" are agentic but don't match isCodeModel.
+ if (ALLOWED_MODES.has(providerConfig?.mode)) return providerConfig.mode;
+ // These providers run through OpenClaude in the Terminal. Opaque hosted model
+ // names such as z-ai/glm-5.2 should not be downgraded to Chat by heuristic.
+ if (DEFAULT_CODE_PROVIDERS.has(active)) return 'code';
const model = resolveProviderModel(providerConfig);
if (isCodeModel(model)) return 'code';
return 'chat';
}
+function _mergeProviderDefaults(config) {
+ try {
+ if (!fs.existsSync(PROVIDERS_EXAMPLE_PATH)) return config;
+ const defaults = JSON.parse(fs.readFileSync(PROVIDERS_EXAMPLE_PATH, 'utf8'));
+ if (!config || typeof config !== 'object' || Array.isArray(config)) config = {};
+
+ for (const [key, value] of Object.entries(defaults)) {
+ if (key === 'providers') continue;
+ if (!(key in config)) config[key] = value;
+ }
+
+ if (!config.providers || typeof config.providers !== 'object' || Array.isArray(config.providers)) {
+ config.providers = {};
+ }
+
+ for (const [providerId, defaultProvider] of Object.entries(defaults.providers || {})) {
+ const existing = config.providers[providerId];
+ if (!existing || typeof existing !== 'object' || Array.isArray(existing)) {
+ config.providers[providerId] = defaultProvider;
+ continue;
+ }
+
+ for (const [key, value] of Object.entries(defaultProvider)) {
+ if (key === 'env_vars') {
+ if (!existing.env_vars || typeof existing.env_vars !== 'object' || Array.isArray(existing.env_vars)) {
+ existing.env_vars = {};
+ }
+ for (const [envKey, envDefault] of Object.entries(value || {})) {
+ if (!(envKey in existing.env_vars)) existing.env_vars[envKey] = envDefault;
+ }
+ } else if (!(key in existing)) {
+ existing[key] = value;
+ }
+ }
+ }
+ } catch (_) {
+ return config;
+ }
+ return config;
+}
+
function loadProviderConfig() {
try {
if (!fs.existsSync(PROVIDERS_PATH)) {
- return { cli_command: 'claude', env_vars: {}, active: 'anthropic' };
+ return { cli_command: 'claude', env_vars: {}, active: 'anthropic', fallback_models: [], fallback_providers: [], providers: {}, model_tiers: {} };
}
- const config = JSON.parse(fs.readFileSync(PROVIDERS_PATH, 'utf8'));
+ const config = _mergeProviderDefaults(JSON.parse(fs.readFileSync(PROVIDERS_PATH, 'utf8')));
const active = config.active_provider || 'anthropic';
const provider = config.providers?.[active] || {};
let cliCommand = provider.cli_command || 'claude';
if (!ALLOWED_CLI.has(cliCommand)) cliCommand = 'claude';
- const envVars = Object.fromEntries(
- Object.entries(provider.env_vars || {}).filter(
+ const sanitizeEnv = (rawEnv = {}) => Object.fromEntries(
+ Object.entries(rawEnv).filter(
([k, v]) => v !== '' && ALLOWED_ENV_VARS.has(k)
)
);
+ const envVars = _resolveSecrets(sanitizeEnv(provider.env_vars || {}));
+
if (active === 'codex_auth' && 'OPENAI_API_KEY' in envVars) {
delete envVars.OPENAI_API_KEY;
}
+ // OpenClaude ≥0.18 detects the NVIDIA NIM base URL and requires the key
+ // in NVIDIA_API_KEY — derive it so the UI only asks for one key field.
+ if (
+ !envVars.NVIDIA_API_KEY &&
+ envVars.OPENAI_API_KEY &&
+ /\bnvidia\.com\b/i.test(envVars.OPENAI_BASE_URL || '')
+ ) {
+ envVars.NVIDIA_API_KEY = envVars.OPENAI_API_KEY;
+ }
+
+ // Ordered fallback chain — the CLI consumes the first entry
+ // (--fallback-model); the full list is used by Chat Completion fallback.
+ const fallbackModels = Array.isArray(provider.fallback_models)
+ ? provider.fallback_models
+ .filter((m) => typeof m === 'string' && m.trim())
+ .map((m) => m.trim())
+ : [];
+
+ const fallbackProviders = Array.isArray(provider.fallback_providers)
+ ? provider.fallback_providers
+ .filter((p) => typeof p === 'string' && p.trim())
+ .map((p) => p.trim())
+ : [];
+
+ const providers = {};
+ for (const [id, p] of Object.entries(config.providers || {})) {
+ let pCliCommand = p.cli_command || 'claude';
+ if (!ALLOWED_CLI.has(pCliCommand)) pCliCommand = 'claude';
+ const pEnv = _resolveSecrets(sanitizeEnv(p.env_vars || {}));
+ if (id === 'codex_auth' && 'OPENAI_API_KEY' in pEnv) delete pEnv.OPENAI_API_KEY;
+ if (!pEnv.NVIDIA_API_KEY && pEnv.OPENAI_API_KEY && /\bnvidia\.com\b/i.test(pEnv.OPENAI_BASE_URL || '')) {
+ pEnv.NVIDIA_API_KEY = pEnv.OPENAI_API_KEY;
+ }
+ providers[id] = {
+ cli_command: pCliCommand,
+ env_vars: pEnv,
+ active: id,
+ provider_name: p.name || id,
+ mode: ALLOWED_MODES.has(p.mode) ? p.mode : null,
+ fallback_models: Array.isArray(p.fallback_models)
+ ? p.fallback_models.filter((m) => typeof m === 'string' && m.trim()).map((m) => m.trim())
+ : [],
+ fallback_providers: Array.isArray(p.fallback_providers)
+ ? p.fallback_providers.filter((fp) => typeof fp === 'string' && fp.trim()).map((fp) => fp.trim())
+ : [],
+ model_tiers: {},
+ };
+ if (p.model_tiers && typeof p.model_tiers === 'object' && !Array.isArray(p.model_tiers)) {
+ for (const [tier, model] of Object.entries(p.model_tiers)) {
+ if (typeof model === 'string' && model.trim()) {
+ providers[id].model_tiers[tier.toLowerCase()] = model.trim();
+ }
+ }
+ }
+ }
+
+ // Per-tier model map: agents declare model: opus|sonnet|haiku in their
+ // frontmatter; providers.json maps each tier to a provider model.
+ const modelTiers = providers[active]?.model_tiers || {};
+
return {
cli_command: cliCommand,
env_vars: envVars,
active,
provider_name: provider.name || active,
+ mode: ALLOWED_MODES.has(provider.mode) ? provider.mode : null,
+ fallback_models: fallbackModels,
+ fallback_providers: fallbackProviders,
+ providers,
+ model_tiers: modelTiers,
};
} catch {
- return { cli_command: 'claude', env_vars: {}, active: 'anthropic' };
+ return { cli_command: 'claude', env_vars: {}, active: 'anthropic', fallback_models: [], fallback_providers: [], providers: {}, model_tiers: {} };
}
}
@@ -103,4 +277,3 @@ module.exports = {
isCodeModel,
isChatCompletionModel,
};
-
diff --git a/dashboard/terminal-server/src/server.js b/dashboard/terminal-server/src/server.js
index f5f8605a..b3f20606 100644
--- a/dashboard/terminal-server/src/server.js
+++ b/dashboard/terminal-server/src/server.js
@@ -11,6 +11,30 @@ const SessionStore = require('./utils/session-store');
const ChatLogger = require('./utils/chat-logger');
const { loadProviderConfig, getProviderMode } = require('./provider-config');
+function isEioError(error) {
+ const message = typeof error === 'string' ? error : (error?.message || '');
+ return error?.code === 'EIO' || /\bEIO\b|read EIO|write EIO/i.test(message);
+}
+
+// Sprint 7 (terminal-ux-upgrade): attachment upload limits (Q6 default).
+const UPLOAD_MAX_BYTES = 20 * 1024 * 1024;
+const UPLOAD_MIME_ALLOWLIST = new Set([
+ 'image/png', 'image/jpeg', 'image/webp', 'image/gif',
+ 'application/pdf', 'text/plain', 'text/markdown', 'text/csv',
+ 'application/json', 'application/octet-stream',
+]);
+
+// Strips any path components and anything outside a safe charset — the
+// filename comes from a query param (user-controlled), so this is the one
+// line standing between it and a path-traversal write (`../../etc/passwd`
+// etc.). path.basename() alone drops directories; the regex replace then
+// drops everything that isn't alphanumeric/dot/dash/underscore.
+function sanitizeUploadFilename(name) {
+ const base = path.basename(String(name || 'arquivo'));
+ const cleaned = base.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 120);
+ return cleaned || 'arquivo';
+}
+
class TerminalServer {
constructor(options = {}) {
this.port = options.port || 32352;
@@ -26,6 +50,15 @@ class TerminalServer {
);
this.autoSaveIntervalMs = options.autoSaveIntervalMs ?? 30000;
+ // Guard-rails de memória: cada PTY claude/openclaude consome 200-400MB.
+ // Sem teto, abas demais derrubam o container por OOM (limite da stack).
+ const maxActive = parseInt(process.env.TERMINAL_MAX_ACTIVE_SESSIONS || '', 10);
+ this.maxActiveSessions = options.maxActiveSessions
+ ?? (Number.isFinite(maxActive) && maxActive > 0 ? maxActive : 4);
+ const detachedMin = parseFloat(process.env.TERMINAL_DETACHED_TTL_MINUTES || '');
+ this.detachedTtlMs = options.detachedTtlMs
+ ?? (Number.isFinite(detachedMin) && detachedMin >= 0 ? detachedMin * 60 * 1000 : 30 * 60 * 1000);
+
this.app = express();
this.claudeSessions = new Map();
this.webSocketConnections = new Map();
@@ -83,9 +116,46 @@ class TerminalServer {
this.sessionGcInterval = setInterval(() => {
void this.purgeStaleSessions();
+ void this.reapDetachedSessions();
}, this.sessionGcIntervalMs);
}
+ /**
+ * Recolhe PTYs ativos que estão sem nenhum viewer conectado E sem produzir
+ * output há mais de detachedTtlMs. Fechar a aba do navegador não mata o
+ * processo (de propósito, permite reconectar) — sem este reaper cada aba
+ * aberta/fechada deixa um claude de 200-400MB vivo até o OOM do container.
+ * A sessão continua no store (buffer preservado) e pode ser reiniciada.
+ */
+ async reapDetachedSessions() {
+ if (this.detachedTtlMs <= 0) return { reaped: 0 };
+ const now = Date.now();
+ let reaped = 0;
+ for (const [sessionId, session] of this.claudeSessions.entries()) {
+ if (!session.active) continue;
+ const viewers = session.connections instanceof Set
+ ? session.connections.size
+ : (Array.isArray(session.connections) ? session.connections.length : 0);
+ if (viewers > 0) continue;
+ // Só recolhe PTYs de terminal — sessões de chat não seguram processo.
+ const pty = this.claudeBridge.getSession(sessionId);
+ if (!pty || !pty.active) continue;
+ const lastTouch = new Date(session.lastActivity || session.created || 0).getTime();
+ if (!Number.isFinite(lastTouch) || (now - lastTouch) <= this.detachedTtlMs) continue;
+
+ reaped += 1;
+ console.log(`[session-gc] Reaping detached idle session ${sessionId} (sem viewer há ${Math.round((now - lastTouch) / 60000)}min)`);
+ try {
+ await this.claudeBridge.stopSession(sessionId);
+ } catch (error) {
+ if (this.dev) console.warn(`Failed to reap session ${sessionId}:`, error.message);
+ }
+ session.active = false;
+ }
+ if (reaped > 0) await this.saveSessionsToDisk();
+ return { reaped };
+ }
+
async saveSessionsToDisk() {
await this.sessionStore.saveSessions(this.claudeSessions);
}
@@ -275,10 +345,14 @@ class TerminalServer {
// Scope reuse by (agentName, ticketId) when ticketId is provided.
// Without ticketId the old behaviour is preserved (reuse by agentName alone).
+ // Never reuse a session that already has a live viewer attached — two
+ // concurrent terminals would share one PTY and mirror/duplicate every
+ // keystroke. The busy session keeps its viewer; the caller gets a fresh one.
for (const [id, s] of this.claudeSessions.entries()) {
const agentMatch = s.agentName === agentName;
const ticketMatch = ticketId ? s.ticketId === ticketId : !s.ticketId;
- if (agentMatch && ticketMatch) {
+ const busy = s.connections && s.connections.size > 0;
+ if (agentMatch && ticketMatch && !busy) {
return res.json({
success: true,
sessionId: id,
@@ -381,6 +455,121 @@ class TerminalServer {
res.json({ sessions });
});
+ // Aggregate HUD status across every live opencode REPL session — lets
+ // the /agents page show "N agentes trabalhando agora" and which ones,
+ // without opening any individual terminal tab. Only opencode sessions
+ // carry busy/heavy telemetry today (see claude-bridge.js's
+ // _emitHudUpdate) — native claude/openclaude PTY sessions don't track
+ // turn boundaries, so they're left out rather than reported as a
+ // meaningless always-false busy flag.
+ this.app.get('/api/sessions/hud-status', (req, res) => {
+ const sessions = [];
+ for (const [id, s] of this.claudeBridge.sessions.entries()) {
+ if (!s.isOpencode || !s.active || !s.agentName) continue;
+ const hud = s.lastHud || {};
+ sessions.push({
+ sessionId: id,
+ agent: s.agentName,
+ busy: !!s.busy,
+ heavy: !!hud.heavy,
+ providerId: s.providerId,
+ providerModel: s.providerModel,
+ tokensPerSec: typeof hud.tokensPerSec === 'number' ? hud.tokensPerSec : 0,
+ });
+ }
+ res.json({ sessions });
+ });
+
+ // Sprint 5 (terminal-ux-upgrade): audio -> text, proxied server-side so
+ // GROQ_API_KEY never reaches the browser. Body is the raw audio blob
+ // (whatever MIME the MediaRecorder produced — webm/opus in every
+ // Chromium/Firefox build that matters here); express.raw() is scoped to
+ // this one route only, the rest of the app keeps using express.json().
+ // No multer/form-data dependency needed — Node 22 has FormData/Blob/
+ // fetch as globals, sufficient for this single outbound multipart call.
+ this.app.post(
+ '/api/transcribe',
+ express.raw({ type: '*/*', limit: '25mb' }),
+ async (req, res) => {
+ const apiKey = process.env.GROQ_API_KEY;
+ if (!apiKey) {
+ return res.status(503).json({ error: 'GROQ_API_KEY não configurada no terminal-server.' });
+ }
+ const audio = req.body;
+ if (!Buffer.isBuffer(audio) || audio.length === 0) {
+ return res.status(400).json({ error: 'Corpo da requisição vazio — envie o áudio como binário.' });
+ }
+ try {
+ const form = new FormData();
+ const mime = req.headers['content-type'] || 'audio/webm';
+ form.append('file', new Blob([audio], { type: mime }), 'audio.webm');
+ form.append('model', 'whisper-large-v3-turbo');
+
+ const groqRes = await fetch('https://api.groq.com/openai/v1/audio/transcriptions', {
+ method: 'POST',
+ headers: { Authorization: `Bearer ${apiKey}` },
+ body: form,
+ });
+ const data = await groqRes.json().catch(() => null);
+ if (!groqRes.ok) {
+ // Groq's error payload is safe to relay — it never echoes the
+ // key back. Logged without the key too (apiKey never touches
+ // console.* in this handler).
+ console.warn(`[transcribe] Groq returned ${groqRes.status}:`, data);
+ return res.status(502).json({ error: (data && data.error && data.error.message) || `Groq respondeu ${groqRes.status}` });
+ }
+ res.json({ text: (data && data.text) || '' });
+ } catch (error) {
+ console.error('[transcribe] falha ao chamar Groq:', error.message);
+ res.status(502).json({ error: 'Falha ao transcrever o áudio.' });
+ }
+ }
+ );
+
+ // Sprint 7 (terminal-ux-upgrade): attach a photo/document to a prompt.
+ // Reuses the raw-body pattern from /api/transcribe (Sprint 5) — a
+ // single-file binary body, no multer/multipart parsing needed. Saves
+ // under a per-session dir inside the session's own workingDir (never
+ // outside it) so the opencode agent can reference the path directly in
+ // its prompt; cleaned up on session stop (see claude-bridge.js
+ // stopSession, Q6). Best-effort: whether the agent's own Read tool
+ // actually picks up an image (vs. a text document) depends on the
+ // underlying model's multimodal support — not something this endpoint
+ // can guarantee, only the file being there for it to try.
+ this.app.post(
+ '/api/upload',
+ express.raw({ type: '*/*', limit: '20mb' }),
+ async (req, res) => {
+ const sessionId = String(req.query.sessionId || '');
+ const session = sessionId && this.claudeBridge.sessions.get(sessionId);
+ if (!session || !session.active) {
+ return res.status(404).json({ error: 'Sessão não encontrada ou inativa.' });
+ }
+ const file = req.body;
+ if (!Buffer.isBuffer(file) || file.length === 0) {
+ return res.status(400).json({ error: 'Arquivo vazio.' });
+ }
+ if (file.length > UPLOAD_MAX_BYTES) {
+ return res.status(413).json({ error: `Arquivo excede o limite de ${UPLOAD_MAX_BYTES / 1024 / 1024}MB.` });
+ }
+ const mime = String(req.headers['content-type'] || '').split(';')[0].trim();
+ if (mime && !UPLOAD_MIME_ALLOWLIST.has(mime)) {
+ return res.status(415).json({ error: `Tipo de arquivo não permitido: ${mime}` });
+ }
+ const filename = sanitizeUploadFilename(req.query.filename);
+ const dir = path.join(session.workingDir, '.uploads', sessionId);
+ try {
+ await fs.promises.mkdir(dir, { recursive: true });
+ const destPath = path.join(dir, `${Date.now()}-${filename}`);
+ await fs.promises.writeFile(destPath, file);
+ res.json({ path: destPath, filename });
+ } catch (error) {
+ console.error('[upload] falha ao salvar arquivo:', error.message);
+ res.status(500).json({ error: 'Falha ao salvar o arquivo.' });
+ }
+ }
+ );
+
// Create a NEW session for an agent (always creates, never reuses)
this.app.post('/api/sessions/create', (req, res) => {
const { agentName, workingDir } = req.body;
@@ -618,14 +807,34 @@ class TerminalServer {
const session = this.claudeSessions.get(wsInfo.claudeSessionId);
if (session && session.connections.has(wsId) && session.active && session.agent === 'claude') {
try {
- await this.claudeBridge.sendInput(wsInfo.claudeSessionId, data.data);
+ const accepted = await this.claudeBridge.sendInput(wsInfo.claudeSessionId, data.data);
+ if (accepted === false) {
+ session.active = false;
+ this.broadcastToSession(wsInfo.claudeSessionId, { type: 'exit', code: 1, signal: null });
+ }
} catch (error) {
+ if (isEioError(error)) {
+ session.active = false;
+ this.broadcastToSession(wsInfo.claudeSessionId, { type: 'exit', code: 1, signal: null });
+ break;
+ }
if (this.dev) console.error(`Failed to send input to session ${wsInfo.claudeSessionId}:`, error.message);
this.sendToWebSocket(wsInfo.ws, {
type: 'error',
message: 'Agent is not running in this session. Please start an agent first.',
});
}
+ } else if (session && !session.active) {
+ // The CLI process already died (crash, provider error, etc.) —
+ // without this, typing into a dead session silently vanished:
+ // the client kept sending 'input' with nothing telling it the
+ // PTY was gone, so the user saw no feedback at all ("send a
+ // prompt and nothing happens"). Tell the client explicitly so
+ // it can offer/trigger a restart instead of a dead end.
+ this.sendToWebSocket(wsInfo.ws, {
+ type: 'error',
+ message: 'Session has exited. Restart the agent to continue.',
+ });
}
}
break;
@@ -924,6 +1133,10 @@ class TerminalServer {
outputBuffer: session.outputBuffer.slice(-200),
chatHistory,
ticketId: session.ticketId || null,
+ // Sprint 4 (terminal-ux-upgrade): when attaching to an already-active
+ // session, no 'claude_started' broadcast follows — this is the only
+ // message that tells the frontend which input mode to use.
+ isOpencode: !!this.claudeBridge.sessions.get(claudeSessionId)?.isOpencode,
});
if (this.dev) console.log(`WebSocket ${wsId} joined session ${claudeSessionId}`);
@@ -958,12 +1171,30 @@ class TerminalServer {
// through reverse proxies like Traefik). The session is already
// running — replay the buffer and tell the client it's attached
// instead of surfacing a misleading error toast.
- this.sendToWebSocket(wsInfo.ws, { type: 'claude_started', sessionId: wsInfo.claudeSessionId });
+ this.sendToWebSocket(wsInfo.ws, {
+ type: 'claude_started',
+ sessionId: wsInfo.claudeSessionId,
+ // Sprint 4 (terminal-ux-upgrade): tells the frontend whether to
+ // route typing through the dedicated input bar (opencode REPL,
+ // line-based) or raw xterm keystrokes (claude/openclaude PTY,
+ // needs arrows/Ctrl-C/interactive TUI).
+ isOpencode: !!this.claudeBridge.sessions.get(wsInfo.claudeSessionId)?.isOpencode,
+ });
return;
}
const sessionId = wsInfo.claudeSessionId;
+ // Teto de PTYs simultâneos — proteção contra OOM do container.
+ const activePtys = Array.from(this.claudeSessions.values()).filter((s) => s.active).length;
+ if (activePtys >= this.maxActiveSessions) {
+ this.sendToWebSocket(wsInfo.ws, {
+ type: 'error',
+ message: `Limite de ${this.maxActiveSessions} sessões ativas atingido — encerre uma sessão/aba antes de iniciar outra. (Ajustável via TERMINAL_MAX_ACTIVE_SESSIONS; sessões ociosas sem aba aberta são recolhidas automaticamente.)`,
+ });
+ return;
+ }
+
try {
// Ensure agent name from session is passed even if options don't include it
const agentForSession = (options && options.agent) || session.agentName || null;
@@ -977,6 +1208,9 @@ class TerminalServer {
onOutput: (data) => {
const currentSession = this.claudeSessions.get(sessionId);
if (!currentSession) return;
+ // Output conta como atividade — o reaper de sessões destacadas só
+ // recolhe PTYs parados E sem viewer.
+ currentSession.lastActivity = new Date();
currentSession.outputBuffer.push(data);
if (currentSession.outputBuffer.length > currentSession.maxBufferSize) {
currentSession.outputBuffer.shift();
@@ -991,7 +1225,20 @@ class TerminalServer {
onError: (error) => {
const currentSession = this.claudeSessions.get(sessionId);
if (currentSession) currentSession.active = false;
- this.broadcastToSession(sessionId, { type: 'error', message: error.message });
+ if (isEioError(error)) {
+ // EIO is a PTY artifact when the child exits — treat as normal
+ // exit with signal null so the frontend shows "Process exited"
+ // instead of the scary "read EIO" error toast.
+ this.broadcastToSession(sessionId, { type: 'exit', code: 1, signal: null });
+ } else {
+ this.broadcastToSession(sessionId, { type: 'error', message: error.message });
+ }
+ },
+ onHudUpdate: (hud) => {
+ // terminal-hud feature: semaphore + gear/LCD panel. opencode REPL
+ // sessions only (claude-bridge.js's onHudUpdate is a no-op for the
+ // pty-interactive claude/openclaude path).
+ this.broadcastToSession(sessionId, { type: 'hud_update', ...hud });
},
});
@@ -1001,8 +1248,17 @@ class TerminalServer {
session.lastActivity = new Date();
if (!session.sessionStartTime) session.sessionStartTime = new Date();
- this.broadcastToSession(sessionId, { type: 'claude_started', sessionId });
+ this.broadcastToSession(sessionId, {
+ type: 'claude_started',
+ sessionId,
+ isOpencode: !!this.claudeBridge.sessions.get(sessionId)?.isOpencode,
+ });
} catch (error) {
+ if (isEioError(error)) {
+ session.active = false;
+ this.broadcastToSession(sessionId, { type: 'exit', code: 1, signal: null });
+ return;
+ }
if (this.dev) console.error(`Error starting Claude in session ${wsInfo.claudeSessionId}:`, error);
this.sendToWebSocket(wsInfo.ws, { type: 'error', message: `Failed to start Claude Code: ${error.message}` });
}
diff --git a/dashboard/terminal-server/test/chat-bridge-fallback.test.js b/dashboard/terminal-server/test/chat-bridge-fallback.test.js
new file mode 100644
index 00000000..012ae8e8
--- /dev/null
+++ b/dashboard/terminal-server/test/chat-bridge-fallback.test.js
@@ -0,0 +1,176 @@
+const assert = require('assert/strict');
+const test = require('node:test');
+
+const {
+ buildProviderFallbackChain,
+ isRetryableProviderError,
+ isFatalProviderError,
+ SDK_COMPATIBLE_CLI,
+} = require('../src/chat-bridge');
+
+// Fixture no formato retornado por loadProviderConfig()
+function makeConfig() {
+ const nvidiaEnv = {
+ CLAUDE_CODE_USE_OPENAI: '1',
+ OPENAI_BASE_URL: 'https://integrate.api.nvidia.com/v1',
+ OPENAI_API_KEY: 'nvapi-XXX',
+ OPENAI_MODEL: 'stepfun-ai/step-3.7-flash',
+ NVIDIA_API_KEY: 'nvapi-XXX',
+ };
+ const omniEnv = {
+ CLAUDE_CODE_USE_OPENAI: '1',
+ OPENAI_BASE_URL: 'http://omniroute:20128/v1',
+ OPENAI_API_KEY: 'omni-key',
+ OPENAI_MODEL: 'auto',
+ };
+ return {
+ cli_command: 'openclaude',
+ env_vars: { ...nvidiaEnv },
+ active: 'nvidia',
+ mode: 'code',
+ fallback_models: ['deepseek-ai/deepseek-v4-flash'],
+ fallback_providers: ['omnirouter', 'anthropic'],
+ providers: {
+ nvidia: {
+ cli_command: 'openclaude',
+ env_vars: { ...nvidiaEnv },
+ active: 'nvidia',
+ mode: 'code',
+ fallback_models: ['deepseek-ai/deepseek-v4-flash'],
+ fallback_providers: ['omnirouter', 'anthropic'],
+ model_tiers: {},
+ },
+ omnirouter: {
+ cli_command: 'openclaude',
+ env_vars: { ...omniEnv },
+ active: 'omnirouter',
+ mode: 'code',
+ fallback_models: [],
+ fallback_providers: [],
+ model_tiers: {},
+ },
+ anthropic: {
+ cli_command: 'claude',
+ env_vars: {},
+ active: 'anthropic',
+ mode: null,
+ fallback_models: [],
+ fallback_providers: [],
+ model_tiers: {},
+ },
+ },
+ model_tiers: {},
+ };
+}
+
+test('chain: primário → fallback_models → fallback_providers → anthropic', () => {
+ const chain = buildProviderFallbackChain(makeConfig());
+ const labels = chain.map((c) => `${c.providerId}:${c.model || 'native'}`);
+ assert.deepEqual(labels, [
+ 'nvidia:stepfun-ai/step-3.7-flash',
+ 'nvidia:deepseek-ai/deepseek-v4-flash',
+ 'omnirouter:auto',
+ 'anthropic:native',
+ ]);
+});
+
+test('attempt de fallback usa o env do PRÓPRIO provider, não do ativo', () => {
+ const chain = buildProviderFallbackChain(makeConfig());
+ const omni = chain.find((c) => c.providerId === 'omnirouter');
+ assert.ok(omni, 'omnirouter deve estar na cadeia');
+ assert.equal(omni.baseUrl, 'http://omniroute:20128/v1');
+ assert.equal(omni.envVars.OPENAI_BASE_URL, 'http://omniroute:20128/v1');
+ assert.equal(omni.envVars.OPENAI_API_KEY, 'omni-key');
+ // A chave NVIDIA não pode vazar pro attempt do gateway — sequestra a chamada.
+ assert.equal(omni.envVars.NVIDIA_API_KEY, undefined);
+});
+
+test('attempt final anthropic é claude nativo com env limpo', () => {
+ const chain = buildProviderFallbackChain(makeConfig());
+ const last = chain[chain.length - 1];
+ assert.equal(last.providerId, 'anthropic');
+ assert.equal(last.cliCommand, 'claude');
+ assert.deepEqual(last.envVars, {});
+ assert.equal(last.model, null);
+});
+
+test('cadeia vazia quando o provider ativo é anthropic (caminho nativo não usa fallback)', () => {
+ const config = makeConfig();
+ config.active = 'anthropic';
+ config.env_vars = {};
+ const chain = buildProviderFallbackChain(config);
+ assert.equal(chain.length, 0);
+});
+
+test('isRetryableProviderError reconhece o 503 do OmniRoute', () => {
+ assert.ok(isRetryableProviderError(new Error('API Error: 503 Maximum combo retry limit reached')));
+ assert.ok(isRetryableProviderError(new Error('429 Too Many Requests')));
+ assert.ok(isRetryableProviderError(new Error('Service Unavailable')));
+ assert.ok(!isRetryableProviderError(new Error('SyntaxError: unexpected token')));
+});
+
+test('isFatalProviderError reconhece erros de auth', () => {
+ assert.ok(isFatalProviderError(new Error('401 Unauthorized')));
+ assert.ok(isFatalProviderError(new Error('invalid api key')));
+ assert.ok(!isFatalProviderError(new Error('503 Service Unavailable')));
+});
+
+// opencode não implementa o protocolo de subprocesso do Agent SDK
+// (--input-format stream-json / control_request-response) — só claude e
+// openclaude falam esse protocolo. Um attempt com cli_command "opencode" na
+// cadeia (ex.: se o provider ativo algum dia ganhar um OPENAI_MODEL) tem que
+// ser filtrado pelo chamador antes de spawnar via SDK, senão é o crash
+// "exit code 1 / chat não responde" que motivou esse teste.
+function makeOpencodeConfig() {
+ const opencodeEnv = { OPENAI_MODEL: 'auto' };
+ const anthropicEnv = {};
+ return {
+ cli_command: 'opencode',
+ env_vars: { ...opencodeEnv },
+ active: 'opencode',
+ mode: 'code',
+ fallback_models: [],
+ fallback_providers: ['anthropic'],
+ providers: {
+ opencode: {
+ cli_command: 'opencode',
+ env_vars: { ...opencodeEnv },
+ active: 'opencode',
+ mode: 'code',
+ fallback_models: [],
+ fallback_providers: ['anthropic'],
+ model_tiers: {},
+ },
+ anthropic: {
+ cli_command: 'claude',
+ env_vars: { ...anthropicEnv },
+ active: 'anthropic',
+ mode: null,
+ fallback_models: [],
+ fallback_providers: [],
+ model_tiers: {},
+ },
+ },
+ model_tiers: {},
+ };
+}
+
+test('cadeia crua inclui o attempt opencode (documenta o que precisa ser filtrado)', () => {
+ const chain = buildProviderFallbackChain(makeOpencodeConfig());
+ const labels = chain.map((c) => `${c.providerId}:${c.cliCommand}`);
+ assert.deepEqual(labels, ['opencode:opencode', 'anthropic:claude']);
+});
+
+test('filtro por SDK_COMPATIBLE_CLI remove o attempt opencode e mantém o anthropic nativo', () => {
+ const chain = buildProviderFallbackChain(makeOpencodeConfig())
+ .filter((attempt) => SDK_COMPATIBLE_CLI.has(attempt.cliCommand));
+ assert.equal(chain.length, 1);
+ assert.equal(chain[0].providerId, 'anthropic');
+ assert.equal(chain[0].cliCommand, 'claude');
+});
+
+test('SDK_COMPATIBLE_CLI aceita claude e openclaude, rejeita opencode', () => {
+ assert.ok(SDK_COMPATIBLE_CLI.has('claude'));
+ assert.ok(SDK_COMPATIBLE_CLI.has('openclaude'));
+ assert.ok(!SDK_COMPATIBLE_CLI.has('opencode'));
+});
diff --git a/dashboard/terminal-server/test/manual/fallback-e2e.js b/dashboard/terminal-server/test/manual/fallback-e2e.js
new file mode 100644
index 00000000..63f013fb
--- /dev/null
+++ b/dashboard/terminal-server/test/manual/fallback-e2e.js
@@ -0,0 +1,119 @@
+// Teste E2E MANUAL do fallback do chat-bridge (fora do `npm test` — precisa
+// do binário openclaude instalado e sobe processos reais do Agent SDK).
+//
+// node test/manual/fallback-e2e.js
+//
+// Provider primário responde 503 "Maximum combo retry limit reached" (mock A);
+// fallback (mock B) responde um chat completion válido em SSE.
+// Esperado: a sessão NÃO crasha — rotaciona pro mock B em segundos e completa
+// com o texto "FALLBACK-OK", zero results de erro entregues ao UI.
+const http = require('http');
+const path = require('path');
+
+const TS_ROOT = path.resolve(__dirname, '..', '..');
+
+// ---- Mock A: sempre 503 combo ----
+const serverA = http.createServer((req, res) => {
+ console.log(`[mockA] ${req.method} ${req.url}`);
+ res.writeHead(503, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: { message: 'Maximum combo retry limit reached', code: 503 } }));
+});
+
+// ---- Mock B: OpenAI-compatible SSE ----
+const serverB = http.createServer((req, res) => {
+ console.log(`[mockB] ${req.method} ${req.url}`);
+ let body = '';
+ req.on('data', (c) => (body += c));
+ req.on('end', () => {
+ if (!req.url.includes('/chat/completions')) {
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ data: [{ id: 'good-model' }] }));
+ return;
+ }
+ res.writeHead(200, {
+ 'Content-Type': 'text/event-stream',
+ 'Cache-Control': 'no-cache',
+ Connection: 'keep-alive',
+ });
+ const send = (obj) => res.write(`data: ${JSON.stringify(obj)}\n\n`);
+ const base = { id: 'chatcmpl-mock', object: 'chat.completion.chunk', created: Date.now() / 1000 | 0, model: 'good-model' };
+ send({ ...base, choices: [{ index: 0, delta: { role: 'assistant', content: 'FALLBACK-OK' }, finish_reason: null }] });
+ send({ ...base, choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] });
+ res.write('data: [DONE]\n\n');
+ res.end();
+ });
+});
+
+function mkProvider(id, port, model) {
+ return {
+ cli_command: 'openclaude',
+ env_vars: {
+ CLAUDE_CODE_USE_OPENAI: '1',
+ OPENAI_BASE_URL: `http://127.0.0.1:${port}/v1`,
+ OPENAI_API_KEY: `${id}-key`,
+ OPENAI_MODEL: model,
+ },
+ active: id,
+ mode: 'code',
+ fallback_models: [],
+ fallback_providers: id === 'mockbad' ? ['mockgood'] : [],
+ model_tiers: {},
+ };
+}
+
+async function main() {
+ await new Promise((r) => serverA.listen(45031, '127.0.0.1', r));
+ await new Promise((r) => serverB.listen(45032, '127.0.0.1', r));
+
+ const fakeConfig = {
+ ...mkProvider('mockbad', 45031, 'bad-model'),
+ providers: {
+ mockbad: mkProvider('mockbad', 45031, 'bad-model'),
+ mockgood: mkProvider('mockgood', 45032, 'good-model'),
+ },
+ };
+
+ // Injeta o loadProviderConfig fake ANTES de carregar o chat-bridge
+ const providerConfigMod = require(path.join(TS_ROOT, 'src/provider-config'));
+ providerConfigMod.loadProviderConfig = () => JSON.parse(JSON.stringify(fakeConfig));
+ const { ChatBridge } = require(path.join(TS_ROOT, 'src/chat-bridge'));
+
+ const bridge = new ChatBridge();
+ const events = [];
+ let done, fail;
+ const finished = new Promise((res, rej) => { done = res; fail = rej; });
+ const timer = setTimeout(() => fail(new Error('TIMEOUT 120s')), 120000);
+
+ await bridge.startSession('e2e-test', {
+ agentName: null,
+ workingDir: '/tmp',
+ prompt: 'diga apenas: oi',
+ onMessage: (m) => {
+ events.push(m);
+ if (m.type === 'text_delta' || m.type === 'result') {
+ console.log('[event]', JSON.stringify(m).slice(0, 200));
+ }
+ },
+ onError: (err) => { clearTimeout(timer); fail(err); },
+ onComplete: () => { clearTimeout(timer); done(); },
+ });
+
+ try {
+ await finished;
+ const text = events.filter((e) => e.type === 'text_delta').map((e) => e.text).join('');
+ console.log('\n=== RESULTADO ===');
+ console.log('completou sem crash:', true);
+ console.log('texto recebido:', JSON.stringify(text.slice(0, 200)));
+ const errResults = events.filter((e) => e.type === 'result' && e.isError);
+ console.log('results de erro entregues ao UI:', errResults.length);
+ process.exit(0);
+ } catch (err) {
+ console.error('\n=== FALHOU ===');
+ console.error(err.message || err);
+ process.exit(1);
+ } finally {
+ serverA.close(); serverB.close();
+ }
+}
+
+main();
diff --git a/dashboard/terminal-server/test/provider-config.test.js b/dashboard/terminal-server/test/provider-config.test.js
new file mode 100644
index 00000000..592bd4e1
--- /dev/null
+++ b/dashboard/terminal-server/test/provider-config.test.js
@@ -0,0 +1,58 @@
+const assert = require('assert/strict');
+const test = require('node:test');
+
+const {
+ getProviderMode,
+ isCodeModel,
+} = require('../src/provider-config');
+
+test('getProviderMode returns anthropic for the native provider', () => {
+ assert.equal(getProviderMode({ active: 'anthropic' }), 'anthropic');
+});
+
+test('getProviderMode falls back to the model-name heuristic', () => {
+ assert.equal(
+ getProviderMode({ active: 'custom', env_vars: { OPENAI_MODEL: 'qwen-coder' } }),
+ 'code'
+ );
+ assert.equal(
+ getProviderMode({ active: 'custom', env_vars: { OPENAI_MODEL: 'openrouter/owl-alpha' } }),
+ 'chat'
+ );
+});
+
+test('explicit mode overrides the model-name heuristic', () => {
+ assert.equal(
+ getProviderMode({ active: 'openrouter', mode: 'code', env_vars: { OPENAI_MODEL: 'openrouter/owl-alpha' } }),
+ 'code'
+ );
+ assert.equal(
+ getProviderMode({ active: 'openrouter', mode: 'chat', env_vars: { OPENAI_MODEL: 'qwen-coder' } }),
+ 'chat'
+ );
+});
+
+test('invalid mode values are ignored', () => {
+ assert.equal(
+ getProviderMode({ active: 'openrouter', mode: 'bogus', env_vars: { OPENAI_MODEL: 'openrouter/owl-alpha' } }),
+ 'code'
+ );
+});
+
+test('OpenClaude terminal providers default to code mode for opaque hosted models', () => {
+ assert.equal(
+ getProviderMode({ active: 'omnirouter', env_vars: { OPENAI_MODEL: 'z-ai/glm-5.2' } }),
+ 'code'
+ );
+ assert.equal(
+ getProviderMode({ active: 'nvidia', env_vars: { OPENAI_MODEL: 'z-ai/glm-5.2' } }),
+ 'code'
+ );
+});
+
+test('isCodeModel recognizes codex aliases and coder names', () => {
+ assert.equal(isCodeModel('codexplan'), true);
+ assert.equal(isCodeModel('codexspark'), true);
+ assert.equal(isCodeModel('devstral-small'), true);
+ assert.equal(isCodeModel('gpt-4.1'), false);
+});
diff --git a/docs/agents/engineering-layer.md b/docs/agents/engineering-layer.md
index 3eb6fb31..38c17b82 100644
--- a/docs/agents/engineering-layer.md
+++ b/docs/agents/engineering-layer.md
@@ -119,7 +119,7 @@ Skills that orchestrate the engineering agents.
### Tier 3 — Meta / utilities (5)
- `dev-cancel` — clean stop of any active eng workflow
-- `dev-remember` — quick context persistence (lighter than mempalace)
+- `dev-remember` — quick context persistence (lighter than mempalace). See [MemPalace Guide](mempalace-guide.md) for the full memory reference.
- `dev-ask` — single-shot query to a specific model (Claude/Codex/Gemini)
- `dev-learner` — extract reusable skills from conversation patterns
- `dev-skillify` — convert current conversation into a new skill
diff --git a/docs/agents/mempalace-guide.md b/docs/agents/mempalace-guide.md
new file mode 100644
index 00000000..18a075f9
--- /dev/null
+++ b/docs/agents/mempalace-guide.md
@@ -0,0 +1,131 @@
+# Guia curto — MemPalace para agentes
+
+Este guia diz, em uma página, **quando** um agente deve usar MemPalace e **como** ler/escrever memória no workspace. Para o brief técnico completo, veja `workspace/development/research/[C]research-mempalace-agent-guide-2026-06-16.md`.
+
+## 30-seconds cheatsheet
+
+- **Buscar:** `evo.get("/api/mempalace/search", params={"q": "...", "n": 5})`
+- **Indexar:** `evo.post("/api/mempalace/mine")`
+- **Status:** `evo.get("/api/mempalace/status")`
+- **Privado (1 agente):** `.claude/agent-memory/{agent}/`
+- **Compartilhado (todos):** `memory/*.md`
+- **Semântico (grande volume):** MemPalace
+
+## TL;DR
+
+| Onde | Use para | Como acessar |
+|---|---|---|
+| `.claude/agent-memory/{agent}/` | Notas privadas de um agente (decisões, gotchas, padrões de uma sessão) | Leitura/escrita de arquivo `.md` |
+| `memory/*.md` | Conhecimento compartilhado entre **todos** os agentes (pessoas, projetos, glossário, contexto) | Leitura/escrita de arquivo `.md`; o conteúdo vira hot cache via `CLAUDE.md` |
+| **MemPalace** (`dashboard/data/mempalace/`) | Busca semântica + BM25 sobre fontes grandes (código, docs, transcrições) | API REST `/api/mempalace/*`, MCP, CLI ou SDK Python |
+
+`memory/*.md` e MemPalace **não competem**: o markdown é a fonte curada, o MemPalace é o índice de busca.
+
+## Quando usar qual
+
+```
+Preciso guardar uma informação?
+│
+├── Contexto técnico de UMA sessão (gotcha, decisão local)
+│ → .claude/agent-memory/{agente}/ (markdown)
+│
+├── Conhecimento que TODOS os agentes precisam (pessoa, projeto, glossário)
+│ → memory/*.md (markdown)
+│
+└── Preciso BUSCAR semanticamente em muitos arquivos
+ → MemPalace (índice)
+```
+
+Regra do `dev-remember`: nota compartilhada → `memory/`. Quando o volume e a busca semântica justificarem (centenas de arquivos), use MemPalace.
+
+## Como ler
+
+### API REST (rotinas, heartbeats, scripts)
+
+```python
+from dashboard.backend.sdk_client import evo
+
+hits = evo.get("/api/mempalace/search", params={
+ "q": "como funciona o provider fallback",
+ "wing": "evo-nexus",
+ "n": 5,
+})
+for r in hits.get("results", []):
+ print(f"[{r['similarity']:.2f}] {r['source_file']}")
+```
+
+Outros endpoints úteis:
+
+| Método | Rota | Função |
+|---|---|---|
+| GET | `/api/mempalace/status` | Versão, drawers, wings, rooms, status do mining |
+| GET | `/api/mempalace/sources` | Fontes configuradas |
+| POST | `/api/mempalace/mine` | Dispara reindexação (opcional `source_index`) |
+
+Tudo requer `DASHBOARD_API_TOKEN` (injetado automaticamente pelo `evo` SDK).
+
+### MCP (dentro do Claude Code)
+
+```bash
+claude mcp add mempalace -- python -m mempalace.mcp_server \
+ --palace /home/sistemabritto/Documentos/evo-nexus/dashboard/data/mempalace
+```
+
+Depois, a tool `search_memories` aparece direto na sessão.
+
+### CLI (debug rápido)
+
+```bash
+.venv/bin/mempalace search "autenticação jwt" \
+ --palace dashboard/data/mempalace -n 5
+```
+
+## Como escrever
+
+**Ninguém escreve drawers à mão.** O ciclo é:
+
+1. Escreva o conteúdo em arquivos (`.md`, `.py`, `.ts`, …) dentro de uma fonte registrada.
+2. Dispare a indexação: `POST /api/mempalace/mine`.
+3. Acompanhe `GET /api/mempalace/status` (`mining.phase`, `files_done/total`).
+4. Pronto — buscas já enxergam o conteúdo.
+
+Para fixar `wing` e `rooms` de uma fonte, crie um `mempalace.yaml` na raiz dela. Caso contrário, o worker assume `wing = nome do diretório`, `room = general`.
+
+## Estrutura Wing → Room → Drawer
+
+| Nível | O que é | Exemplo |
+|---|---|---|
+| **Wing** | Projeto ou categoria top-level | `evo-nexus`, `evo-ai`, `docs` |
+| **Room** | Tópico dentro da wing | `architecture`, `decisions`, `technical` |
+| **Drawer** | Chunk de ~800 caracteres + metadata | Indexado automaticamente |
+
+Filtre por `wing`/`room` na busca quando souber o domínio — corta ruído.
+
+## MemPalace vs Knowledge Base (pgvector)
+
+São produtos **diferentes** no mesmo dashboard:
+
+| | MemPalace | Knowledge Base |
+|---|---|---|
+| Storage | ChromaDB local em `dashboard/data/mempalace/` | Postgres + pgvector (BYO) |
+| Escopo | Memória pessoal do workspace, offline | Multi-tenant, API-first para times e produtos (ex.: Evo Academy) |
+| Rota | `/api/mempalace/*` | `/api/knowledge/*` e `/api/knowledge/v1/*` |
+| Skills | — | `knowledge-{query,summarize,ingest,browse,organize,admin}` |
+
+Se a busca é para o agente raciocinar localmente, é MemPalace. Se é para servir clientes externos via API, é Knowledge Base.
+
+## Versão e observações
+
+- **MemPalace 3.4.0** instalado em `.venv/` (verificado em 2026-06-16).
+- Embedding padrão: `all-MiniLM-L6-v2` (384-dim, inglês). Para pt-BR, considere trocar para `embeddinggemma-300m-ONNX` — exige `mempalace repair rebuild-index`.
+- Worker de mining é subprocesso detached: se o dashboard reiniciar no meio, o worker antigo termina sozinho; o `/status` faz PID check no próximo poll.
+- ChromaDB não tem auth própria — a segurança vem do `require_permission` no Flask (`mempalace:view` e `mempalace:manage`).
+
+## Próximos passos sugeridos
+
+- Indexar `memory/` e `workspace/development/` como fontes do MemPalace para deixar todo o conhecimento curado searchable.
+- Atualizar `.claude/skills/dev-remember/SKILL.md` com link direto para este guia.
+
+---
+
+**Brief técnico completo:** `workspace/development/research/[C]research-mempalace-agent-guide-2026-06-16.md`
diff --git a/docs/agents/overview.md b/docs/agents/overview.md
index 8c7e74f0..d3ddec89 100644
--- a/docs/agents/overview.md
+++ b/docs/agents/overview.md
@@ -120,6 +120,8 @@ Memory is organized by type:
Each memory file uses frontmatter (`name`, `description`, `type`) and a `MEMORY.md` index file tracks all entries. Agents read memory at the start of each session and update it as they learn.
+For semantic search over large volumes of content (code, docs, transcripts), use **MemPalace** — the local vector memory system. See [MemPalace Guide](mempalace-guide.md) for the complete reference on when and how to use it.
+
## Custom Agents
You can create your own agents with the `custom-` prefix. Custom agents are gitignored (personal to your workspace) and appear in the dashboard with a gray "custom" badge.
diff --git a/docs/plugin-contract.md b/docs/plugin-contract.md
new file mode 100644
index 00000000..9ca0006c
--- /dev/null
+++ b/docs/plugin-contract.md
@@ -0,0 +1,173 @@
+# EvoNexus Plugin Contract
+
+This document describes the plugin.yaml schema for EvoNexus plugins, including capabilities, validated fields, and host-enforced contracts.
+
+---
+
+## plugin.yaml — Top-Level Fields
+
+```yaml
+schema_version: "1.0" # required; must be "1.0"
+name: string # human-readable name
+slug: string # kebab-case identifier; unique across plugins
+version: string # semver
+description: string
+author: string
+capabilities: # list of declared capabilities (see below)
+ - capability_name
+```
+
+---
+
+## Capabilities
+
+A capability must be declared in `capabilities:` before the corresponding block is used. Unknown capabilities are rejected at install time.
+
+| Capability | Enum value | Purpose |
+|---|---|---|
+| `readonly_data` | `readonly_data` | Expose plugin data to agent queries |
+| `custom_tools` | `custom_tools` | Register callable tools on agents |
+| `public_pages` | `public_pages` | Token-gated public web pages served by host |
+| `safe_uninstall` | `safe_uninstall` | 3-step uninstall wizard with data preservation |
+
+---
+
+## `public_pages` — Token-Gated Public Pages
+
+Requires `capabilities: [public_pages]`.
+
+```yaml
+public_pages:
+ - id: string # unique within this plugin
+ description: string
+ route_prefix: string # e.g. "orders"; becomes /p//orders/
+ token_source:
+ table: string # must start with _ (snake_case)
+ column: string # column holding the access token (snake_case)
+ bundle: string # must start with ui/public/
+ custom_element_name: string # e.g. "my-plugin-orders"
+ auth_mode: token # only "token" supported in v1
+ rate_limit_per_ip: string # e.g. "60/minute"
+ audit_action: string # logged per request
+```
+
+### Routes
+
+| Method | Path | Description |
+|---|---|---|
+| `GET` | `/p///` | Serve the HTML bundle (portal entry) |
+| `GET` | `/p////data` | Run a `public_via`-tagged readonly query |
+| `GET` | `/p////public-assets/` | Serve static assets from `ui/public/` |
+
+All three endpoints:
+1. Validate the token parametrically against `token_source.table/column` (SQL: `SELECT 1 FROM
WHERE = ?`)
+2. Apply rate limiting (60 req/min on portal, 120 req/min on data)
+3. Emit security headers (CSP, X-Content-Type-Options, Referrer-Policy, HSTS)
+4. Write an audit log row
+
+### Linking a `readonly_data` query to a public page
+
+```yaml
+readonly_data:
+ queries:
+ - name: order_summary
+ sql: "SELECT id, status, total FROM nutri_orders WHERE id = :order_id"
+ public_via: orders # id of the public_page above
+ bind_token_param: order_id # parameter name that receives the token value
+```
+
+`public_via` must reference a declared `public_pages[].id`. When set, `bind_token_param` is required; the validated token value is injected at query time.
+
+---
+
+## `safe_uninstall` — 3-Step Uninstall Wizard
+
+Requires `capabilities: [safe_uninstall]`.
+
+```yaml
+safe_uninstall:
+ enabled: bool # true = enforce wizard; false = legacy confirm()
+ block_uninstall: bool # if true, uninstall is unconditionally blocked (409)
+ reason: string # displayed in wizard Step 1 (regulatory context)
+
+ user_confirmation:
+ checkbox_label: string # Step 1 checkbox text
+ typed_phrase: string # Step 3 required phrase (exact match)
+
+ pre_uninstall_hook:
+ script: string # relative path inside plugin dir (e.g. scripts/export.py)
+ output_dir: string # where the export lands (relative to plugin dir)
+ timeout_seconds: int # 1–600
+ must_produce_file: bool # if true, fail if output_dir is empty after hook
+
+ preserved_tables: # tables to rename rather than drop
+ - _tablename # must be prefixed with _
+
+ preserved_host_entities: # host-managed tables with partial row preservation
+ host_table_name:
+ "SQL condition for rows to KEEP"
+ # rows matching NOT (condition) are deleted
+
+ block_uninstall: false
+```
+
+### Host enforcement
+
+When `enabled: true`:
+
+1. **Admin role required** — non-admin users receive 403.
+2. **Confirmation phrase** — `DELETE /api/plugins/` body must include `confirmation_phrase` matching `user_confirmation.typed_phrase`.
+3. **Export verification** — `exported_at` path must be provided and the file must exist.
+4. **ZIP password** — `zip_password` must be present (forwarded to pre-uninstall hook if configured).
+5. **Pre-uninstall hook** — if configured, runs in a sandboxed subprocess with no secret env vars (only `PLUGIN_SLUG`, `PLUGIN_VERSION`, `OUTPUT_DIR`, `DB_READONLY_PATH`). Hook failure aborts uninstall.
+6. **Preserved tables** — tables listed in `preserved_tables` are renamed to `_orphan__` and recorded in `plugin_orphans`. They are **not dropped**.
+7. **Cascade-DELETE filtering** — for tables listed in `preserved_host_entities`, only rows NOT matching the preservation condition are deleted.
+
+### Force-uninstall escape hatch
+
+Setting `EVONEXUS_ALLOW_FORCE_UNINSTALL=1` in the host environment bypasses all safe_uninstall checks. Every force-uninstall is logged as `plugin_uninstall_force` in the audit table with the acting user's identity. This flag is intended for emergency recovery only.
+
+### Reinstall after safe_uninstall
+
+On reinstall of a plugin with orphaned tables:
+
+1. Host checks `plugin_orphans` for unrecovered rows.
+2. If present, compares `tarball_sha256` of the incoming tarball against `original_sha256` recorded at uninstall time.
+3. SHA256 mismatch → install blocked unless request includes `confirmed_sha256_change: true` (explicit operator acknowledgment).
+4. On SHA256 match (or explicit override): orphan tables are renamed back (`_orphan__
` → `
`) before install.sql runs.
+
+### `plugin_orphans` table (host-managed)
+
+```sql
+CREATE TABLE plugin_orphans (
+ id TEXT PRIMARY KEY,
+ slug TEXT NOT NULL,
+ tablename TEXT NOT NULL, -- original name (before _orphan_ prefix)
+ orphaned_at TEXT NOT NULL,
+ orphaned_by_user_id INTEGER,
+ original_plugin_version TEXT,
+ original_sha256 TEXT,
+ original_publisher_url TEXT,
+ recovered_at TEXT, -- NULL until reinstall recovery
+ UNIQUE(slug, tablename)
+);
+```
+
+---
+
+## Security Notes
+
+- Plugin SQL identifiers (`table`, `column`) are validated at install time against `^[a-z][a-z0-9_]*$`. The host never interpolates untrusted input into SQL identifiers.
+- Token values in public-page routes are always bound as SQL parameters (`?`), never interpolated.
+- Pre-uninstall hooks run with a read-only DB copy; no write access and no secret env vars.
+- SQL in `readonly_data.queries` must not reference `_orphan_*` tables (rejected at install via schema validator).
+- Rate limiting is applied at the IP level on all public endpoints (flask-limiter, in-memory storage, single-process).
+
+---
+
+## Changelog
+
+| Version | Change |
+|---|---|
+| v1.0.0 | Initial contract: `readonly_data`, `custom_tools` |
+| v1.1.0 | Added `public_pages` (B2) and `safe_uninstall` (B3) capabilities |
diff --git a/entrypoint.sh b/entrypoint.sh
index eb1e8870..5d8dd668 100755
--- a/entrypoint.sh
+++ b/entrypoint.sh
@@ -60,6 +60,17 @@ if [ -d "$DEFAULTS_DIR" ]; then
done
fi
+# --- 2b. Seed/refresh /workspace/.claude from image defaults ---------------
+# /workspace/.claude may be a named volume (persists custom-* skills/agents/
+# commands and plugin-* artifacts across restarts). Built-ins are re-copied
+# from the image on every boot so upgrades propagate; anything not shipped
+# in the image (custom-*, plugin-*, settings.local.json) is left untouched.
+# agent-memory is not in the stash — it has its own volume.
+if [ -d "$DEFAULTS_DIR/claude" ]; then
+ mkdir -p /workspace/.claude
+ cp -a "$DEFAULTS_DIR/claude/." /workspace/.claude/ 2>/dev/null || true
+fi
+
# --- 3. Ensure EVONEXUS_SECRET_KEY exists (Flask session signing) ----------
# Without this, Flask invalidates every session on restart. We generate it
# once on first boot and persist it in the same .env the UI edits.
@@ -107,11 +118,45 @@ fi
# --- 5. Source .env (UI-configured values become env vars) -----------------
# Using `set -a` so every variable assigned here is auto-exported.
+#
+# Confirmed live 2026-07-14: .env.example ships secrets meant to come from
+# the Swarm stack's `environment:` section (e.g. DASHBOARD_API_TOKEN=, no
+# default) as blank lines — first boot copies that verbatim into
+# $CONFIG_DIR/.env (step 2 above). Docker sets the real value on the
+# container BEFORE this script runs, but sourcing .env here with `set -a`
+# blindly re-exports every line in it, including blank ones, silently
+# clobbering correct values with an empty string. DASHBOARD_API_TOKEN doing
+# this broke every Bearer-token API call with "Authentication required" —
+# not because the token was wrong, but because the server-side value had
+# already been erased by the time Flask read it, while `docker exec ...
+# printenv` (a fresh process, never ran this script) still showed the real
+# one, which made it look like a token mismatch instead of an overwrite.
+# DASHBOARD_API_USER has the same blank-in-.env.example /
+# set-in-stack shape (lower severity — it has a documented "first admin"
+# fallback — but the same silent-clobber risk if the stack ever pins a
+# specific user). Generalized fix: snapshot every exported var Docker may
+# have injected, source .env, then restore any that ended up empty when
+# they weren't before — non-empty file values still win (so the
+# Providers/Settings UI can update them), only genuinely empty ones are
+# prevented from erasing a real value. Covers the whole class, not just
+# the two instances found so far.
+declare -A _PRE_ENV
+while IFS= read -r _name; do
+ _PRE_ENV["$_name"]="${!_name}"
+done < <(compgen -e)
+
set -a
# shellcheck disable=SC1091
. "$CONFIG_DIR/.env" 2>/dev/null || true
set +a
+for _name in "${!_PRE_ENV[@]}"; do
+ if [ -z "${!_name:-}" ] && [ -n "${_PRE_ENV[$_name]}" ]; then
+ export "$_name=${_PRE_ENV[$_name]}"
+ fi
+done
+unset _PRE_ENV _name
+
# --- 6. Optional: _FILE env vars (explicit Docker Secrets pattern) ---------
for file_var in $(compgen -A variable | grep -E '_FILE$' || true); do
var="${file_var%_FILE}"
@@ -137,17 +182,102 @@ fi
# a key. Instead of crash-looping, we wait and re-read .env every 30s. When
# the user saves the key in dashboard → Providers, it lands in .env and
# we pick it up on the next iteration — no manual restart needed.
+#
+# Confirmed live 2026-07-14: this gate only ever checked ANTHROPIC_API_KEY,
+# hardcoded — a workspace whose active_provider is opencode/openclaude
+# (credentials live in config/providers.json's per-provider env_vars, not a
+# top-level ANTHROPIC_API_KEY) waits here FOREVER, even with a fully working
+# non-Anthropic provider configured. scheduler.py never even starts, so no
+# routine — core or custom — ever runs; this was mistaken for a
+# config/routines.yaml bug before the entrypoint log revealed the real
+# blocker. Fix: also unblock when config/providers.json's active provider
+# has a real (non-empty, non-"[REDACTED]") credential — same check
+# _get_provider_config() in ADWs/runner.py already does at call time.
+_has_usable_provider() {
+ [ -n "${ANTHROPIC_API_KEY:-}" ] && return 0
+ _PYBIN="/workspace/.venv/bin/python3"
+ [ -x "$_PYBIN" ] || _PYBIN="$(command -v python3 || true)"
+ [ -n "$_PYBIN" ] || return 1
+ "$_PYBIN" -c "
+import json, sys
+try:
+ cfg = json.load(open('$CONFIG_DIR/providers.json'))
+except Exception:
+ sys.exit(1)
+active = cfg.get('active_provider')
+provider = (cfg.get('providers') or {}).get(active) or {}
+env_vars = provider.get('env_vars') or {}
+key = env_vars.get('OPENAI_API_KEY') or env_vars.get('ANTHROPIC_API_KEY') or ''
+sys.exit(0 if key and key != '[REDACTED]' else 1)
+" 2>/dev/null
+}
+
if [ "${REQUIRE_ANTHROPIC_KEY:-0}" = "1" ]; then
- while [ -z "${ANTHROPIC_API_KEY:-}" ]; do
- echo "[$(date -Is)] waiting for ANTHROPIC_API_KEY — configure via dashboard → Providers" >&2
+ while ! _has_usable_provider; do
+ echo "[$(date -Is)] waiting for a usable provider (ANTHROPIC_API_KEY or an active provider with a real key in Providers) — configure via dashboard → Providers" >&2
sleep 30
set -a
# shellcheck disable=SC1091
. "$CONFIG_DIR/.env" 2>/dev/null || true
set +a
done
- echo "[$(date -Is)] ANTHROPIC_API_KEY detected — starting $*" >&2
+ echo "[$(date -Is)] usable provider detected — starting $*" >&2
+fi
+
+# --- 8b. Claude CLI headless bootstrap --------------------------------------
+# Heartbeats/routines invoke `claude --print --dangerously-skip-permissions`
+# as root. Two first-run gates block that in a fresh container:
+# 1) /root/.claude.json lives in the container layer (wiped on redeploy);
+# without the trust flags for /workspace the CLI ignores the project's
+# .claude/settings.json permissions and fails with "this workspace has
+# not been trusted".
+# 2) Claude Code refuses --dangerously-skip-permissions as root unless
+# IS_SANDBOX=1 signals a containerized environment.
+# Same fix as start-dashboard.sh / telegram_swarm_entry.sh.
+export IS_SANDBOX="${IS_SANDBOX:-1}"
+# Restore the latest backup BEFORE seeding/patching. The main config is a
+# sibling of /root/.claude/ (the volume) and is wiped on redeploy; blind
+# seeding here would shadow the backup restore downstream (telegram wrapper /
+# start-dashboard check "[ ! -f ]") and lose account state — e.g. the
+# channels feature gate — that only the backup carries.
+if [ ! -f /root/.claude.json ]; then
+ _latest_backup=$(ls -t /root/.claude/backups/.claude.json.backup.* 2>/dev/null | head -n1 || true)
+ if [ -n "${_latest_backup:-}" ] && [ -f "$_latest_backup" ]; then
+ echo "[$(date -Is)] restoring /root/.claude.json from $_latest_backup" >&2
+ cp "$_latest_backup" /root/.claude.json
+ fi
+fi
+unset _latest_backup
+_PYBIN="/workspace/.venv/bin/python3"
+[ -x "$_PYBIN" ] || _PYBIN="$(command -v python3 || true)"
+if [ -n "$_PYBIN" ]; then
+ "$_PYBIN" - <<'EOF' || echo "[$(date -Is)] WARNING: could not patch /root/.claude.json flags" >&2
+import json, os
+
+path = "/root/.claude.json"
+cfg = {}
+if os.path.exists(path):
+ try:
+ with open(path) as f:
+ cfg = json.load(f)
+ except Exception:
+ cfg = {}
+
+cfg.setdefault("theme", "dark")
+cfg["hasCompletedOnboarding"] = True
+cfg["hasSeenWelcome"] = True
+cfg["bypassPermissionsModeAccepted"] = True
+project = cfg.setdefault("projects", {}).setdefault("/workspace", {})
+project["hasTrustDialogAccepted"] = True
+project["hasCompletedProjectOnboarding"] = True
+
+with open(path, "w") as f:
+ json.dump(cfg, f, indent=2)
+EOF
+else
+ echo "[$(date -Is)] WARNING: no python3 — /root/.claude.json not patched" >&2
fi
+unset _PYBIN
# --- 9. Hand off to the actual process -------------------------------------
exec "$@"
diff --git a/evonexus-vps.stack.example.yml b/evonexus-vps.stack.example.yml
new file mode 100644
index 00000000..4083fa8f
--- /dev/null
+++ b/evonexus-vps.stack.example.yml
@@ -0,0 +1,290 @@
+## ============================================================================
+## EvoNexus — Stack de exemplo para VPS / Docker Swarm (Portainer)
+##
+## Pré-requisitos na VPS:
+## * Docker Swarm inicializado (docker swarm init)
+## * Traefik já rodando e conectado à rede externa `network_public`
+## - entrypoint TLS: websecure
+## - cert resolver: letsencryptresolver
+## * Um domínio apontando para a VPS (A record)
+##
+## Como usar (Portainer → Stacks → Add stack → Web editor):
+## 1. Cole este arquivo.
+## 2. Preencha as variáveis de ambiente da stack (aba "Environment variables"):
+## EVONEXUS_DOMAIN → seu domínio (ex.: nexus.seudominio.com.br)
+## DASHBOARD_API_TOKEN → gere com: openssl rand -base64 32
+## As SMTP_* são opcionais (notificações por email).
+## 3. Deploy. No primeiro boot, acesse https://SEU_DOMINIO e configure
+## providers (NVIDIA/OpenRouter/Codex...), integrações e tokens pela UI —
+## esta stack não contém nenhuma credencial de propósito.
+##
+## Telegram (opcional):
+## * Crie um bot no @BotFather e salve o token em dashboard → Integrations
+## (TELEGRAM_BOT_TOKEN) e seu chat id (TELEGRAM_CHAT_ID).
+## * TELEGRAM_MODE=provider → o bot responde pelo provider ativo do
+## dashboard (NVIDIA, OmniRouter, OpenRouter, Codex...). O modo
+## `channels` (nativo do Claude Code) exige login claude.ai no container
+## (`docker exec -it claude /login`) e NÃO funciona via providers
+## OpenAI-compatíveis.
+## * Cada deploy precisa do SEU próprio bot/token — dois pollers no mesmo
+## token brigam (HTTP 409) e um rouba as mensagens do outro.
+## ============================================================================
+version: "3.7"
+
+services:
+
+ evonexus_dashboard:
+ image: excarplex/evo-nexus-dashboard:latest
+
+ volumes:
+ - evonexus_config:/workspace/config
+ - evonexus_workspace:/workspace/workspace
+ - evonexus_dashboard_data:/workspace/dashboard/data
+ - evonexus_memory:/workspace/memory
+ - evonexus_backups:/workspace/backups
+ - evonexus_adw_logs:/workspace/ADWs/logs
+ - evonexus_claude_workspace:/workspace/.claude
+ - evonexus_agent_memory:/workspace/.claude/agent-memory
+ - evonexus_claude_auth:/root/.claude
+ - evonexus_codex_auth:/root/.codex
+
+ networks:
+ network_public:
+ # Alias fixo para os outros serviços acharem a API independente do
+ # nome que você der à stack no Portainer.
+ aliases:
+ - evonexus-dashboard
+
+ environment:
+ - DASHBOARD_API_TOKEN=${DASHBOARD_API_TOKEN}
+ - DASHBOARD_API_USER=${DASHBOARD_API_USER:-admin}
+ - EVONEXUS_API_URL=http://localhost:8080
+ - CORS_ALLOWED_ORIGINS=https://${EVONEXUS_DOMAIN}
+ - TZ=${TZ:-America/Sao_Paulo}
+ - EVONEXUS_PORT=8080
+ - TERMINAL_SERVER_PORT=32352
+ - FORWARDED_ALLOW_IPS=*
+ - SMTP_DOMAIN=${SMTP_DOMAIN}
+ - SMTP_USERNAME=${SMTP_USERNAME}
+ - SMTP_PASSWORD=${SMTP_PASSWORD}
+ - SMTP_ADDRESS=${SMTP_ADDRESS}
+ - SMTP_PORT=${SMTP_PORT}
+ - SMTP_AUTHENTICATION=${SMTP_AUTHENTICATION}
+ - SMTP_ENABLE_STARTTLS_AUTO=${SMTP_ENABLE_STARTTLS_AUTO}
+
+ deploy:
+ placement:
+ constraints:
+ - node.role == manager
+ resources:
+ limits:
+ cpus: "1"
+ memory: 1024M
+ labels:
+ - traefik.enable=true
+ - traefik.docker.network=network_public
+
+ ## Flask API + React SPA em :8080
+ - traefik.http.routers.evonexus_dashboard.rule=Host(`${EVONEXUS_DOMAIN}`)
+ - traefik.http.routers.evonexus_dashboard.entrypoints=websecure
+ - traefik.http.routers.evonexus_dashboard.priority=1
+ - traefik.http.routers.evonexus_dashboard.tls.certresolver=letsencryptresolver
+ - traefik.http.routers.evonexus_dashboard.service=evonexus_dashboard
+ - traefik.http.services.evonexus_dashboard.loadbalancer.server.port=8080
+ - traefik.http.services.evonexus_dashboard.loadbalancer.passHostHeader=true
+
+ ## Terminal-server embutido em :32352. /terminal é removido do path.
+ - traefik.http.routers.evonexus_terminal.rule=Host(`${EVONEXUS_DOMAIN}`) && PathPrefix(`/terminal`)
+ - traefik.http.routers.evonexus_terminal.entrypoints=websecure
+ - traefik.http.routers.evonexus_terminal.priority=10
+ - traefik.http.routers.evonexus_terminal.tls.certresolver=letsencryptresolver
+ - traefik.http.routers.evonexus_terminal.service=evonexus_terminal
+ - traefik.http.routers.evonexus_terminal.middlewares=evonexus_terminal_strip
+ - traefik.http.middlewares.evonexus_terminal_strip.stripprefix.prefixes=/terminal
+ - traefik.http.services.evonexus_terminal.loadbalancer.server.port=32352
+ - traefik.http.services.evonexus_terminal.loadbalancer.passHostHeader=true
+
+ evonexus_telegram:
+ image: excarplex/evo-nexus-runtime:latest
+ # Wrapper com dois modos (TELEGRAM_MODE=channels|provider, default auto):
+ # provider — telegram_provider_bot.py no provider ativo do dashboard
+ # (NVIDIA, OmniRouter, OpenRouter, Codex...). Recomendado.
+ # channels — claude --channels direto na Anthropic; requer claude /login
+ # no volume de auth e plano com channels habilitado.
+ command: ["bash", "scripts/telegram_swarm_entry.sh"]
+
+ volumes:
+ - evonexus_config:/workspace/config
+ - evonexus_workspace:/workspace/workspace
+ - evonexus_memory:/workspace/memory
+ - evonexus_adw_logs:/workspace/ADWs/logs
+ - evonexus_claude_workspace:/workspace/.claude
+ - evonexus_agent_memory:/workspace/.claude/agent-memory
+ - evonexus_claude_auth:/root/.claude
+ - evonexus_codex_auth:/root/.codex
+
+ networks:
+ - network_public
+
+ environment:
+ - DASHBOARD_API_TOKEN=${DASHBOARD_API_TOKEN}
+ - DASHBOARD_API_USER=${DASHBOARD_API_USER:-admin}
+ - EVONEXUS_API_URL=http://evonexus-dashboard:8080
+ - TELEGRAM_MODE=${TELEGRAM_MODE:-provider}
+ - TZ=${TZ:-America/Sao_Paulo}
+ - SMTP_DOMAIN=${SMTP_DOMAIN}
+ - SMTP_USERNAME=${SMTP_USERNAME}
+ - SMTP_PASSWORD=${SMTP_PASSWORD}
+ - SMTP_ADDRESS=${SMTP_ADDRESS}
+ - SMTP_PORT=${SMTP_PORT}
+ - SMTP_AUTHENTICATION=${SMTP_AUTHENTICATION}
+ - SMTP_ENABLE_STARTTLS_AUTO=${SMTP_ENABLE_STARTTLS_AUTO}
+
+ stdin_open: true
+ tty: true
+
+ deploy:
+ placement:
+ constraints:
+ - node.role == manager
+ resources:
+ limits:
+ cpus: "1"
+ memory: 1024M
+
+ evonexus_scheduler:
+ image: excarplex/evo-nexus-runtime:latest
+ command: ["uv", "run", "python", "scheduler.py"]
+
+ volumes:
+ - evonexus_config:/workspace/config
+ - evonexus_workspace:/workspace/workspace
+ - evonexus_memory:/workspace/memory
+ - evonexus_adw_logs:/workspace/ADWs/logs
+ - evonexus_claude_workspace:/workspace/.claude
+ - evonexus_agent_memory:/workspace/.claude/agent-memory
+ - evonexus_codex_auth:/root/.codex
+
+ networks:
+ - network_public
+
+ environment:
+ - DASHBOARD_API_TOKEN=${DASHBOARD_API_TOKEN}
+ - DASHBOARD_API_USER=${DASHBOARD_API_USER:-admin}
+ - EVONEXUS_API_URL=http://evonexus-dashboard:8080
+ - TZ=${TZ:-America/Sao_Paulo}
+ ## O entrypoint espera em loop de 30s até a ANTHROPIC_API_KEY aparecer
+ ## no .env (configurada via dashboard → Providers). Sem crash-loop
+ ## enquanto o usuário ainda está fazendo o onboarding.
+ - REQUIRE_ANTHROPIC_KEY=1
+ - SMTP_DOMAIN=${SMTP_DOMAIN}
+ - SMTP_USERNAME=${SMTP_USERNAME}
+ - SMTP_PASSWORD=${SMTP_PASSWORD}
+ - SMTP_ADDRESS=${SMTP_ADDRESS}
+ - SMTP_PORT=${SMTP_PORT}
+ - SMTP_AUTHENTICATION=${SMTP_AUTHENTICATION}
+ - SMTP_ENABLE_STARTTLS_AUTO=${SMTP_ENABLE_STARTTLS_AUTO}
+
+ deploy:
+ placement:
+ constraints:
+ - node.role == manager
+ resources:
+ limits:
+ cpus: "1"
+ memory: 1024M
+
+ ## --------------------------------------------------------------------------
+ ## OPCIONAL — OmniRoute (https://github.com/diegosouzapw/OmniRoute, MIT)
+ ## Gateway de IA local: roteia 237+ providers (90+ com free tier) por um
+ ## único endpoint OpenAI-compatível, com fallback automático, compressão de
+ ## tokens e bridges de protocolo. Rodando DENTRO da stack, o provider
+ ## "omnirouter" do EvoNexus aponta para http://omniroute:20128/v1 e nunca
+ ## depende de um gateway externo fora do ar.
+ ##
+ ## Setup pós-deploy:
+ ## 1. Acesse o dashboard do OmniRoute (via túnel SSH, mais seguro:
+ ## ssh -L 20128:127.0.0.1:20128 root@SUA_VPS
+ ## + `docker service update --publish-add published=20128,target=20128,mode=host _omniroute`
+ ## ou exponha via Traefik com autenticação — veja labels comentadas).
+ ## 2. Conecte providers (NVIDIA NIM, OpenRouter, Codex OAuth, ...).
+ ## 3. Gere uma API key em "Endpoints".
+ ## 4. No EvoNexus (dashboard → Providers → OMNIROUTER):
+ ## OPENAI_BASE_URL = http://omniroute:20128/v1
+ ## OPENAI_API_KEY = a key gerada
+ ## OPENAI_MODEL = auto (roteamento com fallback automático)
+ ## ATENÇÃO: o volume guarda keys de providers — não exponha o dashboard
+ ## publicamente sem autenticação (REQUIRE_API_KEY=true + auth no Traefik).
+ ## --------------------------------------------------------------------------
+ omniroute:
+ image: diegosouzapw/omniroute:latest
+
+ volumes:
+ - omniroute_data:/app/data
+
+ networks:
+ network_public:
+ aliases:
+ - omniroute
+
+ ## Auth NATIVA do OmniRoute (login com INITIAL_PASSWORD, sessão JWT em
+ ## cookie). NÃO coloque basic-auth do Traefik na frente do dashboard: as
+ ## chamadas internas (SSE/WS/API) usam Authorization próprio e entram em
+ ## loop de 401 com o basic-auth do proxy.
+ ## Preencha via env da stack no Portainer:
+ ## OMNIROUTE_DOMAIN → subdomínio do dashboard (ex.: omni.seudominio.com.br)
+ ## OMNIROUTE_INITIAL_PASSWORD → senha de login do dashboard
+ ## OMNIROUTE_JWT_SECRET → openssl rand -base64 48
+ ## OMNIROUTE_API_KEY_SECRET → openssl rand -hex 32
+ ## OMNIROUTE_STORAGE_KEY → openssl rand -hex 32
+ environment:
+ - TZ=${TZ:-America/Sao_Paulo}
+ - PORT=20128
+ - REQUIRE_API_KEY=true
+ - INITIAL_PASSWORD=${OMNIROUTE_INITIAL_PASSWORD}
+ - JWT_SECRET=${OMNIROUTE_JWT_SECRET}
+ - API_KEY_SECRET=${OMNIROUTE_API_KEY_SECRET}
+ - STORAGE_ENCRYPTION_KEY=${OMNIROUTE_STORAGE_KEY}
+ - AUTH_COOKIE_SECURE=true
+ - OMNIROUTE_TRUST_PROXY=true
+ - NEXT_PUBLIC_BASE_URL=https://${OMNIROUTE_DOMAIN}
+ - OMNIROUTE_PUBLIC_BASE_URL=https://${OMNIROUTE_DOMAIN}
+ - CORS_ALLOWED_ORIGINS=https://${OMNIROUTE_DOMAIN}
+
+ deploy:
+ placement:
+ constraints:
+ - node.role == manager
+ resources:
+ limits:
+ cpus: "1"
+ memory: 1024M
+ ## Para expor o dashboard do OmniRoute via Traefik (auth nativa acima
+ ## protege o acesso), descomente:
+ # labels:
+ # - traefik.enable=true
+ # - traefik.docker.network=network_public
+ # - traefik.http.routers.omniroute.rule=Host(`${OMNIROUTE_DOMAIN}`)
+ # - traefik.http.routers.omniroute.entrypoints=websecure
+ # - traefik.http.routers.omniroute.tls.certresolver=letsencryptresolver
+ # - traefik.http.routers.omniroute.service=omniroute
+ # - traefik.http.services.omniroute.loadbalancer.server.port=20128
+ # - traefik.http.services.omniroute.loadbalancer.passHostHeader=true
+
+volumes:
+ evonexus_config:
+ evonexus_workspace:
+ evonexus_dashboard_data:
+ evonexus_memory:
+ evonexus_backups:
+ evonexus_adw_logs:
+ evonexus_claude_workspace:
+ evonexus_agent_memory:
+ evonexus_claude_auth:
+ evonexus_codex_auth:
+ omniroute_data:
+
+networks:
+ network_public:
+ external: true
+ name: network_public
diff --git a/evonexus-vps.stack.yml b/evonexus-vps.stack.yml
new file mode 100644
index 00000000..0effec3b
--- /dev/null
+++ b/evonexus-vps.stack.yml
@@ -0,0 +1,250 @@
+## ============================================================================
+## EvoNexus — VPS / Docker Swarm stack (pessoal — Sistema Britto)
+##
+## Target VPS:
+## * Traefik already attached to external network_public
+## * TLS entrypoint: websecure
+## * Cert resolver: letsencryptresolver
+## * Public hosts: nexus.workflowapi.com.br (EvoNexus)
+## omni.workflowapi.com.br (OmniRoute)
+##
+## Segredos ficam FORA do repo: os valores reais (DASHBOARD_API_TOKEN,
+## OMNIROUTE_*, SMTP_*) são preenchidos como variáveis de ambiente
+## da stack no Portainer. Os placeholders abaixo marcam onde entram.
+## DASHBOARD_API_TOKEN → openssl rand -base64 32
+## OMNIROUTE_INITIAL_PASSWORD → openssl rand -base64 12 (senha de login)
+## OMNIROUTE_JWT_SECRET → openssl rand -base64 48
+## OMNIROUTE_API_KEY_SECRET → openssl rand -hex 32
+## OMNIROUTE_STORAGE_KEY → openssl rand -hex 32
+## ============================================================================
+version: "3.7"
+
+services:
+
+ evonexus_dashboard:
+ image: excarplex/evo-nexus-dashboard:latest
+
+ volumes:
+ - evonexus_config:/workspace/config
+ - evonexus_workspace:/workspace/workspace
+ - evonexus_dashboard_data:/workspace/dashboard/data
+ - evonexus_memory:/workspace/memory
+ - evonexus_backups:/workspace/backups
+ - evonexus_adw_logs:/workspace/ADWs/logs
+ - evonexus_claude_workspace:/workspace/.claude
+ - evonexus_agent_memory:/workspace/.claude/agent-memory
+ - evonexus_claude_auth:/root/.claude
+ - evonexus_codex_auth:/root/.codex
+
+ networks:
+ network_public:
+ aliases:
+ - evonexus-dashboard
+
+ environment:
+ - DASHBOARD_API_TOKEN=${DASHBOARD_API_TOKEN}
+ - DASHBOARD_API_USER=${DASHBOARD_API_USER:-admin}
+ - EVONEXUS_API_URL=http://localhost:8080
+ - CORS_ALLOWED_ORIGINS=https://nexus.workflowapi.com.br
+ - TZ=America/Sao_Paulo
+ - EVONEXUS_PORT=8080
+ - TERMINAL_SERVER_PORT=32352
+ - FORWARDED_ALLOW_IPS=*
+ - SMTP_DOMAIN=${SMTP_DOMAIN}
+ - SMTP_USERNAME=${SMTP_USERNAME}
+ - SMTP_PASSWORD=${SMTP_PASSWORD}
+ - SMTP_ADDRESS=${SMTP_ADDRESS}
+ - SMTP_PORT=${SMTP_PORT}
+ - SMTP_AUTHENTICATION=${SMTP_AUTHENTICATION}
+ - SMTP_ENABLE_STARTTLS_AUTO=${SMTP_ENABLE_STARTTLS_AUTO}
+
+ deploy:
+ placement:
+ constraints:
+ - node.role == manager
+ resources:
+ limits:
+ cpus: "1"
+ memory: 1024M
+ labels:
+ - traefik.enable=true
+ - traefik.docker.network=network_public
+
+ ## Flask API + React SPA on :8080
+ - traefik.http.routers.evonexus_dashboard.rule=Host(`nexus.workflowapi.com.br`)
+ - traefik.http.routers.evonexus_dashboard.entrypoints=websecure
+ - traefik.http.routers.evonexus_dashboard.priority=1
+ - traefik.http.routers.evonexus_dashboard.tls.certresolver=letsencryptresolver
+ - traefik.http.routers.evonexus_dashboard.service=evonexus_dashboard
+ - traefik.http.services.evonexus_dashboard.loadbalancer.server.port=8080
+ - traefik.http.services.evonexus_dashboard.loadbalancer.passHostHeader=true
+
+ ## Embedded terminal-server on :32352. /terminal is stripped before proxying.
+ - traefik.http.routers.evonexus_terminal.rule=Host(`nexus.workflowapi.com.br`) && PathPrefix(`/terminal`)
+ - traefik.http.routers.evonexus_terminal.entrypoints=websecure
+ - traefik.http.routers.evonexus_terminal.priority=10
+ - traefik.http.routers.evonexus_terminal.tls.certresolver=letsencryptresolver
+ - traefik.http.routers.evonexus_terminal.service=evonexus_terminal
+ - traefik.http.routers.evonexus_terminal.middlewares=evonexus_terminal_strip
+ - traefik.http.middlewares.evonexus_terminal_strip.stripprefix.prefixes=/terminal
+ - traefik.http.services.evonexus_terminal.loadbalancer.server.port=32352
+ - traefik.http.services.evonexus_terminal.loadbalancer.passHostHeader=true
+
+ evonexus_telegram:
+ image: excarplex/evo-nexus-runtime:latest
+ # TELEGRAM_MODE=provider → telegram_provider_bot.py no provider ativo do
+ # dashboard (NVIDIA, OmniRoute, OpenRouter, Codex...). O modo channels
+ # exige login claude.ai e não funciona via providers OpenAI-compatíveis.
+ command: ["bash", "scripts/telegram_swarm_entry.sh"]
+
+ volumes:
+ - evonexus_config:/workspace/config
+ - evonexus_workspace:/workspace/workspace
+ - evonexus_memory:/workspace/memory
+ - evonexus_adw_logs:/workspace/ADWs/logs
+ - evonexus_claude_workspace:/workspace/.claude
+ - evonexus_agent_memory:/workspace/.claude/agent-memory
+ - evonexus_claude_auth:/root/.claude
+ - evonexus_codex_auth:/root/.codex
+
+ networks:
+ - network_public
+
+ environment:
+ - DASHBOARD_API_TOKEN=${DASHBOARD_API_TOKEN}
+ - DASHBOARD_API_USER=${DASHBOARD_API_USER:-admin}
+ - EVONEXUS_API_URL=http://evonexus-dashboard:8080
+ - TELEGRAM_MODE=provider
+ - TZ=America/Sao_Paulo
+ - SMTP_DOMAIN=${SMTP_DOMAIN}
+ - SMTP_USERNAME=${SMTP_USERNAME}
+ - SMTP_PASSWORD=${SMTP_PASSWORD}
+ - SMTP_ADDRESS=${SMTP_ADDRESS}
+ - SMTP_PORT=${SMTP_PORT}
+ - SMTP_AUTHENTICATION=${SMTP_AUTHENTICATION}
+ - SMTP_ENABLE_STARTTLS_AUTO=${SMTP_ENABLE_STARTTLS_AUTO}
+
+ stdin_open: true
+ tty: true
+
+ deploy:
+ placement:
+ constraints:
+ - node.role == manager
+ resources:
+ limits:
+ cpus: "1"
+ memory: 1024M
+
+ evonexus_scheduler:
+ image: excarplex/evo-nexus-runtime:latest
+ command: ["uv", "run", "python", "scheduler.py"]
+
+ volumes:
+ - evonexus_config:/workspace/config
+ - evonexus_workspace:/workspace/workspace
+ - evonexus_memory:/workspace/memory
+ - evonexus_adw_logs:/workspace/ADWs/logs
+ - evonexus_claude_workspace:/workspace/.claude
+ - evonexus_agent_memory:/workspace/.claude/agent-memory
+ - evonexus_codex_auth:/root/.codex
+
+ networks:
+ - network_public
+
+ environment:
+ - DASHBOARD_API_TOKEN=${DASHBOARD_API_TOKEN}
+ - DASHBOARD_API_USER=${DASHBOARD_API_USER:-admin}
+ - EVONEXUS_API_URL=http://evonexus-dashboard:8080
+ - TZ=America/Sao_Paulo
+ - REQUIRE_ANTHROPIC_KEY=1
+ - SMTP_DOMAIN=${SMTP_DOMAIN}
+ - SMTP_USERNAME=${SMTP_USERNAME}
+ - SMTP_PASSWORD=${SMTP_PASSWORD}
+ - SMTP_ADDRESS=${SMTP_ADDRESS}
+ - SMTP_PORT=${SMTP_PORT}
+ - SMTP_AUTHENTICATION=${SMTP_AUTHENTICATION}
+ - SMTP_ENABLE_STARTTLS_AUTO=${SMTP_ENABLE_STARTTLS_AUTO}
+
+ deploy:
+ placement:
+ constraints:
+ - node.role == manager
+ resources:
+ limits:
+ cpus: "1"
+ memory: 1024M
+
+ ## OmniRoute — gateway de IA (237+ providers, fallback automático,
+ ## compressão de tokens). O provider "omnirouter" do EvoNexus aponta
+ ## para http://omniroute:20128/v1 (DNS interno via alias).
+ ## Dashboard público em omni.workflowapi.com.br com auth nativa (login).
+ omniroute:
+ image: diegosouzapw/omniroute:latest
+
+ volumes:
+ - omniroute_data:/app/data
+
+ networks:
+ network_public:
+ aliases:
+ - omniroute
+
+ ## Auth NATIVA do OmniRoute (login com INITIAL_PASSWORD, sessão JWT em
+ ## cookie). NÃO usar basic-auth do Traefik na frente: as chamadas
+ ## internas do dashboard (SSE/WS/API) usam Authorization próprio e
+ ## entram em loop de 401 com o basic-auth.
+ ## Segredos via env da stack no Portainer:
+ ## OMNIROUTE_INITIAL_PASSWORD → senha de login do dashboard
+ ## OMNIROUTE_JWT_SECRET → openssl rand -base64 48
+ ## OMNIROUTE_API_KEY_SECRET → openssl rand -hex 32
+ ## OMNIROUTE_STORAGE_KEY → openssl rand -hex 32
+ environment:
+ - TZ=America/Sao_Paulo
+ - PORT=20128
+ - REQUIRE_API_KEY=true
+ - INITIAL_PASSWORD=${OMNIROUTE_INITIAL_PASSWORD}
+ - JWT_SECRET=${OMNIROUTE_JWT_SECRET}
+ - API_KEY_SECRET=${OMNIROUTE_API_KEY_SECRET}
+ - STORAGE_ENCRYPTION_KEY=${OMNIROUTE_STORAGE_KEY}
+ - AUTH_COOKIE_SECURE=true
+ - OMNIROUTE_TRUST_PROXY=true
+ - NEXT_PUBLIC_BASE_URL=https://omni.workflowapi.com.br
+ - OMNIROUTE_PUBLIC_BASE_URL=https://omni.workflowapi.com.br
+ - CORS_ALLOWED_ORIGINS=https://omni.workflowapi.com.br
+
+ deploy:
+ placement:
+ constraints:
+ - node.role == manager
+ resources:
+ limits:
+ cpus: "1"
+ memory: 1024M
+ labels:
+ - traefik.enable=true
+ - traefik.docker.network=network_public
+ - traefik.http.routers.omniroute.rule=Host(`omni.workflowapi.com.br`)
+ - traefik.http.routers.omniroute.entrypoints=websecure
+ - traefik.http.routers.omniroute.tls.certresolver=letsencryptresolver
+ - traefik.http.routers.omniroute.service=omniroute
+ - traefik.http.services.omniroute.loadbalancer.server.port=20128
+ - traefik.http.services.omniroute.loadbalancer.passHostHeader=true
+
+volumes:
+ evonexus_config:
+ evonexus_workspace:
+ evonexus_dashboard_data:
+ evonexus_memory:
+ evonexus_backups:
+ evonexus_adw_logs:
+ evonexus_claude_workspace:
+ evonexus_agent_memory:
+ evonexus_claude_auth:
+ evonexus_codex_auth:
+ omniroute_data:
+
+networks:
+ network_public:
+ external: true
+ name: network_public
diff --git a/opencode.json b/opencode.json
new file mode 100644
index 00000000..d6edbe09
--- /dev/null
+++ b/opencode.json
@@ -0,0 +1,29 @@
+{
+ "$schema": "https://opencode.ai/config.json",
+ "provider": {
+ "opencode": {
+ "npm": "@ai-sdk/openai-compatible",
+ "name": "OpenCode",
+ "options": {
+ "baseURL": "{env:OPENAI_BASE_URL}",
+ "apiKey": "{env:OPENAI_API_KEY}"
+ },
+ "models": {
+ "auto": {
+ "name": "OpenCode auto (LKGP)"
+ },
+ "auto/coding": {
+ "name": "OpenCode auto/coding"
+ },
+ "xai/grok-4.3": {
+ "name": "xAI Grok 4.3 via OpenCode (teste de custo metrificado, nao-assinatura)"
+ }
+ }
+ }
+ },
+ "agent": {
+ "build": {
+ "steps": 15
+ }
+ }
+}
diff --git a/pyproject.toml b/pyproject.toml
index 802fcdc0..db350609 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "evo-nexus"
-version = "0.32.3"
+version = "0.33.0"
description = "Unofficial open source toolkit for Claude Code — AI-powered business operating system"
requires-python = ">=3.10"
dependencies = [
@@ -22,14 +22,22 @@ dependencies = [
"psycopg2-binary",
"alembic>=1.13",
"tiktoken>=0.7",
- "sentence-transformers>=3.0",
"openai>=1.0",
"google-genai>=0.3",
"anthropic>=0.25",
- "marker-pdf>=1.6.1",
"watchdog>=4.0",
"sqlparse>=0.4,<1.0",
"jsonschema>=4.21",
+ "flask-limiter>=3.5",
+]
+
+[project.optional-dependencies]
+knowledge-ml = [
+ "marker-pdf>=1.6.1",
+ "sentence-transformers>=3.0",
+]
+local-embeddings = [
+ "sentence-transformers>=3.0",
]
[project.scripts]
diff --git a/scheduler.py b/scheduler.py
index 144cd40e..0bd5f96f 100644
--- a/scheduler.py
+++ b/scheduler.py
@@ -99,6 +99,14 @@ def run_adw(name: str, script: str, args: str = ""):
print(f" {now} ✗ {name} error: {e}")
+def _hourly_report_safe():
+ """Wrapper that only runs hourly report during business hours (08h-20h BRT)."""
+ from datetime import datetime, timezone, timedelta
+ brt = datetime.now(timezone.utc) + timedelta(hours=-3)
+ if 8 <= brt.hour < 20:
+ run_adw("Hourly Report", "hourly_report.py")
+
+
def setup_schedule():
"""Configure core routines. Custom routines loaded from config/routines.yaml."""
import schedule
@@ -111,11 +119,34 @@ def setup_schedule():
# schedule.every().friday.at("08:00").do(run_adw, "Weekly Review", "weekly_review.py")
schedule.every().sunday.at("09:00").do(run_adw, "Memory Lint", "memory_lint.py")
schedule.every().day.at("21:00").do(run_adw, "Daily Backup", "backup.py")
+ # Exercise every NVIDIA model so the active chain has fresh live traffic
+ # evidence in /costs. Runs late at night to stay out of the way of heartbeats.
+ schedule.every().day.at("04:00").do(run_adw, "Uso Modelos DIA", "uso_modelos_dia.py")
+
+ # Hourly activity report during business hours (08h-20h BRT)
+ schedule.every().hour.do(_hourly_report_safe)
# ── Custom routines (from config/routines.yaml if exists) ──
_load_custom_routines(schedule)
+def _coerce_interval(interval, time_str):
+ """Return an interval in minutes, or None to fall back to a daily .at(time).
+
+ Accepts an explicit `interval` (minutes) or a cron-style `*/N * * * *` time
+ field (common mistake / convenience), converting it to N minutes. Anything
+ else (HH:MM) returns None so the caller uses the daily-at path.
+ """
+ if interval is not None:
+ return int(interval)
+ if isinstance(time_str, str):
+ import re
+ m = re.match(r"^\*/(\d+)\s+\*\s+\*\s+\*\s+\*$", time_str.strip())
+ if m:
+ return int(m.group(1))
+ return None
+
+
def _load_routines_from_yaml(schedule, config_path: Path, is_plugin: bool = False,
disabled_make_ids: set | None = None):
"""Load routines from a single YAML file into the schedule.
@@ -155,10 +186,15 @@ def _load_routines_from_yaml(schedule, config_path: Path, is_plugin: bool = Fals
if make_id in _disabled:
print(f" [{source_label}] skipped disabled routine '{name}' ({make_id})")
continue
- if r.get("interval"):
- schedule.every(int(r["interval"])).minutes.do(run_adw, name, f"custom/{script}", args)
- elif r.get("time"):
- schedule.every().day.at(r["time"]).do(run_adw, name, f"custom/{script}", args)
+ try:
+ interval_min = _coerce_interval(r.get("interval"), r.get("time"))
+ if interval_min is not None:
+ schedule.every(interval_min).minutes.do(run_adw, name, f"custom/{script}", args)
+ elif r.get("time"):
+ schedule.every().day.at(r["time"]).do(run_adw, name, f"custom/{script}", args)
+ except Exception as exc:
+ print(f" [{source_label}] SKIPPED routine '{name}': invalid schedule "
+ f"(time={r.get('time')!r}, interval={r.get('interval')!r}): {exc}")
for r in config.get("weekly", []) or []:
if not r.get("enabled", True):
@@ -175,10 +211,15 @@ def _load_routines_from_yaml(schedule, config_path: Path, is_plugin: bool = Fals
day = r.get("day", "friday").lower()
time_str = r.get("time", "09:00")
days = r.get("days", [day])
- for d in days:
- getattr(schedule.every(), d, schedule.every().friday).at(time_str).do(
- run_adw, name, f"custom/{script}", args
- )
+ try:
+ for d in days:
+ getattr(schedule.every(), d, schedule.every().friday).at(time_str).do(
+ run_adw, name, f"custom/{script}", args
+ )
+ except Exception as exc:
+ print(f" [{source_label}] SKIPPED weekly routine '{name}': invalid time "
+ f"{time_str!r}: {exc}")
+ continue
global _monthly_routines
monthly = config.get("monthly", []) or []
@@ -200,10 +241,21 @@ def _load_routines_from_yaml(schedule, config_path: Path, is_plugin: bool = Fals
_monthly_routines = monthly
except Exception as e:
- if is_plugin:
- print(f" Warning: Failed to load plugin routines from {config_path}: {e}")
- else:
- raise
+ # Confirmed live 2026-07-14: a single bad indent in config/routines.yaml
+ # (invalid YAML) raised here and propagated all the way up through
+ # setup_schedule() -> main(), crashing the scheduler process before it
+ # ever reached its run loop — before ANY routine, including the
+ # hardcoded core ones registered earlier in setup_schedule(), could
+ # fire. If the container restart-loops on the same broken file, every
+ # routine goes silent indefinitely with no error visible anywhere
+ # except scheduler logs. Never crash the whole process over the
+ # custom-routines file — log loudly and keep going with whatever
+ # loaded successfully (core routines are registered before this call,
+ # so they're unaffected either way).
+ source = "core config" if not is_plugin else f"plugin routines from {config_path}"
+ print(f" ERROR: Failed to load {source} ({config_path}): {e}", flush=True)
+ print(f" Scheduler continuing WITHOUT these routines — fix {config_path} and "
+ f"send SIGHUP (or restart) to reload.", flush=True)
def _load_disabled_routines() -> dict[str, set]:
diff --git a/scripts/continue_zapclub_community.py b/scripts/continue_zapclub_community.py
new file mode 100644
index 00000000..6fb25b23
--- /dev/null
+++ b/scripts/continue_zapclub_community.py
@@ -0,0 +1,97 @@
+import base64, json, time, urllib.request, urllib.error
+from pathlib import Path
+from PIL import Image, ImageOps
+BASE='https://go.workflowapi.com.br'; ADMIN='ab7493b4527e089973ad0cfed079d118'
+ROOT=Path('/home/sistemabritto/Documentos/evo-nexus'); OUT=ROOT/'workspace/assets/images/zapclub-groups/processed-small'; OUT.mkdir(parents=True,exist_ok=True)
+LOGO=Path('/home/sistemabritto/.hermes/image_cache/img_52d95475f17f.jpg')
+PARTICIPANT='557199841612@s.whatsapp.net'
+GROUPS=[('Conquistador de Tokens','conquistador-de-tokens.png'),('Triangulador de Modelos','triangulador-de-modelos.png'),('Manipulador de Ferramentas e Integrações','manipulador-de-ferramentas-e-integracoes.png'),('Desenvolvedor de Skills','desenvolvedor-de-skills.png'),('Maestro de Rotinas','maestro-de-rotinas.png')]
+
+def req(method,path,token,body=None,timeout=180,retries=2):
+ data=json.dumps(body,ensure_ascii=False).encode() if body is not None else None
+ for attempt in range(retries+1):
+ r=urllib.request.Request(BASE+path,data=data,method=method,headers={'apikey':token,'Content-Type':'application/json','Accept':'application/json','User-Agent':'Mozilla/5.0'})
+ try:
+ raw=urllib.request.urlopen(r,timeout=timeout).read().decode(); return json.loads(raw) if raw else {}
+ except urllib.error.HTTPError as e:
+ txt=e.read().decode(errors='ignore')
+ if ('429' in txt or e.code==429 or 'rate-overlimit' in txt) and attempt 100_000:
+ print('SKIP existing', out, flush=True)
+ continue
+ prompt = f"""{base_style}
+Main title text: "{item['title']}".
+Small subtitle text: "IA PARA NEGÓCIOS".
+Concept: {item['concept']}.
+Create a polished square icon/banner that can be used as a WhatsApp group photo, with the title large and readable, ZapClub-style neon green speech/data visual language, professional and cohesive with the provided ZapClub logo."""
+ prompt_path = root/f"workspace/assets/prompts/{item['name']}.txt"
+ prompt_path.write_text(prompt)
+ cmd = ['uv','run','python',str(skill),'-o',str(out),'--provider','openai','-m','image2','-a','1:1','--prompt-file',str(prompt_path)]
+ print('GENERATING', item['title'], flush=True)
+ p = subprocess.run(cmd, cwd=str(root), text=True, capture_output=True, timeout=240)
+ print(p.stdout[-2000:], flush=True)
+ if p.returncode != 0:
+ print(p.stderr[-4000:], file=sys.stderr, flush=True)
+ sys.exit(p.returncode)
+print('DONE', outdir, flush=True)
diff --git a/scripts/media_host.py b/scripts/media_host.py
new file mode 100644
index 00000000..208bba58
--- /dev/null
+++ b/scripts/media_host.py
@@ -0,0 +1,88 @@
+#!/usr/bin/env python3
+"""Host media on the Nexus S3 bucket and return a public presigned URL.
+
+Instagram fetches media server-side, so it needs a public https URL. Instead of
+making the bucket public, we upload the file and hand out a short-lived presigned
+GET URL (default 1h) — enough for the Graph API to download during publishing.
+
+Reuses the same S3 config as backups (BACKUP_S3_BUCKET + AWS_* / AWS_ENDPOINT_URL).
+
+Usage:
+ python3 scripts/media_host.py [expires_seconds]
+"""
+
+from __future__ import annotations
+
+import mimetypes
+import os
+import sys
+import time
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent.parent
+DEFAULT_PREFIX = "social-media/"
+DEFAULT_EXPIRES = 3600
+
+
+def _load_dotenv() -> None:
+ env_path = ROOT / ".env"
+ if not env_path.exists():
+ return
+ for line in env_path.read_text(encoding="utf-8").splitlines():
+ line = line.strip()
+ if not line or line.startswith("#") or "=" not in line:
+ continue
+ key, value = line.split("=", 1)
+ key, value = key.strip(), value.strip()
+ if key and key not in os.environ:
+ os.environ[key] = value
+
+
+def _s3_client():
+ import boto3 # imported lazily so non-upload paths don't require it
+ endpoint = os.environ.get("AWS_ENDPOINT_URL")
+ return boto3.client("s3", endpoint_url=endpoint) if endpoint else boto3.client("s3")
+
+
+def is_configured() -> bool:
+ return bool(os.environ.get("BACKUP_S3_BUCKET"))
+
+
+def upload_and_presign(local_path: str | Path, *, prefix: str = DEFAULT_PREFIX,
+ expires: int = DEFAULT_EXPIRES) -> str:
+ """Upload a local file to S3 and return a presigned GET URL."""
+ bucket = os.environ.get("BACKUP_S3_BUCKET")
+ if not bucket:
+ raise RuntimeError("BACKUP_S3_BUCKET not configured in .env")
+ p = Path(local_path)
+ if not p.is_file():
+ raise FileNotFoundError(f"media file not found: {p}")
+
+ s3 = _s3_client()
+ key = f"{prefix}{int(time.time())}-{p.name}"
+ ctype = mimetypes.guess_type(p.name)[0] or "application/octet-stream"
+ s3.upload_file(str(p), bucket, key, ExtraArgs={"ContentType": ctype})
+ return s3.generate_presigned_url(
+ "get_object",
+ Params={"Bucket": bucket, "Key": key},
+ ExpiresIn=expires,
+ )
+
+
+def main() -> int:
+ _load_dotenv()
+ if len(sys.argv) < 2:
+ print("Usage: media_host.py [expires_seconds]")
+ return 1
+ expires = int(sys.argv[2]) if len(sys.argv) > 2 else DEFAULT_EXPIRES
+ try:
+ url = upload_and_presign(sys.argv[1], expires=expires)
+ except Exception as e: # noqa: BLE001
+ print(f"error: {e}", file=sys.stderr)
+ return 1
+ print(url)
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/post_social.py b/scripts/post_social.py
new file mode 100755
index 00000000..9ad268a0
--- /dev/null
+++ b/scripts/post_social.py
@@ -0,0 +1,217 @@
+#!/usr/bin/env python3
+"""Dispatch a social post to available channels.
+
+Current state (2026-06-16):
+ - X (Twitter): publication works via scripts/post_to_x.py (OAuth2 user-context,
+ auto-refresh, retry on 429). Wired here.
+ - Instagram: publishes via scripts/post_to_instagram.py (Graph API, Instagram
+ Login or Facebook Login token). Requires media at a public https URL; local
+ files fall back to the manual queue with a reason.
+ - LinkedIn: no API integration in this workspace. Falls back to a manual queue.
+
+Usage:
+ python3 scripts/post_social.py x "tweet text" [--media img.png] [--account 1] [--dry-run]
+ python3 scripts/post_social.py instagram "caption" --media https://host/foto.jpg [--dry-run]
+ python3 scripts/post_social.py all "post body" [--media https://host/foto.jpg] [--dry-run]
+ python3 scripts/post_social.py --show-queue
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import subprocess
+import sys
+from datetime import datetime, timezone
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent.parent
+POST_SCRIPT = ROOT / "scripts" / "post_to_x.py"
+IG_SCRIPT = ROOT / "scripts" / "post_to_instagram.py"
+QUEUE_PATH = ROOT / "workspace" / "social" / "manual_post_queue.jsonl"
+LOG_PATH = ROOT / "workspace" / "social" / "post_dispatch_log.jsonl"
+
+SUPPORTED_AUTO = {"x": "X (Twitter)", "instagram": "Instagram"}
+MANUAL_CHANNELS = {"linkedin": "LinkedIn"}
+
+VIDEO_EXTS = (".mp4", ".mov", ".m4v")
+
+
+def now_iso() -> str:
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")
+
+
+def append_jsonl(path: Path, record: dict) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ with path.open("a", encoding="utf-8") as f:
+ f.write(json.dumps(record, ensure_ascii=False) + "\n")
+
+
+def dispatch_x(text: str, media: Path | None, account: int | None, dry_run: bool) -> dict:
+ cmd = [sys.executable, str(POST_SCRIPT), text]
+ if account is not None:
+ cmd.extend(["--account", str(account)])
+ if media is not None:
+ cmd.extend(["--media", str(media)])
+ if dry_run:
+ cmd.append("--dry-run")
+ try:
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=180)
+ except subprocess.TimeoutExpired:
+ return {"channel": "x", "status": "error", "error": "timeout posting to X"}
+ return {
+ "channel": "x",
+ "status": "ok" if result.returncode == 0 else "error",
+ "stdout": result.stdout.strip()[:2000],
+ "stderr": result.stderr.strip()[:1000],
+ "returncode": result.returncode,
+ }
+
+
+def dispatch_instagram(text: str, media: Path | None, account: int | None, dry_run: bool) -> dict:
+ """Publish to Instagram via Graph API.
+
+ Media may be a public https URL or a local file — local files are uploaded to
+ S3 (presigned URL) by post_to_instagram.py since the API fetches media
+ server-side. With no media at all, falls back to the manual queue.
+ """
+ media_str = str(media) if media else ""
+ if not media_str:
+ reason = "Instagram requires media (image or video) — none provided."
+ return queue_manual_reason("instagram", "Instagram", text, media, reason)
+
+ # Local files are uploaded to S3 by post_to_instagram.py; https URLs pass through.
+ is_video = media_str.lower().split("?")[0].endswith(VIDEO_EXTS)
+ cmd = [sys.executable, str(IG_SCRIPT), "--caption", text]
+ if is_video:
+ cmd.extend(["--video-url", media_str, "--reels"])
+ else:
+ cmd.extend(["--image-url", media_str])
+ if account is not None:
+ cmd.extend(["--account", str(account)])
+ if dry_run:
+ cmd.append("--dry-run")
+ try:
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=360)
+ except subprocess.TimeoutExpired:
+ return {"channel": "instagram", "status": "error", "error": "timeout publishing to Instagram"}
+ return {
+ "channel": "instagram",
+ "status": "ok" if result.returncode == 0 else "error",
+ "stdout": result.stdout.strip()[:2000],
+ "stderr": result.stderr.strip()[:1000],
+ "returncode": result.returncode,
+ }
+
+
+def queue_manual_reason(channel: str, label: str, text: str, media: Path | None, reason: str) -> dict:
+ record = {
+ "queued_at": now_iso(),
+ "channel": channel,
+ "label": label,
+ "text": text,
+ "media": str(media) if media else None,
+ "length": len(text),
+ "reason": reason,
+ }
+ append_jsonl(QUEUE_PATH, record)
+ return {
+ "channel": channel,
+ "status": "queued_manual",
+ "reason": reason,
+ "queue_path": str(QUEUE_PATH.relative_to(ROOT)),
+ }
+
+
+def queue_manual(channel: str, text: str, media: Path | None) -> dict:
+ record = {
+ "queued_at": now_iso(),
+ "channel": channel,
+ "label": MANUAL_CHANNELS[channel],
+ "text": text,
+ "media": str(media) if media else None,
+ "length": len(text),
+ }
+ append_jsonl(QUEUE_PATH, record)
+ return {
+ "channel": channel,
+ "status": "queued_manual",
+ "queue_path": str(QUEUE_PATH.relative_to(ROOT)),
+ "instruction": (
+ f"Open {MANUAL_CHANNELS[channel]} and paste the text. "
+ f"Row written to {QUEUE_PATH.relative_to(ROOT)}."
+ ),
+ }
+
+
+def show_queue() -> int:
+ if not QUEUE_PATH.exists():
+ print(json.dumps({"queue_path": str(QUEUE_PATH.relative_to(ROOT)), "pending": []}, indent=2))
+ return 0
+ rows = []
+ for line in QUEUE_PATH.read_text(encoding="utf-8").splitlines():
+ line = line.strip()
+ if line:
+ try:
+ rows.append(json.loads(line))
+ except json.JSONDecodeError:
+ continue
+ print(json.dumps({"queue_path": str(QUEUE_PATH.relative_to(ROOT)), "pending": rows}, indent=2, ensure_ascii=False))
+ return 0
+
+
+def parse_channel_arg(raw: str) -> list[str]:
+ if raw == "all":
+ return list(SUPPORTED_AUTO) + list(MANUAL_CHANNELS)
+ return [c.strip() for c in raw.split(",") if c.strip()]
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description="Dispatch a social post across channels")
+ parser.add_argument("channel", nargs="?", default="all",
+ help="x | instagram | linkedin | comma list | all (default)")
+ parser.add_argument("text", nargs="?", help="Post body. Reads stdin if omitted.")
+ parser.add_argument("--media", help="Media to attach: local file path (X) or public https URL (Instagram).")
+ parser.add_argument("--account", type=int, help="SOCIAL_TWITTER_ account index")
+ parser.add_argument("--dry-run", action="store_true", help="Validate without publishing")
+ parser.add_argument("--show-queue", action="store_true", help="Print manual post queue and exit")
+ args = parser.parse_args()
+
+ if args.show_queue:
+ return show_queue()
+
+ if not args.text:
+ args.text = sys.stdin.read().strip()
+ if not args.text:
+ raise SystemExit("post body is required (arg or stdin)")
+
+ channels = parse_channel_arg(args.channel)
+ unknown = [c for c in channels if c not in SUPPORTED_AUTO and c not in MANUAL_CHANNELS]
+ if unknown:
+ raise SystemExit(f"unknown channels: {unknown}. supported: {list(SUPPORTED_AUTO | MANUAL_CHANNELS)}")
+
+ results: list[dict] = []
+ text_for_x = args.text if len(args.text) <= 280 else args.text[:277] + "..."
+ for ch in channels:
+ if ch == "x":
+ results.append(dispatch_x(text_for_x, args.media, args.account, args.dry_run))
+ elif ch == "instagram":
+ results.append(dispatch_instagram(args.text, args.media, args.account, args.dry_run))
+ else:
+ results.append(queue_manual(ch, args.text, args.media))
+
+ summary = {
+ "dispatched_at": now_iso(),
+ "channels": channels,
+ "dry_run": args.dry_run,
+ "results": results,
+ }
+ append_jsonl(LOG_PATH, summary)
+ print(json.dumps(summary, indent=2, ensure_ascii=False))
+
+ failed_auto = [r for r in results if r.get("channel") in SUPPORTED_AUTO and r.get("status") != "ok"]
+ return 1 if failed_auto else 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/post_to_instagram.py b/scripts/post_to_instagram.py
new file mode 100644
index 00000000..470432dd
--- /dev/null
+++ b/scripts/post_to_instagram.py
@@ -0,0 +1,267 @@
+#!/usr/bin/env python3
+"""Publish a post to Instagram via the Graph API.
+
+Supports both Meta products:
+ - "Instagram API com Login do Instagram" tokens (IGAA...) → graph.instagram.com
+ - "Login do Facebook" tokens (EAA...) → graph.facebook.com (needs a linked Page)
+
+IMPORTANT: Instagram fetches the media server-side, so the image/video must be at
+a PUBLIC https URL. The API does not accept a direct binary upload of a local file.
+
+Publishing is a 2-step (image) or 3-step (reels/carousel) flow:
+ 1. create a media container POST //media
+ 2. (video) poll until FINISHED GET /?fields=status_code
+ 3. publish the container POST //media_publish
+
+Usage:
+ post_to_instagram.py --image-url https://host/foto.jpg --caption "texto" [--account 1]
+ post_to_instagram.py --video-url https://host/reel.mp4 --caption "texto" --reels
+ post_to_instagram.py --image-url URL1 --image-url URL2 --caption "..." --carousel
+ add --dry-run to validate inputs without publishing
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import re
+import sys
+import time
+import urllib.error
+import urllib.parse
+import urllib.request
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent.parent
+
+FB_BASE_URL = "https://graph.facebook.com/v25.0"
+IG_BASE_URL = "https://graph.instagram.com/v23.0"
+
+
+def _load_dotenv() -> None:
+ env_path = ROOT / ".env"
+ if not env_path.exists():
+ return
+ for line in env_path.read_text(encoding="utf-8").splitlines():
+ line = line.strip()
+ if not line or line.startswith("#") or "=" not in line:
+ continue
+ key, value = line.split("=", 1)
+ key, value = key.strip(), value.strip()
+ if key and key not in os.environ:
+ os.environ[key] = value
+
+
+def _get_account(index: int | None) -> dict:
+ """Resolve a SOCIAL_INSTAGRAM_ account from env (first one if index omitted)."""
+ indices = sorted(
+ int(m.group(1))
+ for k in os.environ
+ if (m := re.match(r"^SOCIAL_INSTAGRAM_(\d+)_LABEL$", k))
+ )
+ if not indices:
+ return {}
+ idx = index if index in indices else indices[0]
+ return {
+ "index": idx,
+ "label": os.environ.get(f"SOCIAL_INSTAGRAM_{idx}_LABEL", f"Account {idx}"),
+ "access_token": os.environ.get(f"SOCIAL_INSTAGRAM_{idx}_ACCESS_TOKEN", ""),
+ "page_token": os.environ.get(f"SOCIAL_INSTAGRAM_{idx}_PAGE_TOKEN", ""),
+ "account_id": os.environ.get(f"SOCIAL_INSTAGRAM_{idx}_ACCOUNT_ID", ""),
+ }
+
+
+def _token(account: dict) -> str:
+ return account.get("page_token") or account.get("access_token", "")
+
+
+def _resolve_media(ref: str) -> str:
+ """Return a public https URL for a media reference.
+
+ If `ref` is already an https URL, use it as-is. If it is a local file, upload
+ it to S3 and return a presigned URL (requires BACKUP_S3_* configured).
+ """
+ if ref.startswith("https://"):
+ return ref
+ if ref.startswith("http://"):
+ raise ValueError(f"media must be https (Instagram rejects http): {ref}")
+ # treat as local file → host on S3
+ import media_host
+ if not media_host.is_configured():
+ raise ValueError(
+ f"'{ref}' is a local file but no S3 bucket is configured (BACKUP_S3_BUCKET). "
+ "Pass a public https URL instead."
+ )
+ return media_host.upload_and_presign(ref)
+
+
+def _is_ig_login_token(token: str) -> bool:
+ return token.startswith("IG")
+
+
+def _api_post(base: str, path: str, params: dict) -> dict:
+ url = f"{base}/{path}"
+ data = urllib.parse.urlencode(params).encode("utf-8")
+ req = urllib.request.Request(url, data=data, method="POST")
+ try:
+ with urllib.request.urlopen(req, timeout=60) as resp:
+ return json.loads(resp.read().decode("utf-8"))
+ except urllib.error.HTTPError as e:
+ body = e.read().decode("utf-8", errors="replace")
+ return {"error": f"HTTP {e.code}", "detail": body[:600]}
+ except Exception as e: # noqa: BLE001
+ return {"error": str(e)}
+
+
+def _api_get(base: str, path: str, params: dict) -> dict:
+ url = f"{base}/{path}?{urllib.parse.urlencode(params)}"
+ try:
+ with urllib.request.urlopen(url, timeout=30) as resp:
+ return json.loads(resp.read().decode("utf-8"))
+ except urllib.error.HTTPError as e:
+ body = e.read().decode("utf-8", errors="replace")
+ return {"error": f"HTTP {e.code}", "detail": body[:600]}
+ except Exception as e: # noqa: BLE001
+ return {"error": str(e)}
+
+
+def _wait_for_container(base: str, container_id: str, token: str,
+ timeout_s: int = 300, interval_s: int = 5) -> dict:
+ """Poll a video/reel container until it finishes processing."""
+ deadline = time.time() + timeout_s
+ last = {}
+ while time.time() < deadline:
+ last = _api_get(base, container_id, {"fields": "status_code,status", "access_token": token})
+ if "error" in last:
+ return last
+ code = last.get("status_code", "")
+ if code == "FINISHED":
+ return last
+ if code == "ERROR":
+ return {"error": "container processing failed", "detail": last.get("status", "")}
+ time.sleep(interval_s)
+ return {"error": "timeout waiting for container to finish", "detail": json.dumps(last)}
+
+
+def publish(account: dict, *, caption: str, image_urls: list[str],
+ video_url: str | None, reels: bool, carousel: bool,
+ dry_run: bool) -> dict:
+ token = _token(account)
+ base = IG_BASE_URL if _is_ig_login_token(token) else FB_BASE_URL
+ ig_id = account.get("account_id", "") or ("me" if _is_ig_login_token(token) else "")
+
+ if not token or not ig_id:
+ return {"status": "error", "error": "missing access_token or account_id in .env"}
+ if not image_urls and not video_url:
+ return {"status": "error", "error": "provide --image-url and/or --video-url (https URL or local file)"}
+
+ plan = {
+ "account": account.get("label"),
+ "ig_id": ig_id,
+ "api": base,
+ "type": "reels" if reels else "carousel" if carousel else "image",
+ "media": video_url or (image_urls if carousel else image_urls[0]),
+ "caption_len": len(caption),
+ }
+ if dry_run:
+ return {"status": "dry_run", "plan": plan}
+
+ # Resolve local files → public presigned S3 URLs (https URLs pass through)
+ try:
+ image_urls = [_resolve_media(u) for u in image_urls]
+ if video_url:
+ video_url = _resolve_media(video_url)
+ except Exception as e: # noqa: BLE001
+ return {"status": "error", "step": "resolve_media", "error": str(e)}
+
+ # ── Step 1: build container(s) ──
+ if reels and video_url:
+ cont = _api_post(base, f"{ig_id}/media", {
+ "media_type": "REELS", "video_url": video_url,
+ "caption": caption, "access_token": token,
+ })
+ if "error" in cont:
+ return {"status": "error", "step": "create_container", **cont}
+ creation_id = cont["id"]
+
+ elif carousel:
+ if len(image_urls) < 2:
+ return {"status": "error", "error": "carousel needs at least 2 --image-url"}
+ children = []
+ for url in image_urls:
+ item = _api_post(base, f"{ig_id}/media", {
+ "image_url": url, "is_carousel_item": "true", "access_token": token,
+ })
+ if "error" in item:
+ return {"status": "error", "step": "carousel_item", "url": url, **item}
+ children.append(item["id"])
+ cont = _api_post(base, f"{ig_id}/media", {
+ "media_type": "CAROUSEL", "children": ",".join(children),
+ "caption": caption, "access_token": token,
+ })
+ if "error" in cont:
+ return {"status": "error", "step": "carousel_container", **cont}
+ creation_id = cont["id"]
+
+ else: # single image
+ cont = _api_post(base, f"{ig_id}/media", {
+ "image_url": image_urls[0], "caption": caption, "access_token": token,
+ })
+ if "error" in cont:
+ return {"status": "error", "step": "create_container", **cont}
+ creation_id = cont["id"]
+
+ # ── Step 2: wait until the container finished processing, then publish ──
+ ready = _wait_for_container(base, creation_id, token)
+ if "error" in ready:
+ return {"status": "error", "step": "processing", "creation_id": creation_id, **ready}
+
+ pub = _api_post(base, f"{ig_id}/media_publish", {
+ "creation_id": creation_id, "access_token": token,
+ })
+ if "error" in pub:
+ return {"status": "error", "step": "publish", "creation_id": creation_id, **pub}
+
+ media_id = pub.get("id", "")
+ permalink = ""
+ perm = _api_get(base, media_id, {"fields": "permalink", "access_token": token})
+ if "error" not in perm:
+ permalink = perm.get("permalink", "")
+
+ return {"status": "ok", "media_id": media_id, "permalink": permalink, "plan": plan}
+
+
+def main() -> int:
+ _load_dotenv()
+ parser = argparse.ArgumentParser(description="Publish a post to Instagram")
+ parser.add_argument("--caption", default="", help="Post caption / text")
+ parser.add_argument("--image-url", action="append", default=[],
+ help="Public https image URL (repeat for carousel)")
+ parser.add_argument("--video-url", help="Public https video URL (use with --reels)")
+ parser.add_argument("--reels", action="store_true", help="Publish video as a Reel")
+ parser.add_argument("--carousel", action="store_true", help="Publish multiple images as a carousel")
+ parser.add_argument("--account", type=int, help="SOCIAL_INSTAGRAM_ index (default: first)")
+ parser.add_argument("--dry-run", action="store_true", help="Validate inputs without publishing")
+ args = parser.parse_args()
+
+ account = _get_account(args.account)
+ if not account:
+ print(json.dumps({"status": "error", "error": "no SOCIAL_INSTAGRAM_* account in .env"}, indent=2))
+ return 1
+
+ result = publish(
+ account,
+ caption=args.caption,
+ image_urls=args.image_url,
+ video_url=args.video_url,
+ reels=args.reels,
+ carousel=args.carousel,
+ dry_run=args.dry_run,
+ )
+ print(json.dumps(result, indent=2, ensure_ascii=False))
+ return 0 if result.get("status") in {"ok", "dry_run"} else 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/post_to_x.py b/scripts/post_to_x.py
new file mode 100644
index 00000000..6c8ae107
--- /dev/null
+++ b/scripts/post_to_x.py
@@ -0,0 +1,338 @@
+#!/usr/bin/env python3
+"""Post a tweet using an X OAuth2 user-context token.
+
+Requires SOCIAL_TWITTER__ACCESS_TOKEN with tweet.write scope.
+Media upload also requires media.write scope.
+App-only bearer tokens cannot publish tweets.
+
+Features:
+ - Auto-refresh expired access tokens via refresh_token
+ - Retry with exponential backoff on 429/rate-limit
+ - Multi-account support via --account N
+ - Dry-run mode for validation
+"""
+
+from __future__ import annotations
+
+import argparse
+import base64
+import json
+import mimetypes
+import os
+import sys
+import time
+import urllib.error
+import urllib.parse
+import urllib.request
+from pathlib import Path
+
+
+ROOT = Path(__file__).resolve().parent.parent
+ENV_PATH = ROOT / ".env"
+TWEET_URL = "https://api.x.com/2/tweets"
+MEDIA_UPLOAD_URL = "https://api.x.com/2/media/upload"
+TOKEN_REFRESH_URL = "https://api.x.com/2/oauth2/token"
+
+# Retry configuration
+MAX_RETRIES = 3
+BASE_BACKOFF_SECONDS = 15
+RATE_LIMIT_BACKOFF_SECONDS = 900 # 15 min — X free tier resets every 15 min
+
+
+def read_env() -> dict[str, str]:
+ env: dict[str, str] = dict(os.environ)
+ if not ENV_PATH.exists():
+ return env
+ for line in ENV_PATH.read_text(encoding="utf-8").splitlines():
+ line = line.strip()
+ if not line or line.startswith("#") or "=" not in line:
+ continue
+ key, value = line.split("=", 1)
+ env.setdefault(key.strip(), value.strip().strip('"').strip("'"))
+ return env
+
+
+def write_env(key: str, value: str):
+ """Update a key in .env file."""
+ lines = []
+ found = False
+ if ENV_PATH.exists():
+ for line in ENV_PATH.read_text(encoding="utf-8").splitlines():
+ stripped = line.strip()
+ if stripped and not stripped.startswith("#") and "=" in stripped:
+ k = stripped.split("=", 1)[0].strip()
+ if k == key:
+ lines.append(f"{key}={value}")
+ found = True
+ continue
+ lines.append(line if line.endswith("\n") else line + "\n")
+ if not found:
+ lines.append(f"{key}={value}\n")
+ with open(ENV_PATH, "w") as f:
+ f.writelines(lines)
+
+
+def twitter_prefix(env: dict[str, str], index: int | None) -> str:
+ if index is not None:
+ return f"SOCIAL_TWITTER_{index}"
+ indices = sorted(
+ int(key.split("_")[2])
+ for key in env
+ if key.startswith("SOCIAL_TWITTER_") and key.endswith("_ACCESS_TOKEN")
+ )
+ if indices:
+ return f"SOCIAL_TWITTER_{indices[0]}"
+ bearer_indices = sorted(
+ int(key.split("_")[2])
+ for key in env
+ if key.startswith("SOCIAL_TWITTER_") and key.endswith("_BEARER_TOKEN")
+ )
+ if bearer_indices:
+ raise SystemExit(
+ "Only SOCIAL_TWITTER bearer token found. X posting requires OAuth user context with tweet.write. "
+ "Run: python3 social-auth/app.py and reconnect X/Twitter."
+ )
+ raise SystemExit("No SOCIAL_TWITTER account found. Run: python3 social-auth/app.py")
+
+
+def refresh_access_token(env: dict[str, str], prefix: str) -> str | None:
+ """Refresh an expired X access token using the refresh token.
+
+ Returns the new access token on success, None on failure.
+ Updates .env with the new tokens.
+ """
+ refresh_token = env.get(f"{prefix}_REFRESH_TOKEN", "")
+ client_id = env.get("TWITTER_CLIENT_ID", "")
+
+ if not refresh_token:
+ return None
+ if not client_id:
+ # Can't refresh without client_id
+ return None
+
+ data = urllib.parse.urlencode({
+ "grant_type": "refresh_token",
+ "refresh_token": refresh_token,
+ "client_id": client_id,
+ }).encode("utf-8")
+
+ req = urllib.request.Request(
+ TOKEN_REFRESH_URL,
+ data=data,
+ method="POST",
+ headers={
+ "Content-Type": "application/x-www-form-urlencoded",
+ },
+ )
+ try:
+ with urllib.request.urlopen(req, timeout=30) as resp:
+ result = json.loads(resp.read().decode("utf-8"))
+ except (urllib.error.HTTPError, Exception) as exc:
+ body = ""
+ if isinstance(exc, urllib.error.HTTPError):
+ body = exc.read().decode("utf-8", "ignore")
+ print(f" [warn] Token refresh failed: {body[:200]}", file=sys.stderr)
+ return None
+
+ new_access = result.get("access_token", "")
+ new_refresh = result.get("refresh_token", "")
+
+ if not new_access:
+ return None
+
+ # Save new tokens
+ write_env(f"{prefix}_ACCESS_TOKEN", new_access)
+ if new_refresh:
+ write_env(f"{prefix}_REFRESH_TOKEN", new_refresh)
+
+ print(f" [info] Token refreshed for {prefix}", file=sys.stderr)
+ return new_access
+
+
+def _is_rate_limit_error(exc: urllib.error.HTTPError) -> bool:
+ """Check if an HTTPError is a rate-limit (429) response."""
+ if exc.code == 429:
+ return True
+ try:
+ body = exc.read().decode("utf-8", "ignore").lower()
+ return "rate limit" in body or "too many" in body
+ except Exception:
+ return False
+
+
+def _get_retry_after(exc: urllib.error.HTTPError) -> int:
+ """Extract Retry-After header value in seconds, or return default."""
+ try:
+ retry_after = exc.headers.get("Retry-After", "")
+ if retry_after:
+ return int(retry_after)
+ except (ValueError, TypeError):
+ pass
+ return RATE_LIMIT_BACKOFF_SECONDS
+
+
+def upload_media(path: Path, access_token: str) -> str:
+ if not path.exists():
+ raise SystemExit(f"Media file not found: {path}")
+ media_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
+ payload = json.dumps(
+ {
+ "media": base64.b64encode(path.read_bytes()).decode("ascii"),
+ "media_category": "tweet_image",
+ "media_type": media_type,
+ }
+ ).encode("utf-8")
+ req = urllib.request.Request(
+ MEDIA_UPLOAD_URL,
+ data=payload,
+ method="POST",
+ headers={
+ "Authorization": f"Bearer {access_token}",
+ "Content-Type": "application/json",
+ },
+ )
+ try:
+ with urllib.request.urlopen(req, timeout=60) as resp:
+ result = json.loads(resp.read().decode("utf-8"))
+ except urllib.error.HTTPError as exc:
+ body = exc.read().decode("utf-8", "ignore")
+ raise RuntimeError(f"X media upload HTTP {exc.code}: {body[:500]}") from exc
+ media_id = result.get("data", {}).get("id")
+ if not media_id:
+ raise RuntimeError(f"X media upload returned no media id: {json.dumps(result)[:500]}")
+ return media_id
+
+
+def post_tweet(text: str, access_token: str, media_ids: list[str] | None = None) -> dict:
+ body: dict[str, object] = {"text": text}
+ if media_ids:
+ body["media"] = {"media_ids": media_ids}
+ payload = json.dumps(body).encode("utf-8")
+
+ req = urllib.request.Request(
+ TWEET_URL,
+ data=payload,
+ method="POST",
+ headers={
+ "Authorization": f"Bearer {access_token}",
+ "Content-Type": "application/json",
+ },
+ )
+ try:
+ with urllib.request.urlopen(req, timeout=30) as resp:
+ return json.loads(resp.read().decode("utf-8"))
+ except urllib.error.HTTPError as exc:
+ body = exc.read().decode("utf-8", "ignore")
+ raise RuntimeError(f"X API HTTP {exc.code}: {body[:500]}") from exc
+
+
+def post_tweet_with_retry(
+ text: str,
+ access_token: str,
+ media_ids: list[str] | None = None,
+ prefix: str = "SOCIAL_TWITTER_1",
+ env: dict[str, str] | None = None,
+) -> dict:
+ """Post a tweet with automatic retry + token refresh on 429/expiry.
+
+ Strategy:
+ 1. Try posting. If 429 → backoff and retry.
+ 2. If token expired (401) → refresh token and retry.
+ 3. After MAX_RETRIES exhausted, raise RuntimeError.
+ """
+ if env is None:
+ env = read_env()
+
+ current_token = access_token
+
+ for attempt in range(1, MAX_RETRIES + 1):
+ try:
+ return post_tweet(text, current_token, media_ids)
+ except (urllib.error.HTTPError, RuntimeError) as exc:
+ is_429 = False
+ is_401 = False
+
+ if isinstance(exc, urllib.error.HTTPError):
+ is_429 = _is_rate_limit_error(exc)
+ is_401 = exc.code == 401
+
+ # Token expired — try refresh
+ if is_401 and env:
+ new_token = refresh_access_token(env, prefix)
+ if new_token:
+ current_token = new_token
+ continue
+ else:
+ raise RuntimeError(
+ "Token expired (401) and refresh failed. "
+ "Reconnect X/Twitter via python3 social-auth/app.py."
+ ) from exc
+
+ # Rate limit — backoff
+ if is_429 and attempt < MAX_RETRIES:
+ wait = RATE_LIMIT_BACKOFF_SECONDS if attempt == 1 else BASE_BACKOFF_SECONDS * (2 ** (attempt - 1))
+ print(
+ f" [rate-limit] Attempt {attempt}/{MAX_RETRIES} — "
+ f"waiting {wait}s before retry...",
+ file=sys.stderr,
+ )
+ time.sleep(wait)
+ continue
+
+ # Last attempt or non-retryable error
+ if is_429:
+ raise RuntimeError(
+ f"X rate limit exceeded after {MAX_RETRIES} attempts. "
+ f"Free tier resets every 15 minutes. Try again later or use --dry-run to validate."
+ ) from exc
+ raise
+
+ raise RuntimeError(f"Failed after {MAX_RETRIES} attempts")
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description="Post a tweet to X using OAuth user context")
+ parser.add_argument("text", nargs="?", help="Tweet text. Reads stdin when omitted.")
+ parser.add_argument("--account", type=int, help="SOCIAL_TWITTER_ account index")
+ parser.add_argument("--media", type=Path, help="Image file to attach to the tweet")
+ parser.add_argument("--dry-run", action="store_true", help="Validate token selection without posting")
+ args = parser.parse_args()
+
+ text = args.text if args.text is not None else sys.stdin.read()
+ text = text.strip()
+ if not text:
+ raise SystemExit("Tweet text is required")
+ if len(text) > 280:
+ raise SystemExit(f"Tweet is {len(text)} characters; X limit is 280 for this script")
+
+ env = read_env()
+ prefix = twitter_prefix(env, args.account)
+ access_token = env.get(f"{prefix}_ACCESS_TOKEN")
+ if not access_token:
+ raise SystemExit(
+ f"{prefix}_ACCESS_TOKEN missing. Bearer/app-only auth cannot publish. "
+ "Reconnect X/Twitter through python3 social-auth/app.py."
+ )
+
+ if args.dry_run:
+ print(
+ json.dumps(
+ {
+ "ok": True,
+ "account": prefix,
+ "chars": len(text),
+ "media": str(args.media) if args.media else None,
+ },
+ indent=2,
+ )
+ )
+ return 0
+
+ media_ids = [upload_media(args.media, access_token)] if args.media else None
+ result = post_tweet_with_retry(text, access_token, media_ids, prefix=prefix, env=env)
+ print(json.dumps(result, indent=2, ensure_ascii=False))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/publish_scheduled.py b/scripts/publish_scheduled.py
new file mode 100755
index 00000000..d1ffb157
--- /dev/null
+++ b/scripts/publish_scheduled.py
@@ -0,0 +1,420 @@
+#!/usr/bin/env python3
+"""Schedule and dispatch the day's social posts from the editorial calendar.
+
+Reads `workspace/marketing/calendars/[C]calendario-editorial-*.md`, finds the
+posts scheduled for *today* (BRT), and when the local BRT clock passes each
+slot's target time, invokes `scripts/post_social.py` once per post. A JSONL
+ledger at `workspace/social/scheduled_posts.jsonl` keeps idempotency — a post
+is only dispatched once.
+
+State (2026-06-16):
+- X path: automated via scripts/post_to_x.py → scripts/post_social.py x
+- IG / LinkedIn: no API in this workspace → fall back to manual queue log
+ inside post_social.py. Scheduled posts still get queued; the cron-like
+ behaviour here just moves clock-watching out of the operator's hands.
+
+Window logic: a slot fires when current BRT time is within
+`[target, target + window_minutes]` AND the post was not yet dispatched.
+Default window = 60 minutes so a routine run every 15-30 minutes won't miss
+slots if the scheduler hiccups.
+
+Usage:
+ python3 scripts/publish_scheduled.py # process today's slots
+ python3 scripts/publish_scheduled.py --date 2026-06-17 # specific day
+ python3 scripts/publish_scheduled.py --dry-run # show plan, no dispatch
+ python3 scripts/publish_scheduled.py --window 90 # widen or narrow window
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import re
+import subprocess
+import sys
+from datetime import datetime, time, timedelta
+from pathlib import Path
+from zoneinfo import ZoneInfo
+
+ROOT = Path(__file__).resolve().parent.parent
+CALENDAR_DIR = ROOT / "workspace" / "marketing" / "calendars"
+DISPATCH_SCRIPT = ROOT / "scripts" / "post_social.py"
+LEDGER_PATH = ROOT / "workspace" / "social" / "scheduled_posts.jsonl"
+
+BRT = ZoneInfo("America/Sao_Paulo")
+
+# Legacy format: "### Dia 1 — Seg 16/06"
+DAY_HEADER_RE = re.compile(r"^###\s+Dia\s+(\d+)\s+—\s+\w+\s+(\d{2})/(\d{2})\b")
+# V2 format: "### 🟦 Seg 16/06 — DIA 1 · EIXO ..."
+DAY_HEADER_V2_RE = re.compile(
+ r"^###\s+[^\w]*\s*(\w{3})\s+(\d{2})/(\d{2})\s+—\s+DIA\s+(\d+)\b"
+)
+SLOT_LINE_RE = re.compile(
+ r"^\|\s*(X|LinkedIn|Instagram)\s*\|\s*(\d{1,2}):(\d{2})\s*\|"
+)
+CHANNEL_ALIAS = {"X": "x", "LinkedIn": "linkedin", "Instagram": "instagram"}
+
+# Cadência table parser — matches the "Cadência e horários" section
+# Format: | Dia | X | LinkedIn | Instagram |
+# | Seg 16 | — | 09:00 | — | (v2: with date)
+# | Seg | — | 09:00 | — | (legacy: day name only)
+CADENCIA_HEADER_RE = re.compile(r"^\|\s*Dia\s*\|\s*X\s*\|\s*LinkedIn\s*\|\s*Instagram\s*\|")
+CADENCIA_ROW_RE = re.compile(
+ r"^\|\s*(\w+(?:\s+\d{2})?)\s*\|\s*([^|]*?)\s*\|\s*([^|]*?)\s*\|\s*([^|]*?)\s*\|"
+)
+DAY_NAME_TO_INDEX = {
+ "seg": 0, "ter": 1, "qua": 2, "qui": 3, "sex": 4, "sáb": 5, "dom": 5,
+ "sab": 5, # without accent
+}
+TIME_RE = re.compile(r"(\d{1,2}):(\d{2})")
+
+
+def brt_now() -> datetime:
+ return datetime.now(BRT)
+
+
+def brt_today_str() -> str:
+ return brt_now().strftime("%d/%m")
+
+
+def parse_day_label(label: str) -> datetime:
+ """Convert 'Dia 1 — Seg 16/06' → datetime in current year (BRT-naive date)."""
+ m = DAY_HEADER_RE.search(label)
+ if not m:
+ raise ValueError(f"unrecognized day header: {label!r}")
+ _, dd, mm = m.groups()
+ today = brt_now().date()
+ return datetime(today.year, int(mm), int(dd))
+
+
+def parse_day_label_v2(day_name: str, dd: str, mm: str) -> datetime:
+ """Convert ('Seg', '16', '06') → datetime in current year (BRT-naive date)."""
+ today = brt_now().date()
+ return datetime(today.year, int(mm), int(dd))
+
+
+def parse_any_day_header(line: str) -> tuple[int, datetime] | None:
+ """Return (day_index, date) for a day header in legacy or v2 format, else None.
+
+ Legacy: '### Dia 1 — Seg 16/06'
+ V2: '### 🟦 Seg 16/06 — DIA 1 · EIXO ...'
+ """
+ m = DAY_HEADER_RE.search(line)
+ if m:
+ day_index, dd, mm = m.groups()
+ today = brt_now().date()
+ return int(day_index), datetime(today.year, int(mm), int(dd))
+ m = DAY_HEADER_V2_RE.search(line)
+ if m:
+ day_name, dd, mm, day_index = m.groups()
+ return int(day_index), parse_day_label_v2(day_name, dd, mm)
+ return None
+
+
+def find_latest_calendar() -> Path:
+ if not CALENDAR_DIR.exists():
+ raise SystemExit(f"calendar dir not found: {CALENDAR_DIR}")
+ files = [p for p in CALENDAR_DIR.iterdir()
+ if p.is_file() and p.name.startswith("[C]calendario-editorial-") and p.suffix == ".md"]
+ files.sort()
+ if not files:
+ raise SystemExit(f"no editorial calendar under {CALENDAR_DIR}")
+ return files[-1]
+
+
+def parse_cadencia_table(calendar_text: str) -> dict[int, dict[str, time]]:
+ """Parse the cadência/horários table → {day_index: {channel: time}}.
+
+ The cadência table maps day-of-week names to channel times:
+ | Dia | X | LinkedIn | Instagram |
+ | Seg | — | 09:00 | — |
+
+ We match day names to Dia headers by order (1st day = first row, etc.).
+ """
+ in_cadencia = False
+ rows: list[tuple[str, str, str, str]] = []
+ for raw in calendar_text.splitlines():
+ stripped = raw.strip()
+ if CADENCIA_HEADER_RE.match(stripped):
+ in_cadencia = True
+ continue
+ if in_cadencia:
+ if not stripped.startswith("|"):
+ break # end of table
+ m = CADENCIA_ROW_RE.match(stripped)
+ if m:
+ rows.append(m.groups())
+
+ # Map rows to day indices by matching day names to Dia headers
+ # First, collect all Dia header dates in order (legacy + v2 formats)
+ day_dates: list[tuple[int, datetime]] = []
+ for raw in calendar_text.splitlines():
+ parsed = parse_any_day_header(raw)
+ if parsed:
+ day_dates.append(parsed)
+
+ result: dict[int, dict[str, time]] = {}
+ for row_day_name, x_cell, li_cell, ig_cell in rows:
+ day_key = row_day_name.strip().lower()[:3]
+ # Find matching Dia header by day-of-week
+ day_idx = None
+ for idx, dt in day_dates:
+ if dt.strftime("%a").lower()[:3] == day_key:
+ day_idx = idx
+ break
+ if day_idx is None:
+ # Fallback: match by position
+ row_pos = list(DAY_NAME_TO_INDEX.get(day_key, -1) for _ in [0])
+ continue
+
+ slots: dict[str, time] = {}
+ for channel_key, cell in [("x", x_cell), ("linkedin", li_cell), ("instagram", ig_cell)]:
+ cell = cell.strip()
+ if cell == "—" or cell == "-" or not cell:
+ continue
+ m_time = TIME_RE.search(cell)
+ if m_time:
+ slots[channel_key] = time(int(m_time.group(1)), int(m_time.group(2)))
+ if slots:
+ result[day_idx] = slots
+
+ return result
+
+
+def parse_slots(calendar_text: str) -> list[dict]:
+ """Return one record per day with its (channel, time, slug_day).
+
+ Tries the inline SLOT_LINE_RE first (legacy format). If no slots found,
+ falls back to parsing the cadência table at the bottom of the calendar.
+ """
+ # Try legacy inline format first
+ days: list[dict] = []
+ current: dict | None = None
+ for raw in calendar_text.splitlines():
+ m_header = DAY_HEADER_RE.search(raw)
+ if m_header:
+ if current is not None:
+ days.append(current)
+ current = {"day_index": int(m_header.group(1)), "date": parse_day_label(raw), "slots": []}
+ continue
+ if current is None:
+ continue
+ m_slot = SLOT_LINE_RE.match(raw)
+ if not m_slot:
+ continue
+ channel_raw, hh, mm = m_slot.groups()
+ channel = CHANNEL_ALIAS[channel_raw]
+ current["slots"].append({"channel": channel, "time": time(int(hh), int(mm))})
+ if current is not None:
+ days.append(current)
+
+ if any(d["slots"] for d in days):
+ return days # legacy format worked
+
+ # Fallback: parse cadência table
+ cadencia = parse_cadencia_table(calendar_text)
+ if not cadencia:
+ return days # return empty (will show no_slots)
+
+ # Build day records from Dia headers + cadencia mapping (legacy + v2)
+ days = []
+ for raw in calendar_text.splitlines():
+ parsed = parse_any_day_header(raw)
+ if not parsed:
+ continue
+ idx, dt = parsed
+ day_slots = []
+ for channel, t in cadencia.get(idx, {}).items():
+ day_slots.append({"channel": channel, "time": t})
+ days.append({"day_index": idx, "date": dt, "slots": day_slots})
+
+ return days
+
+
+# Tolerate v2 label suffixes: "Hook Reels", "CTA slide 7", "Hook T1", etc.
+HOOK_RE = re.compile(r"\|\s*\*{0,2}Hook[^|]*\|\s*(.+?)\s*\|")
+BODY_RE = re.compile(r"\|\s*\*{0,2}Ângulo[^|]*\|\s*(.+?)\s*\|")
+CTA_RE = re.compile(r"\|\s*\*{0,2}CTA[^|]*\|\s*(.+?)\s*\|")
+
+
+def extract_post_text(calendar_text: str, day_index: int) -> str:
+ """Extract post body from a day's section in the calendar.
+
+ Looks for the Hook field in the day's content table and builds a
+ post-ready string: hook + angle + CTA.
+ """
+ # Find the section for this day (matches legacy '### Dia N' and v2 headers)
+ lines = calendar_text.splitlines()
+ in_section = False
+ hook = angle = cta = ""
+ for line in lines:
+ parsed = parse_any_day_header(line)
+ if parsed:
+ if parsed[0] == day_index:
+ in_section = True
+ continue
+ if in_section:
+ break # reached the next day's section
+ continue
+ if in_section:
+ m = HOOK_RE.match(line)
+ if m:
+ hook = m.group(1).strip().strip('"').strip("'")
+ continue
+ m = BODY_RE.match(line)
+ if m:
+ angle = m.group(1).strip()
+ continue
+ m = CTA_RE.match(line)
+ if m:
+ cta = m.group(1).strip()
+ continue
+
+ parts = [p for p in [hook, angle, cta] if p]
+ if parts:
+ return " ".join(parts)
+ return ""
+
+
+def slot_key(day_index: int, channel: str, slot_time: time) -> str:
+ return f"d{day_index}-{channel}-{slot_time.strftime('%H%M')}"
+
+
+def load_ledger() -> set[str]:
+ if not LEDGER_PATH.exists():
+ return set()
+ seen: set[str] = set()
+ for line in LEDGER_PATH.read_text(encoding="utf-8").splitlines():
+ line = line.strip()
+ if not line:
+ continue
+ try:
+ row = json.loads(line)
+ except json.JSONDecodeError:
+ continue
+ if row.get("key"):
+ seen.add(row["key"])
+ return seen
+
+
+def append_ledger(row: dict) -> None:
+ LEDGER_PATH.parent.mkdir(parents=True, exist_ok=True)
+ with LEDGER_PATH.open("a", encoding="utf-8") as f:
+ f.write(json.dumps(row, ensure_ascii=False) + "\n")
+
+
+def dispatch(text: str, channel: str, day_index: int, dry_run: bool) -> dict:
+ """Delegate to scripts/post_social.py so we keep one source of dispatch truth."""
+ cmd = [sys.executable, str(DISPATCH_SCRIPT), channel, text]
+ if dry_run:
+ cmd.append("--dry-run")
+ try:
+ proc = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
+ except subprocess.TimeoutExpired:
+ return {"channel": channel, "status": "error", "error": "timeout in post_social.py"}
+ return {
+ "channel": channel,
+ "status": "dispatched" if proc.returncode == 0 else "error",
+ "returncode": proc.returncode,
+ "stdout_tail": proc.stdout.strip().splitlines()[-3:],
+ "stderr_tail": proc.stderr.strip().splitlines()[-3:],
+ }
+
+
+def select_target_day(days: list[dict], force_date: datetime | None) -> dict | None:
+ target = force_date.date() if force_date else brt_now().date()
+ for day in days:
+ if day["date"].date() == target:
+ return day
+ return None
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description="Dispatch scheduled social posts for the day")
+ parser.add_argument("--date", help="Target date dd/mm/yyyy; default = today BRT")
+ parser.add_argument("--window", type=int, default=60,
+ help="Minutes after slot time the post is still eligible (default 60)")
+ parser.add_argument("--dry-run", action="store_true", help="Show what would be dispatched; don't write anything")
+ parser.add_argument("--calendar", help="Path to a specific calendar file (overrides latest detection)")
+ args = parser.parse_args()
+
+ calendar_path = Path(args.calendar) if args.calendar else find_latest_calendar()
+ days = parse_slots(calendar_path.read_text(encoding="utf-8"))
+
+ force_date = None
+ if args.date:
+ try:
+ force_date = datetime.strptime(args.date, "%d/%m/%Y").replace(tzinfo=BRT)
+ except ValueError:
+ raise SystemExit(f"--date must be dd/mm/yyyy, got {args.date!r}")
+
+ day = select_target_day(days, force_date)
+ if day is None:
+ target = (force_date or brt_now()).strftime("%d/%m/%Y")
+ print(json.dumps({"status": "no_slots", "calendar": calendar_path.name,
+ "target_date": target, "dispatched": []}, indent=2, ensure_ascii=False))
+ return 0
+
+ now = brt_now()
+ seen = load_ledger()
+ dispatched: list[dict] = []
+ untouched: list[dict] = []
+
+ for slot in day["slots"]:
+ slot_dt = now.replace(hour=slot["time"].hour, minute=slot["time"].minute,
+ second=0, microsecond=0)
+ slot_end = slot_dt + timedelta(minutes=args.window)
+ key = slot_key(day["day_index"], slot["channel"], slot["time"])
+
+ if key in seen:
+ untouched.append({"key": key, "reason": "already_dispatched",
+ "slot_brt": slot_dt.strftime("%H:%M")})
+ continue
+
+ if now < slot_dt:
+ untouched.append({"key": key, "reason": "not_yet",
+ "slot_brt": slot_dt.strftime("%H:%M"),
+ "window_ends_brt": slot_end.strftime("%H:%M")})
+ continue
+ if now >= slot_end:
+ untouched.append({"key": key, "reason": "window_expired",
+ "slot_brt": slot_dt.strftime("%H:%M"),
+ "window_ends_brt": slot_end.strftime("%H:%M")})
+ continue
+
+ post_text = extract_post_text(calendar_path.read_text(encoding="utf-8"), day["day_index"])
+ if not post_text:
+ post_text = f"[W{calendar_path.stem.split('-')[-1]}-D{day['day_index']}-{slot['channel']}]"
+ result = dispatch(post_text, slot["channel"], day["day_index"], args.dry_run)
+ record = {
+ "key": key,
+ "slot_brt": slot_dt.strftime("%H:%M"),
+ "channel": slot["channel"],
+ "day_index": day["day_index"],
+ "calendar": calendar_path.name,
+ "dispatched_at": now.isoformat(timespec="seconds"),
+ "dry_run": args.dry_run,
+ **result,
+ }
+ if not args.dry_run:
+ append_ledger(record)
+ dispatched.append(record)
+
+ summary = {
+ "status": "ok",
+ "calendar": calendar_path.name,
+ "target_date": day["date"].strftime("%d/%m/%Y"),
+ "brt_now": now.strftime("%Y-%m-%d %H:%M:%S"),
+ "window_minutes": args.window,
+ "dry_run": args.dry_run,
+ "dispatched_count": len(dispatched),
+ "dispatched": dispatched,
+ "untouched": untouched,
+ }
+ print(json.dumps(summary, indent=2, ensure_ascii=False))
+ failed = [r for r in dispatched if r.get("status") == "error"]
+ return 1 if failed else 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/telegram_litellm_proxy.sh b/scripts/telegram_litellm_proxy.sh
new file mode 100755
index 00000000..d1a8ea9f
--- /dev/null
+++ b/scripts/telegram_litellm_proxy.sh
@@ -0,0 +1,28 @@
+#!/usr/bin/env bash
+# Start the LiteLLM proxy that fronts NVIDIA NIM with an Anthropic-compatible
+# /v1/messages endpoint, so `claude --channels` (Telegram bot) can run on NVIDIA.
+set -euo pipefail
+cd "$(dirname "$0")/.."
+
+# LiteLLM config references os.environ/NVIDIA_API_KEY. Prefer an explicit env
+# value, then .env, then the dashboard-managed provider config.
+if [ -z "${NVIDIA_API_KEY:-}" ]; then
+ if [ -f .env ]; then
+ NVIDIA_API_KEY="$(awk -F= '/^NVIDIA_API_KEY=/{sub(/^NVIDIA_API_KEY=/,""); gsub(/^["'\'']|["'\'']$/, ""); print; exit}' .env)"
+ fi
+ if [ -z "${NVIDIA_API_KEY:-}" ] && [ -f config/providers.json ]; then
+ NVIDIA_API_KEY="$(node -e "const fs=require('fs'); const c=JSON.parse(fs.readFileSync('config/providers.json','utf8')); const e=c.providers?.nvidia?.env_vars||{}; process.stdout.write(e.NVIDIA_API_KEY || e.OPENAI_API_KEY || '')")"
+ fi
+ export NVIDIA_API_KEY
+fi
+
+if [ -z "${NVIDIA_API_KEY:-}" ]; then
+ echo "NVIDIA_API_KEY is missing. Configure NVIDIA in the dashboard or set it in .env." >&2
+ exit 1
+fi
+
+PORT="${LITELLM_PORT:-4000}"
+exec .venv/bin/litellm \
+ --config config/litellm-telegram.yaml \
+ --host 127.0.0.1 \
+ --port "$PORT"
diff --git a/scripts/telegram_provider_bot.py b/scripts/telegram_provider_bot.py
new file mode 100644
index 00000000..ee68fe3b
--- /dev/null
+++ b/scripts/telegram_provider_bot.py
@@ -0,0 +1,1002 @@
+#!/usr/bin/env python3
+"""Telegram bot runtime backed by the active EvoNexus provider.
+
+This intentionally bypasses Claude Code Channels for provider-backed chat:
+non-Anthropic models can answer in the terminal without reliably calling the
+Telegram `reply` MCP tool, which means users see no message in Telegram.
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import re
+import subprocess
+import sys
+import tempfile
+import time
+import urllib.error
+import urllib.request
+from pathlib import Path
+
+
+ROOT = Path(__file__).resolve().parent.parent
+PROVIDERS_PATH = ROOT / "config" / "providers.json"
+TELEGRAM_STATE = Path.home() / ".claude" / "channels" / "telegram"
+TELEGRAM_ENV = TELEGRAM_STATE / ".env"
+ACCESS_FILE = TELEGRAM_STATE / "access.json"
+DIRECT_STATE = TELEGRAM_STATE / "direct_state.json"
+CHAT_MEMORY_DIR = TELEGRAM_STATE / "memory"
+INBOX_DIR = TELEGRAM_STATE / "inbox"
+GROQ_TRANSCRIPTION_URL = "https://api.groq.com/openai/v1/audio/transcriptions"
+GROQ_TRANSCRIPTION_MODEL = os.environ.get("GROQ_TRANSCRIPTION_MODEL", "whisper-large-v3-turbo")
+MAX_MEMORY_MESSAGES = 8
+MAX_MEMORY_CHARS = 1800
+MAX_STORED_MESSAGE_CHARS = 1600
+TELEGRAM_CODEX_MODELS = (None,)
+
+
+def _load_workspace_env() -> None:
+ """Load root .env without depending on python-dotenv."""
+ env_file = ROOT / ".env"
+ if not env_file.exists():
+ return
+ for line in env_file.read_text(encoding="utf-8").splitlines():
+ stripped = line.strip()
+ if not stripped or stripped.startswith("#") or "=" not in stripped:
+ continue
+ key, value = stripped.split("=", 1)
+ key = key.strip()
+ value = value.strip().strip("'\"")
+ if key and _usable_secret(value):
+ os.environ.setdefault(key, value)
+
+
+def _usable_secret(value: str | None) -> bool:
+ if not value:
+ return False
+ value = value.strip()
+ return value not in {"[REDACTED]", "your_bot_token_here", "your_chat_id_here", "REDACTED"}
+
+
+_load_workspace_env()
+GROQ_AUDIO_SUFFIXES = {
+ ".oga": ".ogg",
+ ".opus": ".opus",
+ ".ogg": ".ogg",
+ ".mp3": ".mp3",
+ ".m4a": ".m4a",
+ ".mp4": ".mp4",
+ ".mpeg": ".mpeg",
+ ".mpga": ".mpga",
+ ".wav": ".wav",
+ ".webm": ".webm",
+ ".flac": ".flac",
+}
+
+SECRET_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
+ (
+ re.compile(
+ r"(?i)\b(NVIDIA_API_KEY|OPENAI_API_KEY|ANTHROPIC_API_KEY|TELEGRAM_BOT_TOKEN)\b\s*[:=]\s*['\"]?[^'\"\s;]+"
+ ),
+ r"\1=[REDACTED]",
+ ),
+ (re.compile(r"\bsk-[A-Za-z0-9]{16,}\b"), "[REDACTED]"),
+ (re.compile(r"\bgsk_[A-Za-z0-9]{16,}\b"), "[REDACTED]"),
+ (re.compile(r"\bgh[pousr]_[A-Za-z0-9]{16,}\b"), "[REDACTED]"),
+ (re.compile(r"\boxb-[A-Za-z0-9-]{16,}\b"), "[REDACTED]"),
+)
+
+
+def log(message: str) -> None:
+ print(f"[telegram-provider] {message}", flush=True)
+
+
+def read_json(path: Path, default: dict) -> dict:
+ try:
+ return json.loads(path.read_text(encoding="utf-8"))
+ except Exception:
+ return default
+
+
+def redact_secrets(text: str) -> str:
+ redacted = text
+ for pattern, replacement in SECRET_PATTERNS:
+ redacted = pattern.sub(replacement, redacted)
+ return redacted
+
+
+def read_telegram_token() -> str:
+ if os.environ.get("TELEGRAM_BOT_TOKEN"):
+ return os.environ["TELEGRAM_BOT_TOKEN"]
+ try:
+ for line in TELEGRAM_ENV.read_text(encoding="utf-8").splitlines():
+ if line.startswith("TELEGRAM_BOT_TOKEN="):
+ return line.split("=", 1)[1].strip().strip('"').strip("'")
+ except OSError:
+ pass
+ raise RuntimeError(f"TELEGRAM_BOT_TOKEN missing in {TELEGRAM_ENV}")
+
+
+def read_env_value(path: Path, key: str) -> str | None:
+ try:
+ for line in path.read_text(encoding="utf-8").splitlines():
+ line = line.strip()
+ if not line or line.startswith("#") or "=" not in line:
+ continue
+ name, value = line.split("=", 1)
+ if name.strip() == key:
+ return value.strip().strip('"').strip("'")
+ except OSError:
+ return None
+ return None
+
+
+def read_groq_api_key() -> str:
+ if os.environ.get("GROQ_API_KEY"):
+ return os.environ["GROQ_API_KEY"]
+ for path in (ROOT / ".env", TELEGRAM_ENV):
+ value = read_env_value(path, "GROQ_API_KEY")
+ if value:
+ return value
+ config = read_json(PROVIDERS_PATH, {})
+ for provider in config.get("providers", {}).values():
+ env = provider.get("env_vars", {})
+ value = env.get("GROQ_API_KEY")
+ if value:
+ return str(value)
+ raise RuntimeError("GROQ_API_KEY nao configurada")
+
+
+def write_env_value(path: Path, key: str, value: str) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ lines: list[str] = []
+ replaced = False
+ try:
+ existing = path.read_text(encoding="utf-8").splitlines()
+ except OSError:
+ existing = []
+ for line in existing:
+ if line.strip().startswith(f"{key}="):
+ lines.append(f"{key}={value}")
+ replaced = True
+ else:
+ lines.append(line)
+ if not replaced:
+ lines.append(f"{key}={value}")
+ path.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8")
+
+
+def parse_groq_command(text: str) -> str | None:
+ if not text.startswith("/groq"):
+ return None
+ parts = text.split(maxsplit=2)
+ if len(parts) == 1:
+ return "status"
+ action = parts[1].strip().lower()
+ if action in {"status", "set"}:
+ return action if action == "status" else f"set {parts[2].strip() if len(parts) > 2 else ''}"
+ return "help"
+
+
+def handle_groq_command(command: str) -> str:
+ if command == "status":
+ try:
+ read_groq_api_key()
+ return f"Groq configurado. Modelo de transcricao: {GROQ_TRANSCRIPTION_MODEL}"
+ except Exception as exc:
+ return f"Groq nao configurado: {exc}\nUse: /groq set "
+ if command.startswith("set "):
+ value = command.split(" ", 1)[1].strip()
+ if not value.startswith("gsk_"):
+ return "Chave Groq invalida. Ela deve comecar com gsk_."
+ write_env_value(TELEGRAM_ENV, "GROQ_API_KEY", value)
+ return "Groq configurado para transcricao de audio."
+ return "Comandos: /groq status | /groq set "
+
+
+def api(token: str, method: str, payload: dict | None = None, timeout: int = 35) -> dict:
+ url = f"https://api.telegram.org/bot{token}/{method}"
+ data = None
+ headers = {}
+ if payload is not None:
+ data = json.dumps(payload).encode("utf-8")
+ headers["Content-Type"] = "application/json"
+ req = urllib.request.Request(url, data=data, headers=headers)
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
+ return json.loads(resp.read().decode("utf-8"))
+
+
+def download_telegram_file(token: str, file_id: str) -> tuple[Path, str]:
+ data = api(token, "getFile", {"file_id": file_id}, timeout=20)
+ file_path = data.get("result", {}).get("file_path")
+ if not file_path:
+ raise RuntimeError("Telegram nao retornou file_path")
+ suffix = GROQ_AUDIO_SUFFIXES.get(Path(file_path).suffix.lower(), ".ogg")
+ url = f"https://api.telegram.org/file/bot{token}/{file_path}"
+ with urllib.request.urlopen(url, timeout=60) as resp:
+ audio_bytes = resp.read()
+ tmp = tempfile.NamedTemporaryFile(prefix="telegram-audio-", suffix=suffix, delete=False)
+ try:
+ tmp.write(audio_bytes)
+ return Path(tmp.name), suffix
+ finally:
+ tmp.close()
+
+
+def save_telegram_file(token: str, file_id: str, *, suffix: str | None = None) -> Path:
+ data = api(token, "getFile", {"file_id": file_id}, timeout=20)
+ file_path = data.get("result", {}).get("file_path")
+ if not file_path:
+ raise RuntimeError("Telegram nao retornou file_path")
+ file_suffix = suffix or Path(file_path).suffix or ".bin"
+ url = f"https://api.telegram.org/file/bot{token}/{file_path}"
+ with urllib.request.urlopen(url, timeout=60) as resp:
+ content = resp.read()
+ INBOX_DIR.mkdir(parents=True, exist_ok=True)
+ target = INBOX_DIR / f"{int(time.time() * 1000)}-{Path(file_path).stem}{file_suffix}"
+ target.write_bytes(content)
+ return target
+
+
+def multipart_form_data(fields: dict[str, str], files: dict[str, tuple[str, bytes, str]]) -> tuple[bytes, str]:
+ boundary = f"----evonexus-{int(time.time() * 1000)}"
+ chunks: list[bytes] = []
+ for name, value in fields.items():
+ chunks.extend([
+ f"--{boundary}\r\n".encode("utf-8"),
+ f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode("utf-8"),
+ str(value).encode("utf-8"),
+ b"\r\n",
+ ])
+ for name, (filename, content, content_type) in files.items():
+ chunks.extend([
+ f"--{boundary}\r\n".encode("utf-8"),
+ (
+ f'Content-Disposition: form-data; name="{name}"; filename="{filename}"\r\n'
+ f"Content-Type: {content_type}\r\n\r\n"
+ ).encode("utf-8"),
+ content,
+ b"\r\n",
+ ])
+ chunks.append(f"--{boundary}--\r\n".encode("utf-8"))
+ return b"".join(chunks), boundary
+
+
+def transcribe_audio(audio_path: Path) -> str:
+ api_key = read_groq_api_key()
+ upload_suffix = GROQ_AUDIO_SUFFIXES.get(audio_path.suffix.lower(), ".ogg")
+ upload_path = audio_path
+ temp_upload: Path | None = None
+ if audio_path.suffix.lower() != upload_suffix:
+ temp = tempfile.NamedTemporaryFile(prefix="groq-upload-", suffix=upload_suffix, delete=False)
+ try:
+ temp.write(audio_path.read_bytes())
+ temp_upload = Path(temp.name)
+ upload_path = temp_upload
+ finally:
+ temp.close()
+ try:
+ proc = subprocess.run(
+ [
+ "curl",
+ "-sS",
+ "-H",
+ f"Authorization: Bearer {api_key}",
+ "-F",
+ f"file=@{upload_path}",
+ "-F",
+ f"model={GROQ_TRANSCRIPTION_MODEL}",
+ "-F",
+ "response_format=json",
+ "-F",
+ "language=pt",
+ GROQ_TRANSCRIPTION_URL,
+ ],
+ text=True,
+ capture_output=True,
+ timeout=120,
+ )
+ finally:
+ if temp_upload:
+ try:
+ temp_upload.unlink()
+ except OSError:
+ pass
+ if proc.returncode != 0:
+ raise RuntimeError((proc.stderr or proc.stdout or "curl failed").strip()[:240])
+ data = json.loads(proc.stdout)
+ if data.get("error"):
+ raise RuntimeError(str(data["error"].get("message") or data["error"])[:240])
+ text = str(data.get("text") or "").strip()
+ if not text:
+ raise RuntimeError("Groq nao retornou transcricao")
+ return text
+
+
+def message_audio_file_id(message: dict) -> str | None:
+ voice = message.get("voice") or {}
+ audio = message.get("audio") or {}
+ document = message.get("document") or {}
+ mime_type = str(document.get("mime_type") or "")
+ if voice.get("file_id"):
+ return str(voice["file_id"])
+ if audio.get("file_id"):
+ return str(audio["file_id"])
+ if mime_type.startswith("audio/") and document.get("file_id"):
+ return str(document["file_id"])
+ return None
+
+
+def message_image_file_id(message: dict) -> tuple[str, str] | None:
+ photos = message.get("photo") or []
+ if photos:
+ largest = max(photos, key=lambda item: int(item.get("file_size") or item.get("width") or 0))
+ if largest.get("file_id"):
+ return str(largest["file_id"]), ".jpg"
+ document = message.get("document") or {}
+ mime_type = str(document.get("mime_type") or "")
+ if mime_type.startswith("image/") and document.get("file_id"):
+ suffix = Path(str(document.get("file_name") or "")).suffix or f".{mime_type.split('/', 1)[1]}"
+ return str(document["file_id"]), suffix
+ return None
+
+
+def handle_audio_message(token: str, chat_id: str, file_id: str) -> str:
+ audio_path: Path | None = None
+ try:
+ audio_path, _suffix = download_telegram_file(token, file_id)
+ return transcribe_audio(audio_path)
+ finally:
+ if audio_path:
+ try:
+ audio_path.unlink()
+ except OSError:
+ pass
+
+
+def allowed_chat(chat_id: str, from_id: str) -> bool:
+ access = read_json(ACCESS_FILE, {"allowFrom": [], "groups": {}, "dmPolicy": "pairing"})
+ if chat_id in {str(x) for x in access.get("allowFrom", [])}:
+ return True
+ if from_id in {str(x) for x in access.get("allowFrom", [])}:
+ return True
+ return chat_id in access.get("groups", {})
+
+
+def provider_chain() -> list[tuple[str, dict]]:
+ config = read_json(PROVIDERS_PATH, {"active_provider": "anthropic", "providers": {}})
+ providers = config.get("providers", {})
+ override_id = os.environ.get("TELEGRAM_PROVIDER") or config.get("telegram_provider")
+ active_id = (
+ override_id
+ or config.get("active_provider")
+ or "anthropic"
+ )
+ active = providers.get(active_id, {})
+ ids = [active_id]
+ if not override_id:
+ ids.extend(pid for pid in active.get("fallback_providers", []) if pid not in ids)
+ return [(pid, providers.get(pid, {})) for pid in ids if providers.get(pid) is not None]
+
+
+def active_provider_info() -> tuple[str, str | None, str | None]:
+ config = read_json(PROVIDERS_PATH, {"active_provider": "anthropic", "providers": {}})
+ provider_id = (
+ os.environ.get("TELEGRAM_PROVIDER")
+ or config.get("telegram_provider")
+ or config.get("active_provider")
+ or "anthropic"
+ )
+ provider = config.get("providers", {}).get(provider_id, {})
+ env = provider.get("env_vars", {})
+ model = env.get("OPENAI_MODEL") or env.get("GEMINI_MODEL") or provider.get("default_model")
+ base_url = env.get("OPENAI_BASE_URL") or provider.get("default_base_url")
+ return provider_id, model, base_url
+
+
+def set_telegram_provider(provider_id: str | None) -> str:
+ config = read_json(PROVIDERS_PATH, {"active_provider": "anthropic", "providers": {}})
+ if provider_id in {"", "active", "global", "default", "none", None}:
+ config.pop("telegram_provider", None)
+ PROVIDERS_PATH.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
+ active = config.get("active_provider") or "anthropic"
+ return f"Telegram agora segue o provider global: {active}"
+ if provider_id not in config.get("providers", {}):
+ available = ", ".join(sorted(config.get("providers", {}).keys()))
+ return f"Provider invalido: {provider_id}. Disponiveis: {available}"
+ config["telegram_provider"] = provider_id
+ PROVIDERS_PATH.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
+ provider = config.get("providers", {}).get(provider_id, {})
+ env = provider.get("env_vars", {})
+ model = env.get("OPENAI_MODEL") or env.get("GEMINI_MODEL") or provider.get("default_model") or "default"
+ return f"Telegram agora usa provider: {provider_id}\nmodel: {model}"
+
+
+def chat_memory_path(chat_id: str) -> Path:
+ safe_chat_id = re.sub(r"[^0-9A-Za-z_-]", "_", str(chat_id))
+ return CHAT_MEMORY_DIR / f"{safe_chat_id}.jsonl"
+
+
+def load_chat_memory(chat_id: str) -> list[dict]:
+ path = chat_memory_path(chat_id)
+ if not path.exists():
+ return []
+ messages: list[dict] = []
+ try:
+ for line in path.read_text(encoding="utf-8").splitlines():
+ line = line.strip()
+ if not line:
+ continue
+ try:
+ entry = json.loads(line)
+ except json.JSONDecodeError:
+ continue
+ if entry.get("role") in {"user", "assistant"} and entry.get("text"):
+ messages.append(entry)
+ except OSError:
+ return []
+ return messages
+
+
+def append_chat_memory(chat_id: str, role: str, text: str, *, speaker: str | None = None) -> None:
+ CHAT_MEMORY_DIR.mkdir(parents=True, exist_ok=True)
+ path = chat_memory_path(chat_id)
+ entry = {
+ "role": role,
+ "text": redact_secrets(text)[:MAX_STORED_MESSAGE_CHARS],
+ "ts": int(time.time()),
+ }
+ if speaker:
+ entry["speaker"] = speaker
+ with open(path, "a", encoding="utf-8") as f:
+ f.write(json.dumps(entry, ensure_ascii=False) + "\n")
+
+
+def clear_chat_memory(chat_id: str) -> None:
+ path = chat_memory_path(chat_id)
+ try:
+ path.unlink()
+ except FileNotFoundError:
+ pass
+
+
+def env_presence(keys: tuple[str, ...]) -> str:
+ values: list[str] = []
+ for key in keys:
+ value = os.environ.get(key, "")
+ values.append(f"{key}={'present' if value else 'missing'}")
+ return ", ".join(values)
+
+
+def skill_context(name: str) -> str:
+ skill_md = ROOT / ".claude" / "skills" / name / "SKILL.md"
+ if not skill_md.is_file():
+ return f"Skill {name}: nao encontrada."
+ text = skill_md.read_text(encoding="utf-8")
+ excerpt = "\n".join(line for line in text.splitlines() if line.startswith(("name:", "description:", "envKeys:", "# Ghost Blog Integration", "## Configuração", "## Auth", "- **Admin API**", "- **Content API**", "### Admin API"))).strip()
+ return f"Skill {name} disponível no workspace:\n{excerpt}"
+
+
+def workspace_context() -> str:
+ return "\n".join([
+ skill_context("custom-int-ghost"),
+ f"Env Ghost: {env_presence(('GHOST_URL', 'GHOST_CONTENT_API_KEY', 'GHOST_ADMIN_API_KEY'))}.",
+ "Ghost Admin API usa JWT HS256 gerado de GHOST_ADMIN_API_KEY no formato id:secret; header Authorization: Ghost .",
+ ])
+
+
+def format_chat_memory(messages: list[dict], *, current_speaker: str | None = None) -> str:
+ lines: list[str] = []
+ for entry in messages[-MAX_MEMORY_MESSAGES:]:
+ role = entry.get("role", "?")
+ text = redact_secrets(str(entry.get("text", "")).strip())
+ if not text:
+ continue
+ speaker = entry.get("speaker")
+ if role == "user":
+ label = f"Usuário{f' ({speaker})' if speaker else ''}"
+ else:
+ label = "Assistente"
+ lines.append(f"{label}: {text}")
+ if current_speaker:
+ lines.append(f"Usuário ({current_speaker}):")
+ memory = "\n".join(lines).strip()
+ if len(memory) <= MAX_MEMORY_CHARS:
+ return memory
+ return memory[-MAX_MEMORY_CHARS:]
+
+
+_URL_RE = re.compile(r"https?://[^\s)>\]\"']+")
+
+
+def fetch_url_context(text: str, max_urls: int = 3, max_chars: int = 6000) -> str:
+ """Fetch the content of URLs mentioned in the message so the model can 'read'
+ them. The bot talks to NVIDIA via plain chat completions (no browser tool), so
+ without this it always answers 'não consigo navegar'. We fetch server-side and
+ inject the text."""
+ urls = []
+ for u in _URL_RE.findall(text or ""):
+ u = u.rstrip(".,;")
+ if u not in urls:
+ urls.append(u)
+ if len(urls) >= max_urls:
+ break
+ if not urls:
+ return ""
+ blocks = []
+ for u in urls:
+ try:
+ req = urllib.request.Request(u, headers={"User-Agent": "Mozilla/5.0 (EvoNexus)"})
+ with urllib.request.urlopen(req, timeout=15) as resp:
+ ctype = resp.headers.get("Content-Type", "")
+ raw = resp.read(300000).decode("utf-8", "replace")
+ if "html" in ctype.lower() or raw.lstrip()[:1] == "<":
+ raw = re.sub(r"<(script|style)\b.*?\1>", " ", raw, flags=re.DOTALL | re.I)
+ raw = re.sub(r"<[^>]+>", " ", raw)
+ raw = re.sub(r"\s+", " ", raw)
+ blocks.append(f"[Conteudo de {u}]\n{raw.strip()[:max_chars]}")
+ except Exception as e: # noqa: BLE001
+ blocks.append(f"[Falha ao acessar {u}: {e}]")
+ return "\n\n".join(blocks)
+
+
+def build_prompt(chat_id: str, prompt_text: str, *, speaker: str | None = None) -> str:
+ memory = format_chat_memory(load_chat_memory(chat_id), current_speaker=speaker)
+ clean_prompt = redact_secrets(prompt_text.strip())
+ parts = [
+ "Voce e o runtime Telegram do EvoNexus, operando dentro do workspace local.",
+ "Responda em portugues, curto e objetivo.",
+ "Nao diga que nao tem acesso a ferramentas de forma generica.",
+ "Quando a mensagem contiver URLs, o conteudo delas ja foi buscado e esta abaixo em 'Conteudo das URLs' — USE esse conteudo; nunca diga que nao consegue navegar.",
+ "Quando o usuario pedir uma acao, tente executar pelo workspace/integracoes disponiveis.",
+ "Se houver bloqueio real, responda somente o bloqueio concreto: credencial, arquivo, permissao, endpoint ou erro.",
+ "Se a mensagem veio de audio transcrito, use a transcricao apenas como entrada interna; nao repita a transcricao ao usuario.",
+ "Use a memoria recente abaixo apenas quando for relevante; ignore respostas antigas que negaram acesso genericamente.",
+ "Contexto de integracoes locais:",
+ workspace_context(),
+ "",
+ ]
+ url_ctx = fetch_url_context(clean_prompt)
+ if url_ctx:
+ parts.extend(["Conteudo das URLs mencionadas:", url_ctx, ""])
+ if memory:
+ parts.extend([
+ "Memoria recente da conversa:",
+ memory,
+ "",
+ ])
+ parts.extend([
+ "Mensagem atual:",
+ clean_prompt,
+ ])
+ return "\n".join(parts).strip()
+
+
+def is_provider_question(text: str) -> bool:
+ # Only a SHORT message can be a "which model are you using?" question. Pasted
+ # docs (llms.txt, etc.) mention "api/model/nvidia/usar" and were wrongly caught
+ # here, making the bot reply the provider status instead of answering.
+ if len(text.strip()) > 80:
+ return False
+ lower = text.lower()
+ provider_terms = ("provider", "modelo", "model", "llm", "nvidia", "codex", "openai")
+ question_terms = ("qual", "quem", "usando", "rodando", "ta com", "tá com")
+ return any(term in lower for term in provider_terms) and any(term in lower for term in question_terms)
+
+
+def parse_provider_command(text: str) -> str | None:
+ if not text.startswith("/provider"):
+ return None
+ parts = text.split(maxsplit=1)
+ return parts[1].strip() if len(parts) > 1 else "status"
+
+
+def models_for(provider: dict) -> list[str | None]:
+ env = provider.get("env_vars", {})
+ primary = env.get("OPENAI_MODEL") or provider.get("default_model")
+ models: list[str | None] = []
+ if primary:
+ models.append(primary)
+ for model in provider.get("fallback_models", []):
+ if model and model not in models:
+ models.append(model)
+ if not models:
+ models.append(None)
+ return models
+
+
+def provider_models(provider_id: str, provider: dict) -> list[str | None]:
+ if provider_id == "codex_auth":
+ return list(TELEGRAM_CODEX_MODELS)
+ return models_for(provider)
+
+
+def invoke_openai_compatible(provider_id: str, provider: dict, model: str, prompt: str) -> str:
+ env = provider.get("env_vars", {})
+ base_url = (env.get("OPENAI_BASE_URL") or provider.get("default_base_url") or "https://api.openai.com/v1").rstrip("/")
+ # A chave do próprio provider vem primeiro — o env do processo carrega a
+ # chave do provider global (NVIDIA_API_KEY do .env) e sequestrava chamadas
+ # a outros gateways (omnirouter recebia a chave NVIDIA → 401). A chave
+ # NVIDIA do env só vale como fallback para endpoints da própria NVIDIA.
+ candidates = [env.get("OPENAI_API_KEY"), env.get("NVIDIA_API_KEY")]
+ if "nvidia.com" in base_url.lower():
+ candidates.append(os.environ.get("NVIDIA_API_KEY"))
+ candidates.append(os.environ.get("OPENAI_API_KEY"))
+ api_key = next((k for k in candidates if _usable_secret(k)), None)
+ if not api_key:
+ raise RuntimeError(f"{provider_id} has no usable API key")
+ payload = {
+ "model": model,
+ "messages": [
+ {
+ "role": "system",
+ "content": (
+ "Voce e o assistente Telegram do EvoNexus. Responda em portugues, "
+ "de forma direta e util. Runtime atual: "
+ f"provider={provider_id}, model={model}, base_url={base_url}. "
+ "Se perguntarem qual LLM, provider ou modelo voce usa, responda exatamente com esses dados."
+ ),
+ },
+ {"role": "user", "content": prompt},
+ ],
+ "max_tokens": 900,
+ "temperature": 0.4,
+ # Gateways como o OmniRoute streamam SSE por padrão; sem stream=false
+ # o corpo vem em chunks "data: {...}" e o json.loads explode
+ # ("Expecting value: line 1 column 1").
+ "stream": False,
+ }
+ req = urllib.request.Request(
+ f"{base_url}/chat/completions",
+ data=json.dumps(payload).encode("utf-8"),
+ headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
+ )
+ try:
+ with urllib.request.urlopen(req, timeout=90) as resp:
+ raw = resp.read().decode("utf-8")
+ except urllib.error.HTTPError as exc:
+ if exc.code == 401:
+ raise RuntimeError(f"401 Unauthorized: chave API inválida/expirada para {provider_id}") from exc
+ raise
+ return _parse_chat_completion(raw, provider_id, model)
+
+
+def _parse_chat_completion(raw: str, provider_id: str, model: str) -> str:
+ try:
+ data = json.loads(raw)
+ except json.JSONDecodeError:
+ # Gateway ignorou stream=false e devolveu SSE mesmo assim — agrega os
+ # deltas de content dos chunks "data: {...}".
+ parts: list[str] = []
+ for line in raw.splitlines():
+ line = line.strip()
+ if not line.startswith("data:"):
+ continue
+ chunk = line[len("data:"):].strip()
+ if not chunk or chunk == "[DONE]":
+ continue
+ try:
+ choices = json.loads(chunk).get("choices") or [{}]
+ delta = choices[0].get("delta") or {}
+ except (json.JSONDecodeError, AttributeError, IndexError, TypeError):
+ continue
+ piece = delta.get("content")
+ if piece:
+ parts.append(piece)
+ content = "".join(parts).strip()
+ if not content:
+ raise RuntimeError(f"{provider_id}:{model} returned unparseable response")
+ return content
+ content = (data.get("choices") or [{}])[0].get("message", {}).get("content", "")
+ if not content:
+ raise RuntimeError(f"{provider_id}:{model} returned empty content")
+ return content.strip()
+
+
+def invoke_cli(provider_id: str, provider: dict, prompt: str, model: str | None = None) -> str:
+ if provider_id == "codex_auth":
+ return invoke_codex_exec(prompt)
+
+ cli = provider.get("cli_command") or ("claude" if provider_id == "anthropic" else "openclaude")
+ if provider_id == "anthropic":
+ cli = "claude"
+ elif provider_id == "codex_auth":
+ cli = "openclaude"
+ env = os.environ.copy()
+ for key, value in provider.get("env_vars", {}).items():
+ # Placeholders [REDACTED] do providers.json não podem vazar pro CLI —
+ # viram uma chave inválida e todo request 401a.
+ if value and _usable_secret(str(value)):
+ env[key] = str(value)
+ # Impede o CLI de se auto-migrar pro instalador nativo no meio da sessão
+ # (mata o processo com exit 1).
+ env["DISABLE_AUTOUPDATER"] = "1"
+ if model:
+ env["OPENAI_MODEL"] = model
+ max_turns = int(os.environ.get("TELEGRAM_CLI_MAX_TURNS") or provider.get("telegram_max_turns") or (5 if provider_id == "codex_auth" else 2))
+ timeout = int(os.environ.get("TELEGRAM_CLI_TIMEOUT") or provider.get("telegram_timeout_seconds") or (240 if provider_id == "codex_auth" else 120))
+ cmd = [cli, "--print", "--max-turns", str(max_turns), "--dangerously-skip-permissions", "--", prompt]
+ proc = subprocess.run(cmd, cwd=ROOT, env=env, text=True, capture_output=True, timeout=timeout)
+ if proc.returncode != 0:
+ raise RuntimeError((proc.stderr or proc.stdout or f"{cli} failed").strip()[:500])
+ return proc.stdout.strip()
+
+
+def invoke_codex_exec(prompt: str) -> str:
+ output_path: Path | None = None
+ with tempfile.NamedTemporaryFile(prefix="telegram-codex-", suffix=".txt", delete=False) as tmp:
+ output_path = Path(tmp.name)
+ timeout = int(os.environ.get("TELEGRAM_CODEX_TIMEOUT") or 600)
+ try:
+ proc = subprocess.run(
+ [
+ "codex",
+ "exec",
+ "-C",
+ str(ROOT),
+ "--dangerously-bypass-approvals-and-sandbox",
+ "--ephemeral",
+ "-o",
+ str(output_path),
+ prompt,
+ ],
+ cwd=ROOT,
+ text=True,
+ capture_output=True,
+ input="",
+ timeout=timeout,
+ )
+ if proc.returncode != 0:
+ raise RuntimeError(compact_codex_error(proc.stdout, proc.stderr))
+ text = output_path.read_text(encoding="utf-8", errors="replace").strip()
+ if not text:
+ raise RuntimeError("codex exec returned empty response")
+ return text
+ finally:
+ if output_path:
+ try:
+ output_path.unlink()
+ except OSError:
+ pass
+
+
+def compact_codex_error(stdout: str, stderr: str) -> str:
+ raw = (stderr or stdout or "codex exec failed").strip()
+ if not raw:
+ return "codex exec failed"
+ interesting: list[str] = []
+ for line in raw.splitlines():
+ stripped = line.strip()
+ if not stripped:
+ continue
+ if stripped.startswith(("ERROR:", "error:", "Error:", "status:", "message:")):
+ interesting.append(stripped)
+ elif "usage limit" in stripped.lower() or "rate limit" in stripped.lower() or "quota" in stripped.lower():
+ interesting.append(stripped)
+ elif "timed out" in stripped.lower() or "timeout" in stripped.lower():
+ interesting.append(stripped)
+ if interesting:
+ return " | ".join(interesting[-3:])[:700]
+ return "codex exec falhou sem mensagem final; veja screen -r telegram"
+
+
+def invoke_provider(prompt: str) -> tuple[str, str]:
+ errors: list[str] = []
+ for provider_id, provider in provider_chain():
+ for model in provider_models(provider_id, provider):
+ try:
+ if provider_id in {"anthropic", "codex_auth"}:
+ text = invoke_cli(provider_id, provider, prompt, model)
+ return text, f"{provider_id}:{model or 'default'}"
+ if model:
+ text = invoke_openai_compatible(provider_id, provider, model, prompt)
+ return text, f"{provider_id}:{model}"
+ except Exception as exc:
+ errors.append(f"{provider_id}:{model or 'default'}: {exc}")
+ log(f"fallback after {provider_id}:{model or 'default'} failed: {exc}")
+ raise RuntimeError("All providers failed: " + " | ".join(errors[-3:]))
+
+
+def load_offset() -> int | None:
+ state = read_json(DIRECT_STATE, {})
+ offset = state.get("offset")
+ return int(offset) if isinstance(offset, int) else None
+
+
+def save_offset(offset: int) -> None:
+ TELEGRAM_STATE.mkdir(parents=True, exist_ok=True)
+ DIRECT_STATE.write_text(json.dumps({"offset": offset}, indent=2) + "\n", encoding="utf-8")
+
+
+def unblock_ticket(ticket_id: str, reply_text: str, author: str) -> str:
+ """Ponte Telegram→ticket: anexa a resposta do humano como comentário e reabre
+ o ticket (blocked→open) para o orquestrador retomar. Cada round da entrevista
+ é um ciclo: agente pergunta (blocked) → humano responde (reply) → reabre."""
+ import sqlite3
+ import uuid
+ import datetime
+ db = ROOT / "dashboard" / "data" / "evonexus.db"
+ if not db.exists():
+ return "Banco de tickets não encontrado."
+ try:
+ c = sqlite3.connect(str(db))
+ c.row_factory = sqlite3.Row
+ row = c.execute(
+ "SELECT id, title, assignee_agent FROM tickets WHERE id = ? OR id LIKE ?",
+ (ticket_id, ticket_id + "%"),
+ ).fetchone()
+ if not row:
+ c.close()
+ return f"Ticket {ticket_id[:8]} não encontrado."
+ tid = row["id"]
+ now = datetime.datetime.now(datetime.timezone.utc)
+ # updated_at no passado para o orquestrador reprocessar já no próximo ciclo
+ stale = (now - datetime.timedelta(minutes=20)).isoformat()
+ c.execute(
+ "INSERT INTO ticket_comments (id, ticket_id, author, body, mentions, created_at) "
+ "VALUES (?, ?, ?, ?, ?, ?)",
+ (str(uuid.uuid4()), tid, f"human:{author}", reply_text, "[]", now.isoformat()),
+ )
+ c.execute(
+ "INSERT INTO ticket_activity (id, ticket_id, actor, action, payload, created_at) "
+ "VALUES (?, ?, ?, ?, ?, ?)",
+ (str(uuid.uuid4()), tid, f"human:{author}", "status_changed",
+ json.dumps({"new_status": "open", "via": "telegram_reply"}), now.isoformat()),
+ )
+ c.execute("UPDATE tickets SET status='open', updated_at=? WHERE id=?", (stale, tid))
+ c.commit()
+ c.close()
+ return (f"✅ Desbloqueado: {row['title'][:60]}\n"
+ f"Sua resposta foi anexada — {row['assignee_agent']} retoma na próxima rodada.")
+ except Exception as e: # noqa: BLE001
+ return f"Erro ao desbloquear: {e}"
+
+
+def main() -> int:
+ token = read_telegram_token()
+ me = api(token, "getMe")
+ username = me.get("result", {}).get("username", "unknown")
+ log(f"polling @{username}; provider follows {PROVIDERS_PATH}")
+
+ offset = load_offset()
+ while True:
+ try:
+ payload = {"timeout": 25, "limit": 20}
+ if offset is not None:
+ payload["offset"] = offset
+ updates = api(token, "getUpdates", payload, timeout=35).get("result", [])
+ for update in updates:
+ offset = int(update["update_id"]) + 1
+ save_offset(offset)
+ message = update.get("message") or update.get("edited_message") or {}
+ text = (message.get("text") or "").strip()
+ chat = message.get("chat") or {}
+ sender = message.get("from") or {}
+ chat_id = str(chat.get("id", ""))
+ from_id = str(sender.get("id", ""))
+ sender_name = (
+ sender.get("username")
+ or " ".join(part for part in [sender.get("first_name"), sender.get("last_name")] if part)
+ or None
+ )
+ if not chat_id:
+ continue
+ if not allowed_chat(chat_id, from_id):
+ log(f"dropped non-allowlisted chat={chat_id}")
+ continue
+ # Ponte de tickets: reply a uma notificação de bloqueio (#tkt:)
+ # anexa a resposta e reabre o ticket para o orquestrador retomar.
+ reply_src = (message.get("reply_to_message") or {}).get("text") or ""
+ m_tkt = re.search(r"#tkt:([0-9a-fA-F-]+)", reply_src)
+ if m_tkt and text:
+ result = unblock_ticket(m_tkt.group(1), text, sender_name or "humano")
+ api(token, "sendMessage", {"chat_id": chat_id, "text": result})
+ log(f"ticket-unblock chat={chat_id} ticket={m_tkt.group(1)[:8]}")
+ continue
+ audio_file_id = message_audio_file_id(message)
+ if audio_file_id:
+ api(token, "sendChatAction", {"chat_id": chat_id, "action": "typing"}, timeout=10)
+ transcription = ""
+ try:
+ transcription = handle_audio_message(token, chat_id, audio_file_id)
+ prompt = build_prompt(chat_id, transcription, speaker=sender_name)
+ answer, used = invoke_provider(prompt)
+ except Exception as exc:
+ error_label = "processar audio" if transcription else "transcrever audio"
+ answer = f"Falhei ao {error_label}: {exc}"
+ used = "error"
+ log(f"audio chat={chat_id} via {used}")
+ api(token, "sendMessage", {"chat_id": chat_id, "text": answer[:3900]})
+ if used != "error":
+ append_chat_memory(chat_id, "user", f"[audio transcrito] {transcription}", speaker=sender_name)
+ append_chat_memory(chat_id, "assistant", answer, speaker="Magneto")
+ continue
+ image_info = message_image_file_id(message)
+ if image_info:
+ api(token, "sendChatAction", {"chat_id": chat_id, "action": "typing"}, timeout=10)
+ try:
+ image_file_id, suffix = image_info
+ image_path = save_telegram_file(token, image_file_id, suffix=suffix)
+ caption = (message.get("caption") or "").strip()
+ prompt_text = (
+ "Analise a imagem recebida no Telegram.\n"
+ f"Caminho local da imagem: {image_path}\n"
+ f"Legenda/mensagem do usuario: {caption or '(sem legenda)'}\n"
+ "Se conseguir acessar o arquivo, descreva o que ve e responda ao pedido do usuario."
+ )
+ prompt = build_prompt(chat_id, prompt_text, speaker=sender_name)
+ answer, used = invoke_provider(prompt)
+ except Exception as exc:
+ answer = f"Falhei ao processar imagem: {exc}"
+ used = "error"
+ log(f"image chat={chat_id} via {used}")
+ api(token, "sendMessage", {"chat_id": chat_id, "text": answer[:3900]})
+ if used != "error":
+ append_chat_memory(chat_id, "user", f"[imagem] {caption or image_path}", speaker=sender_name)
+ append_chat_memory(chat_id, "assistant", answer, speaker="Magneto")
+ continue
+ if not text:
+ continue
+ if text.startswith("/start"):
+ api(token, "sendMessage", {"chat_id": chat_id, "text": "EvoNexus online. Pode mandar."})
+ continue
+ if text.startswith("/new"):
+ clear_chat_memory(chat_id)
+ api(token, "sendMessage", {"chat_id": chat_id, "text": "Sessao nova iniciada. Memoria local limpa."})
+ continue
+ groq_command = parse_groq_command(text)
+ if groq_command is not None:
+ answer = handle_groq_command(groq_command)
+ api(token, "sendMessage", {"chat_id": chat_id, "text": answer})
+ log(f"groq-command chat={chat_id} {groq_command.split(' ', 1)[0]}")
+ continue
+ provider_command = parse_provider_command(text)
+ if provider_command is not None:
+ if provider_command == "status":
+ provider_id, model, base_url = active_provider_info()
+ answer = f"provider: {provider_id}\nmodel: {model or 'default'}"
+ if base_url:
+ answer += f"\nbase_url: {base_url}"
+ else:
+ answer = set_telegram_provider(provider_command)
+ api(token, "sendMessage", {"chat_id": chat_id, "text": answer})
+ log(f"provider-command chat={chat_id} {provider_command}")
+ continue
+ if is_provider_question(text):
+ provider_id, model, base_url = active_provider_info()
+ answer = (
+ "Estou usando o provider ativo do EvoNexus:\n"
+ f"provider: {provider_id}\n"
+ f"model: {model or 'default'}"
+ )
+ if base_url:
+ answer += f"\nbase_url: {base_url}"
+ api(token, "sendMessage", {"chat_id": chat_id, "text": answer})
+ log(f"provider-info chat={chat_id} {provider_id}:{model or 'default'}")
+ continue
+ api(token, "sendChatAction", {"chat_id": chat_id, "action": "typing"}, timeout=10)
+ try:
+ prompt = build_prompt(chat_id, text, speaker=sender_name)
+ answer, used = invoke_provider(prompt)
+ except Exception as exc:
+ answer = f"Falhei ao consultar o provider: {exc}"
+ used = "error"
+ log(f"reply chat={chat_id} via {used}")
+ api(token, "sendMessage", {"chat_id": chat_id, "text": answer[:3900]})
+ if used != "error":
+ append_chat_memory(chat_id, "user", text, speaker=sender_name)
+ append_chat_memory(chat_id, "assistant", answer, speaker="Magneto")
+ except urllib.error.HTTPError as exc:
+ body = exc.read().decode("utf-8", "ignore")
+ log(f"telegram http error {exc.code}: {body[:300]}")
+ time.sleep(5)
+ except KeyboardInterrupt:
+ return 0
+ except Exception as exc:
+ log(f"loop error: {exc}")
+ time.sleep(5)
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/scripts/telegram_swarm_entry.sh b/scripts/telegram_swarm_entry.sh
new file mode 100755
index 00000000..d482f7ae
--- /dev/null
+++ b/scripts/telegram_swarm_entry.sh
@@ -0,0 +1,161 @@
+#!/usr/bin/env bash
+# Swarm entrypoint for the Telegram bot service.
+#
+# Two runtime modes (TELEGRAM_MODE=channels|provider, default: auto):
+# * channels — `claude --channels` direto na Anthropic. Requer login
+# claude.ai no volume de auth (rode `claude /login` uma vez via docker
+# exec; persiste em /root/.claude/.credentials.json). A disponibilidade
+# de channels é verificada server-side pela Anthropic e NÃO funciona
+# através de proxies OpenAI-compatíveis (NVIDIA NIM etc.).
+# * provider — scripts/telegram_provider_bot.py: bot de polling que
+# responde pelo provider ativo em config/providers.json (NVIDIA NIM,
+# OpenAI, ...). Não depende de channels nem de login Anthropic. É o
+# mesmo runtime do `make telegram` local.
+#
+# Auto: se existir login claude.ai → channels; senão → provider. O antigo
+# caminho LiteLLM-proxy foi removido: channels atrás do proxy nunca passa
+# na verificação server-side, então o canal ficava morto ("desconectado").
+set -euo pipefail
+cd /workspace
+
+CONFIG_DIR=/workspace/config
+CHANNEL_DIR="$HOME/.claude/channels/telegram"
+
+reload_env() {
+ set -a
+ # shellcheck disable=SC1091
+ . "$CONFIG_DIR/.env" 2>/dev/null || true
+ set +a
+}
+
+# --- 1. Decide runtime mode --------------------------------------------------
+reload_env
+MODE="${TELEGRAM_MODE:-auto}"
+if [ "$MODE" = "auto" ]; then
+ if grep -q '"claudeAiOauth"' /root/.claude/.credentials.json 2>/dev/null; then
+ MODE=channels
+ else
+ MODE=provider
+ fi
+fi
+echo "[$(date -Is)] telegram mode: $MODE" >&2
+
+# --- 2. Wait for the bot token (both modes need it) --------------------------
+# Lands in config/.env via dashboard → Integrations, or comes pre-set in the
+# channel dir from a previous boot. No crash-loop while onboarding.
+has_channel_token() {
+ grep -q '^TELEGRAM_BOT_TOKEN=..*' "$CHANNEL_DIR/.env" 2>/dev/null
+}
+while [ -z "${TELEGRAM_BOT_TOKEN:-}" ] && ! has_channel_token; do
+ echo "[$(date -Is)] waiting for TELEGRAM_BOT_TOKEN — configure via dashboard → Integrations" >&2
+ sleep 30
+ reload_env
+done
+
+# --- 3. Seed channel config on the auth volume -------------------------------
+mkdir -p "$CHANNEL_DIR"
+if [ ! -f "$CHANNEL_DIR/.env" ] && [ -n "${TELEGRAM_BOT_TOKEN:-}" ]; then
+ printf 'TELEGRAM_BOT_TOKEN=%s\n' "$TELEGRAM_BOT_TOKEN" > "$CHANNEL_DIR/.env"
+ chmod 600 "$CHANNEL_DIR/.env"
+ echo "[$(date -Is)] seeded $CHANNEL_DIR/.env" >&2
+fi
+if [ -n "${TELEGRAM_CHAT_ID:-}" ]; then
+ if [ ! -f "$CHANNEL_DIR/access.json" ]; then
+ printf '{"dmPolicy":"allowlist","allowFrom":["%s"],"groups":{},"pending":{}}\n' \
+ "$TELEGRAM_CHAT_ID" > "$CHANNEL_DIR/access.json"
+ chmod 600 "$CHANNEL_DIR/access.json"
+ echo "[$(date -Is)] seeded $CHANNEL_DIR/access.json (allowlist: $TELEGRAM_CHAT_ID)" >&2
+ else
+ # The file may predate TELEGRAM_CHAT_ID landing in .env (first DM
+ # creates it with the sender stuck in "pending", and the bot keeps
+ # answering with the pairing prompt forever). Merge the owner into
+ # the allowlist on every boot — idempotent.
+ _tmp_acc=$(mktemp)
+ if jq --arg id "$TELEGRAM_CHAT_ID" \
+ '.dmPolicy //= "allowlist"
+ | .allowFrom = ((.allowFrom // []) + [$id] | unique)
+ | .pending = ((.pending // {}) | del(.[$id]))' \
+ "$CHANNEL_DIR/access.json" > "$_tmp_acc" 2>/dev/null; then
+ mv "$_tmp_acc" "$CHANNEL_DIR/access.json"
+ chmod 600 "$CHANNEL_DIR/access.json"
+ echo "[$(date -Is)] ensured $TELEGRAM_CHAT_ID in access.json allowlist" >&2
+ else
+ rm -f "$_tmp_acc"
+ echo "[$(date -Is)] WARNING: could not patch access.json allowlist" >&2
+ fi
+ fi
+fi
+
+# --- 4. Restore /root/.claude.json (same rationale as start-dashboard.sh) --
+# The main CLI config is a SIBLING of /root/.claude/ (the volume), so it
+# lives in the container layer and is wiped on redeploy. Restore the latest
+# backup from the volume, or seed a minimal one to skip first-run prompts.
+if [ ! -f /root/.claude.json ]; then
+ latest_backup=$(ls -t /root/.claude/backups/.claude.json.backup.* 2>/dev/null | head -n1 || true)
+ if [ -n "${latest_backup:-}" ] && [ -f "${latest_backup}" ]; then
+ echo "[$(date -Is)] restoring /root/.claude.json from ${latest_backup}" >&2
+ cp "${latest_backup}" /root/.claude.json
+ else
+ echo "[$(date -Is)] seeding minimal /root/.claude.json" >&2
+ cat > /root/.claude.json <<'EOF'
+{
+ "theme": "dark",
+ "hasCompletedOnboarding": true,
+ "hasSeenWelcome": true,
+ "bypassPermissionsModeAccepted": true,
+ "telemetry": false
+}
+EOF
+ fi
+fi
+
+# Whatever the restore produced, force the first-run flags — a backup taken
+# from a half-initialized run leaves claude stuck at the theme picker or at
+# the /workspace trust dialog, and the channel never starts.
+_tmp_cfg=$(mktemp)
+if jq '.theme //= "dark"
+ | .hasCompletedOnboarding = true
+ | .hasSeenWelcome = true
+ | .bypassPermissionsModeAccepted = true
+ | .projects["/workspace"] = ((.projects["/workspace"] // {}) + {
+ "hasTrustDialogAccepted": true,
+ "hasCompletedProjectOnboarding": true
+ })' \
+ /root/.claude.json > "$_tmp_cfg" 2>/dev/null; then
+ mv "$_tmp_cfg" /root/.claude.json
+else
+ rm -f "$_tmp_cfg"
+ echo "[$(date -Is)] WARNING: could not patch /root/.claude.json flags" >&2
+fi
+
+# The container runs as root; Claude Code refuses --dangerously-skip-
+# permissions as root unless it knows it's inside a sandboxed container.
+export IS_SANDBOX=1
+
+# --- 5. Run the bot -----------------------------------------------------------
+if [ "$MODE" = "provider" ]; then
+ echo "[$(date -Is)] starting telegram_provider_bot.py on the active provider" >&2
+ exec /workspace/.venv/bin/python scripts/telegram_provider_bot.py
+fi
+
+# channels mode — the claude.ai OAuth login must win: any Anthropic env
+# credential (possibly sourced from config/.env) would shadow it and fail
+# the server-side channels entitlement check.
+unset ANTHROPIC_BASE_URL ANTHROPIC_AUTH_TOKEN ANTHROPIC_MODEL ANTHROPIC_API_KEY 2>/dev/null || true
+
+if [ "$MODE" = "channels" ] && ! grep -q '"claudeAiOauth"' /root/.claude/.credentials.json 2>/dev/null; then
+ echo "[$(date -Is)] WARNING: TELEGRAM_MODE=channels but no claude.ai login on the auth volume — run 'claude /login' via docker exec or the channel will stay dead" >&2
+fi
+
+# First boot: the plugin cache lives on the /root/.claude volume and starts
+# empty — install the telegram channel plugin before starting the channel.
+if [ ! -d "$HOME/.claude/plugins/cache/claude-plugins-official/telegram" ]; then
+ echo "[$(date -Is)] installing telegram channel plugin" >&2
+ claude plugin marketplace add anthropics/claude-plugins-official >/dev/null 2>&1 || true
+ claude plugin install telegram@claude-plugins-official --scope user \
+ || echo "[$(date -Is)] WARNING: telegram plugin install failed" >&2
+fi
+
+exec claude \
+ --channels "plugin:telegram@claude-plugins-official" \
+ --dangerously-skip-permissions
diff --git a/social-auth/app.py b/social-auth/app.py
index a079184d..8744e2ef 100644
--- a/social-auth/app.py
+++ b/social-auth/app.py
@@ -47,8 +47,10 @@ def disconnect(platform, index):
if __name__ == "__main__":
port = int(os.environ.get("SOCIAL_AUTH_PORT", 8765))
+ host = os.environ.get("SOCIAL_AUTH_HOST", "0.0.0.0")
+ display_host = "localhost" if host in {"0.0.0.0", "::"} else host
print(f"\n 🔑 Evolution Social Auth")
- print(f" 📍 http://localhost:{port}")
+ print(f" 📍 http://{display_host}:{port}")
print(f" ⛔ Ctrl+C para parar\n")
- webbrowser.open(f"http://localhost:{port}")
- app.run(host="127.0.0.1", port=port, debug=False)
+ webbrowser.open(f"http://{display_host}:{port}")
+ app.run(host=host, port=port, debug=False)
diff --git a/social-auth/auth/twitter.py b/social-auth/auth/twitter.py
index 48c5c199..26bfc7a9 100644
--- a/social-auth/auth/twitter.py
+++ b/social-auth/auth/twitter.py
@@ -33,7 +33,7 @@ def connect():
X / Twitter — Adicionar Conta
-
Cole seu Bearer Token (App-only) ou configure TWITTER_CLIENT_ID no .env pra usar OAuth:
+
Cole um Bearer Token para leitura ou configure TWITTER_CLIENT_ID no .env para OAuth com escrita: