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

-

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.

- Latest version + Uma camada de upgrade mantida por Sistema Britto sobre o EvoNexus da Evolution Foundation. +

+ +

+ Upstream License: Apache 2.0 - Documentation - Community + Sistema Britto

- Website · - Documentation · - Quick Start · - Dashboard · - Community · - Support + Sistema Britto · + Projeto original · + Deploy na VPS · + OmniRoute · + Telegram · + Créditos

--- -> **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] +``` -

- Overview - Agent Chat -

-

- Agents - Integrations -

-

- Costs -

+**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). ---

- An unofficial community toolkit for Claude Code + Um toolkit comunitário não oficial para o Claude Code
- Made by Evolution Foundation · © 2026 + Base por Evolution Foundation · Upgrade por Sistema Britto · © 2026
- Not affiliated with Anthropic + Não afiliado à Anthropic

diff --git a/backup.py b/backup.py index abb0c15b..16d81e24 100644 --- a/backup.py +++ b/backup.py @@ -11,6 +11,7 @@ import json import os import platform +import sqlite3 import subprocess import sys import tempfile @@ -224,9 +225,61 @@ def collect_files() -> list[str]: if full_path.is_file(): files.add(line) + # Explicit env candidates — in Swarm the persistent env lives at + # config/.env (with /workspace/.env symlinked to it), so both paths + # must land in the backup regardless of what git ls-files reports. + for candidate in (".env", "config/.env"): + p = WORKSPACE / candidate + if p.is_file(): + files.add(candidate) + return sorted(files) +SQLITE_MAGIC = b"SQLite format 3\x00" + + +def _is_sqlite_file(path: Path) -> bool: + """Detect a SQLite database by its 16-byte header magic.""" + try: + with open(path, "rb") as f: + return f.read(16) == SQLITE_MAGIC + except OSError: + return False + + +def _snapshot_sqlite(src: Path, tmpdir: Path, index: int) -> Path | None: + """Create a consistent point-in-time copy of a (possibly live) SQLite DB. + + Uses the SQLite Online Backup API, which folds pending WAL frames into + the copy — copying the bare .db file of a WAL-mode database that is + being written to can produce a malformed or stale backup. + + Returns the snapshot path, or None if the backup API failed (caller + falls back to a raw copy with a warning). + """ + dst = tmpdir / f"{index:04d}-{src.name}" + src_conn = None + dst_conn = None + try: + src_conn = sqlite3.connect(f"file:{src}?mode=ro", uri=True) + dst_conn = sqlite3.connect(dst) + src_conn.backup(dst_conn) + check = dst_conn.execute("PRAGMA quick_check").fetchone() + if not check or check[0] != "ok": + dst.unlink(missing_ok=True) + return None + return dst + except sqlite3.Error: + dst.unlink(missing_ok=True) + return None + finally: + if dst_conn is not None: + dst_conn.close() + if src_conn is not None: + src_conn.close() + + def _format_size(size_bytes: int) -> str: """Format bytes to human-readable size.""" for unit in ["B", "KB", "MB", "GB"]: @@ -253,46 +306,72 @@ def backup_local(s3_upload: bool = False, s3_bucket: str = None) -> Path: zip_name = f"evonexus-backup-{timestamp}.zip" zip_path = BACKUPS_DIR / zip_name - # Build manifest - file_entries = [] - total_size = 0 - for rel in files: - full = WORKSPACE / rel - size = full.stat().st_size - total_size += size - file_entries.append({"path": rel, "size": size}) - - manifest = { - "version": _get_version(), - "workspace_name": _get_workspace_name(), - "created_at": datetime.now().isoformat(), - "hostname": platform.node(), - "file_count": len(files), - "total_size": total_size, - "files": file_entries, - } - - # Create ZIP - if HAS_RICH: - with Progress( - SpinnerColumn(), - TextColumn("[progress.description]{task.description}"), - BarColumn(), - TextColumn("{task.completed}/{task.total}"), - console=console, - ) as progress: - task = progress.add_task("Compressing...", total=len(files)) + with tempfile.TemporaryDirectory(prefix="evonexus-backup-") as tmp: + tmpdir = Path(tmp) + + # Resolve the actual source for each file. Live SQLite databases get + # a consistent snapshot (WAL folded in); everything else is zipped + # straight from the workspace. + sources: dict[str, Path] = {} + snapshot_failures: list[str] = [] + for i, rel in enumerate(files): + full = WORKSPACE / rel + if _is_sqlite_file(full): + snap = _snapshot_sqlite(full, tmpdir, i) + if snap is None: + snapshot_failures.append(rel) + sources[rel] = full + else: + sources[rel] = snap + else: + sources[rel] = full + + for rel in snapshot_failures: + msg = f"SQLite snapshot failed for {rel} — raw copy included (may be inconsistent)" + if HAS_RICH: + console.print(f" [yellow]⚠ {msg}[/]") + else: + print(f" {YELLOW}⚠ {msg}{RESET}") + + # Build manifest (sizes reflect what actually goes into the ZIP) + file_entries = [] + total_size = 0 + for rel in files: + size = sources[rel].stat().st_size + total_size += size + file_entries.append({"path": rel, "size": size}) + + manifest = { + "version": _get_version(), + "workspace_name": _get_workspace_name(), + "created_at": datetime.now().isoformat(), + "hostname": platform.node(), + "file_count": len(files), + "total_size": total_size, + "files": file_entries, + } + + # Create ZIP + if HAS_RICH: + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + BarColumn(), + TextColumn("{task.completed}/{task.total}"), + console=console, + ) as progress: + task = progress.add_task("Compressing...", total=len(files)) + with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf: + zf.writestr("manifest.json", json.dumps(manifest, indent=2)) + for rel in files: + zf.write(sources[rel], rel) + progress.advance(task) + else: + print(f" Compressing {len(files)} files...") with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf: zf.writestr("manifest.json", json.dumps(manifest, indent=2)) for rel in files: - zf.write(WORKSPACE / rel, rel) - progress.advance(task) - else: - print(f" Compressing {len(files)} files...") - with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf: - zf.writestr("manifest.json", json.dumps(manifest, indent=2)) - for rel in files: - zf.write(WORKSPACE / rel, rel) + zf.write(sources[rel], rel) zip_size = zip_path.stat().st_size @@ -375,6 +454,20 @@ def cleanup_old_backups(s3_bucket: str = None): # ── Restore ────────────────────────────────────── +def _remove_stale_wal(dest: Path, data: bytes) -> None: + """After replacing a SQLite DB, drop leftover -wal/-shm from the old DB. + + Backups store WAL-folded snapshots (no -wal/-shm in the ZIP). If the + destination had a live WAL-mode database, its journal files survive the + overwrite and SQLite then reports 'database disk image is malformed' + when pairing them with the freshly restored file. + """ + if data[:16] != SQLITE_MAGIC: + return + for suffix in ("-wal", "-shm"): + Path(str(dest) + suffix).unlink(missing_ok=True) + + def restore_local(zip_path: Path, mode: str = "merge"): """Restore workspace from a ZIP backup.""" banner("Backup — Restore") @@ -417,6 +510,7 @@ def restore_local(zip_path: Path, mode: str = "merge"): dest.parent.mkdir(parents=True, exist_ok=True) data = zf.read(rel) dest.write_bytes(data) + _remove_stale_wal(dest, data) restored += 1 progress.advance(task) else: @@ -434,6 +528,7 @@ def restore_local(zip_path: Path, mode: str = "merge"): dest.parent.mkdir(parents=True, exist_ok=True) data = zf.read(rel) dest.write_bytes(data) + _remove_stale_wal(dest, data) restored += 1 if HAS_RICH: diff --git a/config/heartbeats.yaml b/config/heartbeats.yaml new file mode 100644 index 00000000..3628aa06 --- /dev/null +++ b/config/heartbeats.yaml @@ -0,0 +1,92 @@ +heartbeats: +- id: atlas-4h + agent: atlas-project + interval_seconds: 14400 + max_turns: 10 + timeout_seconds: 300 + lock_timeout_seconds: 1800 + wake_triggers: + - interval + - mention + - manual + enabled: true + goal_id: null + required_secrets: [] + decision_prompt: 'You are Atlas, the project agent. Review Linear for stale PRs + and issues older than 3 days with no activity. Check GitHub for open PRs waiting + on review. Decide: should you act now (comment, triage, escalate) or is there + nothing urgent? Respond with a JSON object: {"action": "work"|"skip", "reason": + "<1 sentence>", "tasks": []}. + + ' +- id: zara-2h + agent: zara-cs + interval_seconds: 7200 + max_turns: 15 + timeout_seconds: 300 + lock_timeout_seconds: 1800 + wake_triggers: + - interval + - new_task + - manual + enabled: true + goal_id: null + required_secrets: [] + decision_prompt: 'You are Zara, the customer success agent. Check the support queue + for tickets with status "open" or "pending" assigned to you. Pick the highest-priority + ticket (by urgency label, then oldest created_at). Decide: should you work on + it now or is the queue empty/nothing actionable? Respond with JSON: {"action": + "work"|"skip", "reason": "<1 sentence>", "ticket_id": ""}. + + ' +- id: flux-6h + agent: flux-finance + interval_seconds: 21600 + max_turns: 10 + timeout_seconds: 300 + lock_timeout_seconds: 1800 + wake_triggers: + - interval + - manual + enabled: true + goal_id: null + required_secrets: + - STRIPE_KEY + - OMIE_APP_KEY + decision_prompt: 'You are Flux, the finance agent. Check Stripe for failed payments + or subscriptions that lapsed in the last 6 hours. Check Omie for pending NF-e + that need action. Decide: is there something urgent requiring action or escalation + to Davidson via Telegram? Respond with JSON: {"action": "work"|"skip", "reason": + "<1 sentence>", "alerts": []}. + + ' +- id: integrations-health + agent: system + interval_seconds: 3600 + max_turns: 0 + timeout_seconds: 60 + lock_timeout_seconds: 1800 + wake_triggers: + - interval + - manual + enabled: false + goal_id: null + required_secrets: [] + decision_prompt: '' + handler: plugin_integration_health.tick +- id: nexus-orchestrator + agent: system + interval_seconds: 300 + max_turns: 0 + timeout_seconds: 120 + lock_timeout_seconds: 600 + wake_triggers: + - interval + - manual + # Hybrid execution: NVIDIA agents do the work (free), then structure_via_nvidia + # forces the outcome JSON via response_format=json_schema (heartbeat_outcome.py). + enabled: true + goal_id: null + required_secrets: [] + decision_prompt: '' + handler: nexus_orchestrator.tick diff --git a/config/litellm-telegram.yaml b/config/litellm-telegram.yaml new file mode 100644 index 00000000..a70f0c71 --- /dev/null +++ b/config/litellm-telegram.yaml @@ -0,0 +1,36 @@ +# LiteLLM proxy — Anthropic /v1/messages → NVIDIA NIM (OpenAI-compatible) +# +# Why: the Telegram bot runs via `claude --channels` (real Claude binary), which +# only speaks the Anthropic Messages API. NVIDIA NIM is OpenAI-compatible. This +# proxy translates between them so the bot can run on an NVIDIA model instead of +# Anthropic. Point the bot at it with ANTHROPIC_BASE_URL + ANTHROPIC_AUTH_TOKEN. +# +# Start: make telegram (launches this proxy + the channel) +# Model: z-ai/glm-5.1 on NVIDIA NIM. +# MiniMax M3 works for direct calls, but can leave Claude Code channels stuck +# in "Clauding..." through the Anthropic translation path. + +model_list: + # Named entry used by ANTHROPIC_MODEL. + # Use the native nvidia_nim provider (NOT openai/): the Anthropic /v1/messages + # endpoint routes openai/ models through the OpenAI Responses API, which NVIDIA + # NIM does not implement (→ 404). nvidia_nim/ forces /chat/completions. + - model_name: telegram-nvidia + litellm_params: + model: nvidia_nim/z-ai/glm-5.1 + api_key: os.environ/NVIDIA_API_KEY + + # Wildcard so Claude Code's background/small-model calls (title generation, + # quota probes) also route to NVIDIA instead of failing with an unknown model. + - model_name: "*" + litellm_params: + model: nvidia_nim/z-ai/glm-5.1 + api_key: os.environ/NVIDIA_API_KEY + +litellm_settings: + # NVIDIA NIM rejects some OpenAI params Claude Code sends — drop them instead + # of returning 400 and breaking the conversation. + drop_params: true + +general_settings: + master_key: sk-evonexus-telegram-local diff --git a/config/providers.example.json b/config/providers.example.json index 17a4d61f..863106ce 100644 --- a/config/providers.example.json +++ b/config/providers.example.json @@ -1,5 +1,6 @@ { "active_provider": "anthropic", + "telegram_provider": "", "providers": { "anthropic": { "name": "Anthropic (Claude nativo)", @@ -20,6 +21,35 @@ }, "default_base_url": "https://openrouter.ai/api/v1", "default_model": "anthropic/claude-sonnet-4", + "requires_logout": true, + "mode": "code" + }, + "nvidia": { + "name": "NVIDIA NIM", + "description": "Modelos hospedados na NVIDIA (DeepSeek, Llama, Qwen, etc.) via API OpenAI-compatível", + "cli_command": "openclaude", + "mode": "code", + "env_vars": { + "CLAUDE_CODE_USE_OPENAI": "1", + "OPENAI_BASE_URL": "https://integrate.api.nvidia.com/v1", + "OPENAI_API_KEY": "", + "OPENAI_MODEL": "" + }, + "default_base_url": "https://integrate.api.nvidia.com/v1", + "default_model": "z-ai/glm-5.1", + "fallback_models": [ + "minimaxai/minimax-m3" + ], + "model_tiers": { + "opus": "z-ai/glm-5.1", + "sonnet": "z-ai/glm-5.1", + "haiku": "z-ai/glm-5.1" + }, + "fallback_providers": [ + "openrouter", + "codex_auth", + "anthropic" + ], "requires_logout": true }, "omnirouter": { @@ -34,7 +64,8 @@ }, "default_base_url": "", "default_model": "", - "requires_logout": true + "requires_logout": true, + "mode": "code" }, "openai": { "name": "OpenAI (API Key)", diff --git a/config/providers.json b/config/providers.json new file mode 100644 index 00000000..27c3d7d9 --- /dev/null +++ b/config/providers.json @@ -0,0 +1,153 @@ +{ + "active_provider": "nvidia", + "providers": { + "anthropic": { + "name": "Anthropic (Claude nativo)", + "cli_command": "claude", + "env_vars": {}, + "requires_logout": false + }, + "openrouter": { + "name": "OpenRouter", + "cli_command": "openclaude", + "env_vars": { + "CLAUDE_CODE_USE_OPENAI": "1", + "OPENAI_BASE_URL": "https://openrouter.ai/api/v1", + "OPENAI_API_KEY": "[REDACTED]", + "OPENAI_MODEL": "openrouter/owl-alpha" + }, + "default_base_url": "https://openrouter.ai/api/v1", + "default_model": "openrouter/owl-alpha", + "requires_logout": true, + "mode": "code", + "fallback_models": [ + "openrouter/owl-alpha", + "nex-agi/nex-n2-pro:free" + ] + }, + "nvidia": { + "name": "NVIDIA NIM", + "description": "Modelos hospedados na NVIDIA (DeepSeek, Llama, Qwen, etc.) via API OpenAI-compatível", + "cli_command": "openclaude", + "mode": "code", + "env_vars": { + "CLAUDE_CODE_USE_OPENAI": "1", + "OPENAI_BASE_URL": "https://integrate.api.nvidia.com/v1", + "OPENAI_API_KEY": "[REDACTED]", + "OPENAI_MODEL": "stepfun-ai/step-3.7-flash" + }, + "default_base_url": "https://integrate.api.nvidia.com/v1", + "default_model": "stepfun-ai/step-3.7-flash", + "requires_logout": true, + "fallback_models": [ + "stepfun-ai/step-3.7-flash", + "deepseek-ai/deepseek-v4-flash", + "z-ai/glm-5.1", + "moonshotai/kimi-k2.6", + "nvidia/nemotron-3-ultra-550b-a55b", + "nvidia/nemotron-3-super-120b-a12b", + "qwen/qwen3.5-122b-a10b", + "qwen/qwen3.5-397b-a17b", + "openai/gpt-oss-120b", + "microsoft/phi-4-multimodal-instruct", + "stepfun-ai/step-3.5-flash", + "minimaxai/minimax-m3" + ], + "model_tiers": { + "opus": "minimaxai/minimax-m3", + "sonnet": "minimaxai/minimax-m3", + "haiku": "minimaxai/minimax-m3" + }, + "fallback_providers": [ + "openrouter", + "anthropic" + ] + }, + "openai": { + "name": "OpenAI", + "description": "GPT-4.x via API Key ou GPT-5.x via Codex OAuth", + "cli_command": "openclaude", + "env_vars": { + "CLAUDE_CODE_USE_OPENAI": "1", + "OPENAI_API_KEY": "[REDACTED]", + "OPENAI_MODEL": "" + }, + "default_model": "gpt-4.1", + "requires_logout": true + }, + "gemini": { + "name": "Google Gemini (em breve)", + "cli_command": "openclaude", + "env_vars": { + "CLAUDE_CODE_USE_GEMINI": "1", + "GEMINI_API_KEY": "[REDACTED]", + "GEMINI_MODEL": "gemini-2.5-pro" + }, + "default_model": "gemini-2.5-pro", + "requires_logout": true, + "coming_soon": true + }, + "bedrock": { + "name": "AWS Bedrock (em breve)", + "cli_command": "openclaude", + "env_vars": { + "CLAUDE_CODE_USE_BEDROCK": "1", + "AWS_REGION": "", + "AWS_BEARER_TOKEN_BEDROCK": "[REDACTED]" + }, + "requires_logout": true, + "coming_soon": true + }, + "vertex": { + "name": "Google Vertex AI (em breve)", + "cli_command": "openclaude", + "env_vars": { + "CLAUDE_CODE_USE_VERTEX": "1", + "ANTHROPIC_VERTEX_PROJECT_ID": "", + "CLOUD_ML_REGION": "" + }, + "default_region": "us-east5", + "requires_logout": true, + "coming_soon": true + }, + "codex_auth": { + "name": "OpenAI Codex (OAuth)", + "description": "GPT-5.x via ChatGPT Codex — login OAuth, sem API key", + "cli_command": "openclaude", + "env_vars": { + "CLAUDE_CODE_USE_OPENAI": "1", + "OPENAI_MODEL": "codexplan" + }, + "default_model": "codexplan", + "auth_type": "oauth", + "auth_file": "~/.codex/auth.json", + "requires_logout": true, + "setup_hint": "Use o botão Login para autenticar via OAuth do ChatGPT", + "mode": "code", + "fallback_providers": [ + "nvidia" + ], + "fallback_models": [ + "codexplan", + "codexspark" + ] + }, + "opencode": { + "name": "OpenCode", + "description": "Harness opencode — CLI agêntico com tool-use nativo, roteável por qualquer endpoint OpenAI-compatível (Base URL/API Key editáveis abaixo, igual ao OpenClaude). Requer opencode.json na raiz do workspace com um provider \"opencode\" registrado (baseURL/apiKey resolvidos via env em runtime).", + "cli_command": "opencode", + "mode": "code", + "env_vars": { + "OPENAI_BASE_URL": "", + "OPENAI_API_KEY": "" + }, + "default_base_url": "", + "default_model": "auto", + "requires_logout": false, + "fallback_providers": [ + "anthropic" + ] + } + }, + "telegram_provider": "nvidia" +} diff --git a/dashboard/backend/app.py b/dashboard/backend/app.py index 2dccb597..3a355fd8 100644 --- a/dashboard/backend/app.py +++ b/dashboard/backend/app.py @@ -42,6 +42,14 @@ def _cors_allowed_origins(): return "*" if not _is_production() else [] app = Flask(__name__, static_folder=None) + +# Atrás do Traefik o scheme visto pelo Flask é http; sem honrar +# X-Forwarded-Proto/Host, request.host_url gera redirect_uri http:// nos +# fluxos OAuth (social-auth blueprints) e o provedor recusa o callback. +# A porta 8080 não é exposta diretamente na VPS, então confiar em 1 hop +# de proxy é seguro; sem os headers (acesso local) nada muda. +from werkzeug.middleware.proxy_fix import ProxyFix +app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1, x_host=1) # Persist secret key so sessions survive restarts _secret_key = os.environ.get("EVONEXUS_SECRET_KEY") if not _secret_key: @@ -75,8 +83,12 @@ def _cors_allowed_origins(): app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{WORKSPACE / 'dashboard' / 'data' / 'evonexus.db'}" app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False app.config["REMEMBER_COOKIE_DURATION"] = timedelta(days=30) -# SameSite=Strict prevents cross-origin cookie riding (CSRF defense layer 1). -app.config["SESSION_COOKIE_SAMESITE"] = "Strict" +# SameSite=Lax: bloqueia cookie em requests cross-site mutantes (POST/iframe/ +# subresource — onde CSRF acontece) mas permite navegação top-level GET. +# Strict quebrava TODO fluxo OAuth montado no dashboard: o redirect de volta +# do provedor (twitter.com → /callback/twitter) é cross-site, o cookie de +# sessão não era enviado e o state salvo em session sumia → "Invalid state". +app.config["SESSION_COOKIE_SAMESITE"] = "Lax" # --- JSON encoding for API responses --- # With ensure_ascii=True (Flask default) jsonify escapes every non-ASCII @@ -93,6 +105,12 @@ def _cors_allowed_origins(): CORS(app, origins=_cors_allowed_origins(), supports_credentials=True) +# --------------- Rate limiting (in-memory, single-process Flask) --------------- +# Vault audit §2.S1 CRITICAL: all public endpoints require rate limiting. +# The limiter singleton lives in rate_limit.py to avoid circular imports with blueprints. +from rate_limit import limiter +limiter.init_app(app) + # --------------- Database --------------- from models import db, User, BrainRepoConfig, needs_setup, seed_roles, seed_systems db.init_app(app) @@ -132,6 +150,7 @@ def _cors_allowed_origins(): goal_id TEXT, required_secrets TEXT DEFAULT '[]', decision_prompt TEXT NOT NULL, + handler TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); @@ -450,9 +469,11 @@ def _cors_allowed_origins(): PRIMARY KEY (plugin_slug, handler_path) ); CREATE INDEX IF NOT EXISTS idx_plugins_status ON plugins_installed(status); - CREATE INDEX IF NOT EXISTS idx_plugin_audit_plugin ON plugin_audit_log(plugin_id, created_at); CREATE INDEX IF NOT EXISTS idx_hook_cb_disabled ON plugin_hook_circuit_state(disabled_until); """) + _pal_cols = {row[1] for row in _cur.execute("PRAGMA table_info(plugin_audit_log)").fetchall()} + if "plugin_id" in _pal_cols: + _cur.execute("CREATE INDEX IF NOT EXISTS idx_plugin_audit_plugin ON plugin_audit_log(plugin_id, created_at)") _conn.commit() # --- End plugins migration --- @@ -516,6 +537,10 @@ def _cors_allowed_origins(): # source_plugin: tag heartbeats that were contributed by a plugin so the # plugin detail page can filter them via GET /api/heartbeats?source_plugin=. _hb_cols = {row[1] for row in _cur.execute("PRAGMA table_info(heartbeats)").fetchall()} + if "handler" not in _hb_cols: + _cur.execute("ALTER TABLE heartbeats ADD COLUMN handler TEXT") + _conn.commit() + _hb_cols.add("handler") if "source_plugin" not in _hb_cols: _cur.execute("ALTER TABLE heartbeats ADD COLUMN source_plugin TEXT") _conn.commit() @@ -603,6 +628,27 @@ def _cors_allowed_origins(): _conn.commit() # --- End Wave 2.2r migration --- + # --- B3 safe_uninstall migration: plugin_orphans table --- + _existing_tables_b3 = {row[0] for row in _cur.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()} + if "plugin_orphans" not in _existing_tables_b3: + _cur.executescript(""" + CREATE TABLE IF NOT EXISTS plugin_orphans ( + id TEXT PRIMARY KEY, + slug TEXT NOT NULL, + tablename TEXT NOT NULL, + orphaned_at TEXT NOT NULL, + orphaned_by_user_id INTEGER, + original_plugin_version TEXT, + original_sha256 TEXT, + original_publisher_url TEXT, + recovered_at TEXT, + UNIQUE(slug, tablename) + ); + CREATE INDEX IF NOT EXISTS idx_plugin_orphans_slug ON plugin_orphans(slug); + """) + _conn.commit() + # --- End B3 safe_uninstall migration --- + # Fix corrupted datetime columns (NULL or non-string values crash SQLAlchemy) for _tbl, _col in [("roles", "created_at"), ("users", "created_at"), ("users", "last_login")]: try: @@ -621,6 +667,9 @@ def _cors_allowed_origins(): # ({active_provider, providers: {...}}), copy providers.example.json # over it. This recovers from broken state left by older versions of # the onboarding wizard that naively wrote {: {api_key, enabled}}. + # If the schema is valid but older than the bundled example, merge missing + # providers/fields from providers.example.json without overwriting secrets + # stored in the persistent Docker volume. try: _providers_file = WORKSPACE / "config" / "providers.json" _providers_example = WORKSPACE / "config" / "providers.example.json" @@ -639,6 +688,18 @@ def _cors_allowed_origins(): import shutil as _shutil _shutil.copy2(_providers_example, _providers_file) print("[migration] providers.json had invalid schema, restored from providers.example.json") + elif _ok and _providers_example.is_file(): + try: + from routes.providers import _merge_provider_defaults as _merge_provider_defaults + _merged, _changed = _merge_provider_defaults(_data) + if _changed: + _providers_file.write_text( + _json.dumps(_merged, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + print("[migration] providers.json merged missing provider defaults") + except Exception as _merge_exc: + print(f"[migration] providers.json default merge skipped: {_merge_exc}") except Exception as _mig_exc: print(f"[migration] providers.json normalization skipped: {_mig_exc}") # --- End providers.json migration --- @@ -732,7 +793,7 @@ def _cors_allowed_origins(): @login_manager.user_loader def load_user(user_id): - return User.query.get(int(user_id)) + return db.session.get(User, int(user_id)) @login_manager.unauthorized_handler def unauthorized(): @@ -796,6 +857,7 @@ def auth_middleware(): path in PUBLIC_PATHS or path.startswith("/api/docs") or path.startswith("/api/triggers/webhook/") + or path.startswith("/api/instagram/webhook") or (path.startswith("/api/shares/") and "/view" in path) or path.startswith("/api/knowledge/v1/") ): @@ -850,6 +912,8 @@ def auth_middleware(): from routes.databases import bp as databases_bp from routes.plugins import bp as plugins_bp from routes.mcp_servers import bp as mcp_servers_bp +from routes.plugin_public_pages import bp as plugin_public_pages_bp +from routes.instagram import bp as instagram_api_bp # Brain Repo + Onboarding blueprints (loaded after routes are created) try: @@ -890,6 +954,9 @@ def auth_middleware(): app.register_blueprint(triggers_bp) app.register_blueprint(terminal_proxy_bp) +# Instagram API routes (webhooks, publish, comments, DMs) +app.register_blueprint(instagram_api_bp) + # Mount the terminal-server WebSocket proxy on the same Sock instance the # rest of the app uses. Done after the blueprint is registered so route # names are unique. Without this, browsers connecting from a host other @@ -922,8 +989,15 @@ def auth_middleware(): app.register_blueprint(databases_bp) app.register_blueprint(plugins_bp) app.register_blueprint(mcp_servers_bp) +# B2.0: plugin public pages (unauthenticated, token-bound portals) +app.register_blueprint(plugin_public_pages_bp) # --------------- Social Auth blueprints --------------- +# The social-auth package lives as a sibling directory; add it once so the +# dashboard backend can mount the same OAuth blueprints on port 8080. +SOCIAL_AUTH_DIR = WORKSPACE / "social-auth" +if SOCIAL_AUTH_DIR.is_dir() and str(SOCIAL_AUTH_DIR) not in sys.path: + sys.path.insert(0, str(SOCIAL_AUTH_DIR)) from auth.youtube import bp as youtube_auth_bp from auth.instagram import bp as instagram_auth_bp from auth.linkedin import bp as linkedin_auth_bp diff --git a/dashboard/backend/brain_repo/git_ops.py b/dashboard/backend/brain_repo/git_ops.py index dad7ada1..6781f421 100644 --- a/dashboard/backend/brain_repo/git_ops.py +++ b/dashboard/backend/brain_repo/git_ops.py @@ -7,6 +7,8 @@ log = logging.getLogger(__name__) DEFAULT_TIMEOUT = 60 # seconds +DEFAULT_GIT_USER_NAME = "EvoNexus" +DEFAULT_GIT_USER_EMAIL = "evonexus@users.noreply.github.com" def _masked_url(url: str, token: str) -> str: @@ -27,6 +29,27 @@ def _run(cmd: list[str], cwd: Path | None = None, timeout: int = DEFAULT_TIMEOUT ) +def _ensure_identity(repo_dir: Path) -> None: + """Ensure local git author identity exists before commit/tag operations.""" + name_result = _run(["git", "config", "--get", "user.name"], cwd=repo_dir, timeout=10) + if name_result.returncode != 0 or not name_result.stdout.strip(): + result = _run(["git", "config", "user.name", DEFAULT_GIT_USER_NAME], cwd=repo_dir, timeout=10) + if result.returncode != 0: + raise RuntimeError( + f"git config user.name failed (exit {result.returncode}): " + f"stderr={result.stderr[:200]!r}" + ) + + email_result = _run(["git", "config", "--get", "user.email"], cwd=repo_dir, timeout=10) + if email_result.returncode != 0 or not email_result.stdout.strip(): + result = _run(["git", "config", "user.email", DEFAULT_GIT_USER_EMAIL], cwd=repo_dir, timeout=10) + if result.returncode != 0: + raise RuntimeError( + f"git config user.email failed (exit {result.returncode}): " + f"stderr={result.stderr[:200]!r}" + ) + + def clone(url: str, token: str, target: Path) -> None: """Clone a repository using a token-embedded HTTPS URL. @@ -56,6 +79,8 @@ def commit_all(repo_dir: Path, message: str) -> bool: Returns False if there was nothing to commit (git exits 1 with no staged changes), True on success, raises RuntimeError on other failures. """ + _ensure_identity(repo_dir) + add_result = _run(["git", "add", "-A"], cwd=repo_dir) if add_result.returncode != 0: raise RuntimeError( @@ -164,6 +189,7 @@ def create_tag(repo_dir: Path, tag: str, message: str, force: bool = False) -> b Returns False on failure (never raises). """ try: + _ensure_identity(repo_dir) cmd = ["git", "tag", "-a", tag, "-m", message] if force: cmd.insert(2, "-f") diff --git a/dashboard/backend/brain_repo/github_api.py b/dashboard/backend/brain_repo/github_api.py index 8c20e71a..e552717a 100644 --- a/dashboard/backend/brain_repo/github_api.py +++ b/dashboard/backend/brain_repo/github_api.py @@ -68,7 +68,7 @@ def _post(url: str, token: str, payload: dict) -> tuple[int, dict | None]: def detect_brain_repos(token: str) -> list[dict]: - """Find repos owned by the authenticated user that carry a ``.evo-brain`` marker. + """Find private repos owned by the authenticated user that can be Brain Repo candidates. Previous implementation used ``/search/code?q=filename:.evo-brain`` which had two bugs in practice: GitHub's code index doesn't reliably include dotfiles, @@ -80,7 +80,11 @@ def detect_brain_repos(token: str) -> list[dict]: per private repo (cheap at ≤100 repos), but the result reflects the current state of the remote, not the indexer. - Returns list of {name, full_name, html_url, private, description}. + Empty/existing repos are valid candidates because the connect pipeline can + initialize the ``.evo-brain`` marker after cloning. Each item includes + ``has_evo_brain`` so the UI can distinguish initialized repos later. + + Returns list of {name, full_name, html_url, private, description, has_evo_brain}. """ repos = list_user_repos(token) results = [] @@ -91,8 +95,12 @@ def detect_brain_repos(token: str) -> list[dict]: owner, name = full_name.split("/", 1) contents_url = f"{_API_BASE}/repos/{owner}/{name}/contents/.evo-brain" status, _body, _ = _get(contents_url, token) + candidate = dict(repo) + candidate["has_evo_brain"] = status == 200 if status == 200: - results.append(repo) + results.insert(0, candidate) + elif status == 404: + results.append(candidate) elif status not in (200, 404): # 403 rate-limit, 5xx, network hiccup — log and move on, the user # can retry. Don't error the whole listing for one flaky repo. diff --git a/dashboard/backend/brain_repo/job_runner.py b/dashboard/backend/brain_repo/job_runner.py index 3099b558..188de598 100644 --- a/dashboard/backend/brain_repo/job_runner.py +++ b/dashboard/backend/brain_repo/job_runner.py @@ -55,6 +55,7 @@ JOB_KIND_SYNC = "sync" JOB_KIND_MILESTONE = "milestone" JOB_KIND_BOOTSTRAP = "bootstrap" +JOB_KIND_CLONE = "clone" JOB_KIND_WATCHER = "watcher" # Max time a job may hold sync_in_progress before the janitor reclaims it. @@ -527,6 +528,72 @@ def run_bootstrap_pipeline( ) +def run_clone_pipeline( + flask_app, + user_id: int, + *, + token: str, + repo_url: str, + repo_name: str, +) -> None: + """Clone an existing Brain Repo and persist local_path. + + Connecting an existing repo does not need the initial skeleton bootstrap, + but sync still requires a local git working tree under dashboard/data. + """ + from models import BrainRepoConfig, db # type: ignore[import] + + with _job_lock: + error: str | None = None + try: + workspace = Path(__file__).resolve().parent.parent.parent.parent + base_dir = workspace / "dashboard" / "data" / "brain-repos" + base_dir.mkdir(parents=True, exist_ok=True) + local_path = base_dir / repo_name + + if local_path.exists(): + shutil.rmtree(local_path, ignore_errors=True) + + from brain_repo import git_ops, manifest # type: ignore[import] + + git_ops.clone(repo_url, token, local_path) + _check_cancel(flask_app, user_id) + + # Existing empty/non-brain repos are accepted: initialize the + # skeleton and push it so future detect/restore calls can see it. + if not (local_path / ".evo-brain").exists(): + import subprocess + + subprocess.run( + ["git", "config", "user.name", "EvoNexus"], + cwd=local_path, check=True, capture_output=True, timeout=10, + ) + subprocess.run( + ["git", "config", "user.email", "evonexus@users.noreply.github.com"], + cwd=local_path, check=True, capture_output=True, timeout=10, + ) + manifest.initialize_brain_repo(local_path, {"workspace_name": repo_name}) + if git_ops.commit_all(local_path, "feat(brain-repo): initialize existing repo"): + pushed, push_err = git_ops.push(local_path, token, with_tags=False) + if not pushed: + error = f"initial brain repo push failed: {push_err}" + return + + with flask_app.app_context(): + config = BrainRepoConfig.query.filter_by(user_id=user_id).first() + if config is not None: + config.local_path = str(local_path) + db.session.commit() + + except JobCancelled: + error = "cancelled by user" + except Exception as exc: + error = f"clone failed: {exc}" + log.exception("clone pipeline raised") + finally: + _release_db_lock(flask_app, user_id, success=error is None, error=error) + + # ──────────────────────────────────────────────────────────────────────── # Public enqueue API — what route handlers call. # ──────────────────────────────────────────────────────────────────────── @@ -586,6 +653,29 @@ def enqueue_bootstrap( return True +def enqueue_clone( + flask_app, + user_id: int, + *, + token: str, + repo_url: str, + repo_name: str, +) -> bool: + """Spawn daemon thread cloning an existing Brain Repo. Returns False if busy.""" + if not _acquire_db_lock(flask_app, user_id, JOB_KIND_CLONE): + return False + + t = threading.Thread( + target=run_clone_pipeline, + args=(flask_app, user_id), + kwargs={"token": token, "repo_url": repo_url, "repo_name": repo_name}, + name=f"brain-repo-clone-{user_id}", + daemon=True, + ) + t.start() + return True + + def request_cancel(flask_app, user_id: int) -> bool: """Set cancel_requested=1 on the active job. No-op if idle. Returns True if a job was flagged.""" from models import BrainRepoConfig, db # type: ignore[import] diff --git a/dashboard/backend/heartbeat_dispatcher.py b/dashboard/backend/heartbeat_dispatcher.py index a19f9160..acaaea57 100644 --- a/dashboard/backend/heartbeat_dispatcher.py +++ b/dashboard/backend/heartbeat_dispatcher.py @@ -189,15 +189,15 @@ def _sync_heartbeats_to_db(): """INSERT INTO heartbeats (id, agent, interval_seconds, max_turns, timeout_seconds, lock_timeout_seconds, wake_triggers, enabled, goal_id, - required_secrets, decision_prompt, source_plugin, + required_secrets, decision_prompt, handler, source_plugin, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", ( hb.id, hb.agent, hb.interval_seconds, hb.max_turns, hb.timeout_seconds, hb.lock_timeout_seconds, json.dumps(hb.wake_triggers), int(hb.enabled), hb.goal_id, json.dumps(hb.required_secrets), hb.decision_prompt, - hb.source_plugin, + hb.handler, hb.source_plugin, now, now, ), ) @@ -207,14 +207,14 @@ def _sync_heartbeats_to_db(): """UPDATE heartbeats SET agent=?, interval_seconds=?, max_turns=?, timeout_seconds=?, lock_timeout_seconds=?, wake_triggers=?, goal_id=?, - required_secrets=?, decision_prompt=?, source_plugin=?, + required_secrets=?, decision_prompt=?, handler=?, source_plugin=?, updated_at=? WHERE id=?""", ( hb.agent, hb.interval_seconds, hb.max_turns, hb.timeout_seconds, hb.lock_timeout_seconds, json.dumps(hb.wake_triggers), hb.goal_id, json.dumps(hb.required_secrets), hb.decision_prompt, - hb.source_plugin, now, + hb.handler, hb.source_plugin, now, hb.id, ), ) @@ -224,8 +224,47 @@ def _sync_heartbeats_to_db(): conn.close() +def _seconds_since_last_run(heartbeat_id: str) -> float | None: + """Seconds since this heartbeat's most recent run started, or None if it + has never run. Used by register_interval_jobs to catch up on heartbeats + that are overdue relative to their OWN history — see that function's + docstring for why this matters. + """ + conn = _get_db() + try: + row = conn.execute( + "SELECT started_at FROM heartbeat_runs WHERE heartbeat_id = ? " + "ORDER BY started_at DESC LIMIT 1", + (heartbeat_id,), + ).fetchone() + finally: + conn.close() + if not row or not row["started_at"]: + return None + try: + last = datetime.strptime(row["started_at"], "%Y-%m-%dT%H:%M:%S.%fZ").replace(tzinfo=timezone.utc) + except ValueError: + return None + return (datetime.now(timezone.utc) - last).total_seconds() + + def register_interval_jobs(): - """Register schedule jobs for all heartbeats with 'interval' wake trigger.""" + """Register schedule jobs for all heartbeats with 'interval' wake trigger. + + `schedule.every(N).do(job)` always counts the first firing from THIS + call, not from any persisted history — so a heartbeat that's mid- + interval when the process restarts (a redeploy, a crash, `reload_config` + after a plugin install) has its countdown reset to the full N, and + starts looking "overdue" relative to its configured cadence even though + nothing is actually broken. Confirmed live 2026-07-14: several redeploys + in a short window left atlas-4h/zara-2h/flux-6h looking hours behind + schedule purely because of restart timing, not a stuck dispatcher. + Fires an immediate catch-up dispatch for anything already overdue by its + own last-run history before/alongside registering the periodic job, so + a redeploy costs at most one wasted-looking cycle, never a multi-hour + stall. dispatch()'s existing 30s debounce makes this safe to call + repeatedly (e.g. on every plugin-triggered reload_config). + """ _sync_heartbeats_to_db() heartbeats = _load_enabled_heartbeats() @@ -245,6 +284,15 @@ def register_interval_jobs(): interval_secs = hb["interval_seconds"] hb_id = hb["id"] + # Catch-up: if this heartbeat is already overdue by its own history + # (last real run further back than its interval), fire it now + # instead of waiting a full fresh interval from this process start. + elapsed = _seconds_since_last_run(hb_id) + if elapsed is not None and elapsed >= interval_secs: + print(f"[dispatcher] {hb_id} overdue ({elapsed:.0f}s since last run, " + f"interval {interval_secs}s) — catch-up dispatch", flush=True) + dispatch(hb_id, "interval") + # Use schedule library (same as scheduler.py) # Tag the job so we can cancel it if needed tag = f"hb-interval-{hb_id}" diff --git a/dashboard/backend/heartbeat_outcome.py b/dashboard/backend/heartbeat_outcome.py new file mode 100644 index 00000000..36bcb8f5 --- /dev/null +++ b/dashboard/backend/heartbeat_outcome.py @@ -0,0 +1,460 @@ +"""Interpret an agent's heartbeat result and apply it to the kanban. + +The old model fired "✅ Heartbeat OK" for every successful run, including +no-op skips — pure noise. This module replaces that with outcome-driven +behaviour: the agent ends its run with a structured JSON block describing what +it did, and we (1) move the ticket on the board, (2) record a comment + activity, +and (3) decide whether anything is worth telling Felipe on Telegram. + +Agent output contract (the agent appends this JSON to its final message): + + { + "action": "work" | "skip" | "blocked", + "ticket_id": "", + "result": "", + "new_status": "in_progress" | "review" | "resolved" | "blocked" | null, + "blocked_reason": "", + "needs": "" + } + +Notification policy (decided with Felipe 2026-06-17): + - action=skip → silent (nothing to report) + - action=work → notify the *result* (not tokens/cost) + - action=blocked → notify, because Felipe needs to intervene + - unparseable / no JSON → silent (no more "heartbeat ok" spam) +""" + +from __future__ import annotations + +import json +import re +from datetime import datetime, timezone + +# Valid ticket states (see .claude/rules/tickets.md) +_VALID_STATUS = {"open", "in_progress", "blocked", "review", "resolved", "closed"} +_STATUS_ALIASES = { + "done": "resolved", + "complete": "resolved", + "completed": "resolved", + "finished": "resolved", + "in progress": "in_progress", + "inprogress": "in_progress", + "review_needed": "review", + "needs_review": "review", +} + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _normalize_status(raw: str | None) -> str | None: + if not raw: + return None + s = str(raw).strip().lower() + s = _STATUS_ALIASES.get(s, s) + return s if s in _VALID_STATUS else None + + +def _unwrap_provider_output(text: str) -> str: + """Unwrap the CLI/provider JSON envelope to get the assistant's actual text. + + step7 returns the raw CLI output, which for `--output-format json` is an + envelope like {"type":"result","result":"","usage":{…}}. + The agent's outcome JSON lives INSIDE that text with escaped quotes. We must + json.loads the envelope (which un-escapes the inner text) before scanning for + the {"action":…} block. Handles stream-json (multi-line) too. + """ + s = (text or "").strip() + if not s.startswith("{"): + return text + + def _content_of(obj): + if not isinstance(obj, dict): + return None + if "action" in obj: + return json.dumps(obj) # already the outcome itself + for key in ("result", "content", "text", "message", "response", "output"): + v = obj.get(key) + if isinstance(v, str) and v.strip(): + return v + return None + + # Single JSON envelope + try: + got = _content_of(json.loads(s)) + if got is not None: + return got + except json.JSONDecodeError: + pass + # stream-json: scan lines bottom-up for the last decodable envelope with content + for line in reversed(s.splitlines()): + line = line.strip() + if not line.startswith("{"): + continue + try: + got = _content_of(json.loads(line)) + except json.JSONDecodeError: + continue + if got is not None: + return got + return text + + +def parse_agent_outcome(output) -> dict | None: + """Extract the structured outcome JSON from an agent's free-form output. + + Returns the parsed dict (with at least an "action" key) or None if no + structured outcome could be found. + """ + if isinstance(output, dict): + return output if "action" in output else None + if not output: + return None + text = _unwrap_provider_output(str(output)) + + candidates: list[str] = [] + # 1. fenced ```json ... ``` blocks + for m in re.finditer(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL): + candidates.append(m.group(1)) + # 2. raw JSON objects scanned with the decoder (handles nesting) + decoder = json.JSONDecoder() + idx = 0 + while True: + start = text.find("{", idx) + if start == -1: + break + try: + obj, end = decoder.raw_decode(text[start:]) + if isinstance(obj, dict): + candidates.append(json.dumps(obj)) + idx = start + max(end, 1) + except json.JSONDecodeError: + idx = start + 1 + + # Prefer the last candidate that carries an "action" key + for cand in reversed(candidates): + try: + obj = json.loads(cand) + except (json.JSONDecodeError, TypeError): + continue + if isinstance(obj, dict) and "action" in obj: + return obj + return None + + +_OUTCOME_SCHEMA = { + "type": "object", + "properties": { + "action": {"type": "string", "enum": ["work", "skip", "blocked"]}, + "ticket_id": {"type": ["string", "null"]}, + "result": {"type": "string"}, + "new_status": {"type": ["string", "null"]}, + "blocked_reason": {"type": "string"}, + "needs": {"type": "string"}, + }, + "required": ["action", "result"], +} + +# Large models that reliably honor response_format=json_schema with good content. +# A short chain so a 429 on one rotates to the next (NVIDIA is free; the limit is +# rate, not cost — see the model-chain rationale). +_STRUCTURER_MODELS = [ + "nvidia/nemotron-3-ultra-550b-a55b", + "qwen/qwen3.5-397b-a17b", + "qwen/qwen3.5-122b-a10b", +] + + +def _nvidia_key_and_base() -> tuple[str, str]: + import os + key = os.environ.get("NVIDIA_API_KEY") or os.environ.get("OPENAI_API_KEY") or "" + base = "https://integrate.api.nvidia.com/v1" + if not key: + # last resort: read NVIDIA_API_KEY from .env at repo root + try: + from pathlib import Path + env = Path(__file__).resolve().parents[2] / ".env" + for line in env.read_text(encoding="utf-8").splitlines(): + if line.strip().startswith("NVIDIA_API_KEY="): + key = line.split("=", 1)[1].strip() + break + except Exception: # noqa: BLE001 + pass + return key, base + + +def structure_via_nvidia(agent_output, agent: str, conn) -> dict | None: + """Structure the agent's report into outcome JSON using NVIDIA (free). + + The executor models run via the agentic CLI and rarely emit clean JSON. This + makes a single, cheap completion call with response_format=json_schema (strict) + on a large model, which guarantees structurally-valid JSON. Free — keeps the + whole loop on NVIDIA. Falls through to Claude only if every NVIDIA model fails. + """ + import json as _json + import urllib.request + import urllib.error + + report = _unwrap_provider_output(str(agent_output or "")).strip() + if not report: + return None + key, base = _nvidia_key_and_base() + if not key: + return None + + rows = conn.execute( + "SELECT id, title FROM tickets WHERE assignee_agent = ? " + "AND status IN ('open','in_progress') ORDER BY priority_rank DESC LIMIT 10", + (agent,), + ).fetchall() + tickets = [{"id": (r["id"] if hasattr(r, "keys") else r[0]), + "title": (r["title"] if hasattr(r, "keys") else r[1])} for r in rows] + + prompt = ( + "Converta o relatório de um agente em um JSON de outcome.\n" + f"Tickets atribuídos ao agente: {_json.dumps(tickets, ensure_ascii=False)}\n\n" + f"Relatório do agente:\n{report[:4000]}\n\n" + "Regras:\n" + "- action='work' se o agente ENTREGOU ou avançou algo concreto (existe um " + "resultado). Defina new_status: 'resolved' se concluiu, 'review' se precisa " + "revisão, 'in_progress' se avançou parcialmente.\n" + "- action='blocked' se depende de algo que só o humano fornece (credencial, " + "acesso, dado, decisão). Preencha blocked_reason e needs.\n" + "- action='skip' SOMENTE se nada acionável foi feito.\n" + "- result: 1 frase em pt-BR com o resultado concreto. ticket_id: o id tratado." + ) + body = { + "messages": [{"role": "user", "content": prompt}], + "temperature": 0.2, + "max_tokens": 600, + "response_format": {"type": "json_schema", + "json_schema": {"name": "outcome", "schema": _OUTCOME_SCHEMA, + "strict": True}}, + } + for model in _STRUCTURER_MODELS: + body["model"] = model + try: + req = urllib.request.Request( + base + "/chat/completions", + data=_json.dumps(body).encode("utf-8"), + headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"}, + ) + with urllib.request.urlopen(req, timeout=90) as resp: + data = _json.loads(resp.read().decode("utf-8")) + content = (data.get("choices") or [{}])[0].get("message", {}).get("content") + if content: + parsed = _json.loads(content) + if isinstance(parsed, dict) and "action" in parsed: + return parsed + except urllib.error.HTTPError as e: + if e.code == 429: + continue # rate-limited → next model + continue + except Exception: # noqa: BLE001 + continue + return None + + +def structure_via_claude(agent_output, agent: str, conn) -> dict | None: + """Hybrid fallback: NVIDIA executes (free, heavy), Claude structures (cheap). + + The NVIDIA models do the actual work but often fail to emit the outcome JSON + reliably (they narrate, hit max turns, or answer generically). When the raw + parse fails, we ask the native `claude` CLI (Anthropic subscription) to turn + the agent's report into the outcome JSON — one turn, no tools, ~hundreds of + tokens, so it barely touches the Anthropic quota. Returns the outcome dict or + None if Claude is unavailable / also fails. + """ + import os + import shutil + import subprocess + + claude_bin = shutil.which("claude") + if not claude_bin: + return None + + report = _unwrap_provider_output(str(agent_output or "")).strip() + if not report: + return None + + # Inbox the agent could have acted on (id + title), so Claude can pick ticket_id + rows = conn.execute( + "SELECT id, title FROM tickets WHERE assignee_agent = ? " + "AND status IN ('open','in_progress') ORDER BY priority_rank DESC LIMIT 10", + (agent,), + ).fetchall() + tickets = [] + for r in rows: + try: + tickets.append({"id": r["id"], "title": r["title"]}) + except (TypeError, KeyError, IndexError): + tickets.append({"id": r[0], "title": r[1]}) + + prompt = ( + "Você converte o relatório de um agente em um único JSON de outcome. " + "Tickets atribuídos ao agente (escolha o ticket_id correto):\n" + f"{json.dumps(tickets, ensure_ascii=False)}\n\n" + f"Relatório do agente:\n{report[:4000]}\n\n" + "Responda SOMENTE com este JSON (uma linha, nada antes/depois):\n" + '{"action":"work"|"skip"|"blocked","ticket_id":"",' + '"result":"",' + '"new_status":"in_progress"|"review"|"resolved"|null,' + '"blocked_reason":"","needs":""}\n' + "Regras: action=work se o agente entregou/avançou algo; blocked se ele " + "depende de dado/credencial/decisão humana; skip se nada foi feito." + ) + + # Clean env so the `claude` CLI uses the Anthropic subscription, not the + # NVIDIA/OpenAI override vars that may be set for the rest of the workspace. + env = {k: v for k, v in os.environ.items() + if not (k.startswith("OPENAI_") or k.startswith("CLAUDE_CODE_USE_") + or k in ("ANTHROPIC_BASE_URL", "NVIDIA_API_KEY"))} + try: + proc = subprocess.run( + [claude_bin, "--print", "--output-format", "json", + "--max-turns", "1", "--tools", "", "--", prompt], + capture_output=True, text=True, timeout=90, env=env, + ) + except (subprocess.TimeoutExpired, Exception): # noqa: BLE001 + return None + if proc.returncode != 0: + return None + return parse_agent_outcome(proc.stdout) + + +def _ticket_title(ticket_id: str, conn) -> str: + row = conn.execute("SELECT title FROM tickets WHERE id = ?", (ticket_id,)).fetchone() + if not row: + return ticket_id + try: + return row["title"] or ticket_id + except (TypeError, KeyError, IndexError): + return (row[0] if row[0] else ticket_id) + + +def _advance_goal_for_ticket(ticket_id: str, old_status: str, new_status: str, conn) -> None: + """When a ticket linked to a goal becomes resolved/closed, advance the goal. + + This is the integration that was missing: the orchestrator moves *tickets*, + but goal progress is tracked on the goal counter. Increment once, on the real + transition into a terminal status (idempotent — the orchestrator only acts on + 'open' tickets so a ticket resolves once). + """ + terminal = ("resolved", "closed") + if new_status not in terminal or old_status in terminal: + return + row = conn.execute("SELECT goal_id FROM tickets WHERE id = ?", (ticket_id,)).fetchone() + goal_id = (row["goal_id"] if row and hasattr(row, "keys") else (row[0] if row else None)) + if not goal_id: + return + g = conn.execute("SELECT current_value, target_value, status FROM goals WHERE id = ?", + (goal_id,)).fetchone() + if not g: + return + cur = (g["current_value"] if hasattr(g, "keys") else g[0]) or 0 + target = (g["target_value"] if hasattr(g, "keys") else g[1]) or 0 + gstatus = (g["status"] if hasattr(g, "keys") else g[2]) + new_val = cur + 1 + achieved = gstatus == "active" and target and new_val >= target + conn.execute( + "UPDATE goals SET current_value = ?, status = ?, updated_at = ? WHERE id = ?", + (new_val, "achieved" if achieved else gstatus, _now_iso(), goal_id), + ) + + +def _move_ticket(ticket_id: str, new_status: str, agent: str, comment: str, conn) -> None: + """Update ticket status + log a comment and an activity event.""" + now = _now_iso() + prev = conn.execute("SELECT status FROM tickets WHERE id = ?", (ticket_id,)).fetchone() + old_status = (prev["status"] if prev and hasattr(prev, "keys") else (prev[0] if prev else "")) + resolved_at = now if new_status in ("resolved", "closed") else None + conn.execute( + "UPDATE tickets SET status = ?, updated_at = ?, " + "resolved_at = COALESCE(?, resolved_at) WHERE id = ?", + (new_status, now, resolved_at, ticket_id), + ) + _advance_goal_for_ticket(ticket_id, old_status, new_status, conn) + if comment: + import uuid + conn.execute( + "INSERT INTO ticket_comments (id, ticket_id, author, body, mentions, created_at) " + "VALUES (?, ?, ?, ?, ?, ?)", + (str(uuid.uuid4()), ticket_id, f"agent:{agent}", comment, "[]", now), + ) + conn.execute( + "INSERT INTO ticket_activity (id, ticket_id, actor, action, payload, created_at) " + "VALUES (?, ?, ?, ?, ?, ?)", + (str(uuid.uuid4()), ticket_id, f"agent:{agent}", "status_changed", + json.dumps({"new_status": new_status}), now), + ) + conn.commit() + + +def apply_outcome(heartbeat_id: str, agent: str, result: dict, conn) -> dict | None: + """Apply the agent's outcome to the kanban and return a notification spec. + + Returns a dict {kind, ...} describing what (if anything) to notify, or None + for silence. The caller (heartbeat_runner) turns this into a Telegram message. + """ + status = result.get("status", "fail") + + # Technical failure of the run itself — surface it compactly, it may be real. + if status != "success": + return { + "kind": "tech_fail", + "agent": agent, + "heartbeat_id": heartbeat_id, + "error": (result.get("error") or "execução falhou")[:300], + } + + raw_output = result.get("output") or result.get("result") or result.get("handler_result") + outcome = parse_agent_outcome(raw_output) + if not outcome: + # NVIDIA executed but didn't emit clean JSON → structure it. Try NVIDIA + # first (free, json_schema-forced), then Claude as a last resort. + outcome = structure_via_nvidia(raw_output, agent, conn) + if not outcome: + outcome = structure_via_claude(raw_output, agent, conn) + if not outcome: + return None # nobody could structure it → silent no-op (no spam) + + action = str(outcome.get("action", "")).strip().lower() + ticket_id = outcome.get("ticket_id") or None + summary = (outcome.get("result") or "").strip() + + if action == "skip": + return None + + if action == "blocked": + reason = (outcome.get("blocked_reason") or summary or "sem detalhes").strip() + needs = (outcome.get("needs") or "").strip() + title = _ticket_title(ticket_id, conn) if ticket_id else "" + if ticket_id: + _move_ticket(ticket_id, "blocked", agent, + f"Bloqueado: {reason}" + (f"\nPrecisa: {needs}" if needs else ""), conn) + return { + "kind": "blocked", + "agent": agent, + "ticket_id": ticket_id or "", + "ticket_title": title, + "reason": reason, + "needs": needs, + } + + if action == "work": + new_status = _normalize_status(outcome.get("new_status")) or "in_progress" + title = _ticket_title(ticket_id, conn) if ticket_id else "" + if ticket_id: + _move_ticket(ticket_id, new_status, agent, summary or "Trabalho realizado.", conn) + if not summary: + return None # worked but reported nothing meaningful → stay silent + return { + "kind": "result", + "agent": agent, + "ticket_title": title, + "new_status": new_status, + "summary": summary, + } + + return None diff --git a/dashboard/backend/heartbeat_runner.py b/dashboard/backend/heartbeat_runner.py index 9966643b..a27029dd 100644 --- a/dashboard/backend/heartbeat_runner.py +++ b/dashboard/backend/heartbeat_runner.py @@ -36,6 +36,11 @@ LOGS_DIR = WORKSPACE / "ADWs" / "logs" / "heartbeats" AGENTS_DIR = WORKSPACE / ".claude" / "agents" +# Agents that monitor external state (Linear, Stripe, Omie, …) and may have work +# to do even with an empty ticket inbox. These bypass the empty-inbox cost guard; +# every other agent skips (without invoking Claude) when it has no assigned work. +STATE_MONITOR_AGENTS = {"atlas-project", "flux-finance"} + def _now_iso() -> str: return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ") @@ -80,13 +85,13 @@ def _upsert_heartbeat_from_yaml(heartbeat_id: str) -> dict | None: """INSERT OR REPLACE INTO heartbeats (id, agent, interval_seconds, max_turns, timeout_seconds, lock_timeout_seconds, wake_triggers, enabled, goal_id, - required_secrets, decision_prompt, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + required_secrets, decision_prompt, handler, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", ( hb.id, hb.agent, hb.interval_seconds, hb.max_turns, hb.timeout_seconds, hb.lock_timeout_seconds, json.dumps(hb.wake_triggers), int(hb.enabled), hb.goal_id, - json.dumps(hb.required_secrets), hb.decision_prompt, + json.dumps(hb.required_secrets), hb.decision_prompt, hb.handler, now, now, ), ) @@ -103,7 +108,15 @@ def step1_load_identity(agent: str) -> str: agent_file = AGENTS_DIR / f"{agent}.md" if not agent_file.exists(): raise FileNotFoundError(f"Agent file not found: {agent_file}") - return agent_file.read_text(encoding="utf-8") + content = agent_file.read_text(encoding="utf-8") + 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() # ── Step 2: Check approvals (stub) ─────────────────────────────────────────── @@ -129,7 +142,7 @@ def step3_query_inbox(agent: str, conn) -> list: rows = conn.execute( """SELECT id, title, description, priority, status, goal_id, project_id, created_at FROM tickets - WHERE assignee_agent = ? AND status IN ('open','in_progress') + WHERE assignee_agent = ? AND status IN ('open','in_progress','review') AND locked_at IS NULL ORDER BY CASE priority @@ -196,8 +209,28 @@ def step6_assemble_context(identity: str, decision_context: dict, goal_id: str | {decision_context['decision_prompt']}{inbox_summary}{approvals_summary} -Respond concisely. If you decide to work, describe what you are doing. -If you decide to skip, briefly explain why. +--- + +## Sua tarefa — responda em UMA única mensagem + +Decida o que fazer com o item de MAIOR prioridade da sua inbox acima, usando o que +você já sabe sobre ele (título e descrição). NÃO narre ("vou fazer", "primeiro +preciso ler"), NÃO peça para abrir arquivos — DECIDA AGORA e entregue o resultado +na própria resposta. + +- `work` — você consegue entregar/avançar agora: uma decisão, um plano objetivo, + um texto pronto, uma resposta. Coloque o conteúdo/resultado concreto em `result` + e ajuste `new_status`. +- `blocked` — depende de algo que só o Felipe fornece (credencial, acesso, dado, + aprovação, uma decisão dele). Preencha `blocked_reason` e `needs`. +- `skip` — a inbox está vazia ou nada é acionável agora. + +## Responda SOMENTE com este JSON — nada antes, nada depois, tudo em uma linha: + +{{"action": "work"|"skip"|"blocked", "ticket_id": "", "result": "", "new_status": "in_progress"|"review"|"resolved"|null, "blocked_reason": "", "needs": ""}} + +`result` deve dizer o RESULTADO entregue, não "analisei" — ex.: "Plano P0: 3 canais +(WhatsApp, IG, e-mail), oferta X, 1 post/dia; sucesso = 20 leads", não "vou montar o plano". """ # Inject goal chain context (F1.2) if goal_id is set @@ -220,7 +253,74 @@ def step7_invoke_claude( max_turns: int, timeout_seconds: int, ) -> dict: - """Invoke Claude via subprocess with hard timeout. Returns result dict.""" + """Invoke the agent CLI with automatic provider fallback. + + Routes through provider_fallback.invoke_with_fallback so a 429/quota error + on the active provider rotates to the next model/provider in the chain + (ending at native `claude`) instead of failing the whole heartbeat run. + + Disable with HEARTBEAT_PROVIDER_FALLBACK=0 (or if provider_fallback is + unavailable) → falls back to a direct native `claude` call. The returned + dict keeps the same contract step8_persist expects. + """ + use_fallback = os.environ.get("HEARTBEAT_PROVIDER_FALLBACK", "1").lower() not in ( + "0", "false", "no", + ) + if use_fallback: + try: + from provider_fallback import invoke_with_fallback + + result = invoke_with_fallback( + prompt=prompt, + max_turns=max_turns, + timeout_seconds=timeout_seconds, + # The heartbeat prompt already embeds the full agent identity + # from .claude/agents/{agent}.md. Passing --agent here breaks + # OpenClaude-backed providers, which can misparse the expanded + # agent markdown as CLI options. + agent="", + ) + # Preserve the step7 contract; provider_fallback already returns + # status/output/error/duration_ms/tokens_*/cost_usd and adds + # provider_id/model/attempt metadata for observability. + result.setdefault("tokens_in", None) + result.setdefault("tokens_out", None) + result.setdefault("cost_usd", None) + if result.get("status") == "success" and result.get("attempt_number", 0) > 1: + print( + f"[heartbeat_runner] step7 fallback succeeded via " + f"{result.get('provider_id')}:{result.get('model')} " + f"(attempt #{result.get('attempt_number')})", + flush=True, + ) + elif result.get("status") != "success" and result.get("attempt_number", 0) > 1: + print( + f"[heartbeat_runner] step7 fallback exhausted; last " + f"{result.get('provider_id')}:{result.get('model')} " + f"(attempt #{result.get('attempt_number')})", + flush=True, + ) + return result + except Exception as exc: + print( + f"[heartbeat_runner] provider_fallback unavailable ({exc}); " + f"using native claude", + flush=True, + ) + + return _step7_invoke_claude_native(agent, prompt, max_turns, timeout_seconds) + + +def _step7_invoke_claude_native( + agent: str, + prompt: str, + max_turns: int, + timeout_seconds: int, +) -> dict: + """Invoke native `claude` via subprocess with hard timeout. Returns result dict. + + Legacy direct path — used when provider fallback is disabled or unavailable. + """ import shutil claude_bin = shutil.which("claude") @@ -240,6 +340,7 @@ def step7_invoke_claude( "--max-turns", str(max_turns), "--dangerously-skip-permissions", "--output-format", "json", + "--", prompt, # positional argument — Claude CLI does not have a -p flag ] @@ -369,6 +470,170 @@ def step9_release_checkout(task_id: str | None, run_id: str, conn): pass # Table may not exist in F1.1 +# ── Failure alert (runs between persist and release) ────────────────────────── + +def _classify_failure(result: dict) -> str: + """Return a short failure category: timeout | provider_exhausted | auth | unknown.""" + error = (result.get("error") or "").lower() + status = result.get("status", "") + if status == "timeout": + return "timeout" + if result.get("fallback_exhausted"): + return "provider_exhausted" + if any(kw in error for kw in ("401", "403", "unauthorized", "invalid_api_key", "authentication")): + return "auth" + return "unknown" + + +def step_alert_on_failure(heartbeat_id: str, result: dict) -> bool: + """Send a compact alert when a heartbeat run fails. Returns True if sent. + + Alert is sent only for non-success statuses (fail, timeout). + Categories: timeout, provider_exhausted, auth, unknown. + """ + status = result.get("status", "success") + if status == "success": + return False + + category = _classify_failure(result) + agent = result.get("agent", heartbeat_id) + duration_s = "" + if result.get("duration_ms"): + duration_s = f"{result['duration_ms'] / 1000:.1f}s" + + error_preview = "" + if result.get("error"): + error_preview = result["error"][:200].replace("\n", " ") + + # Build compact Telegram message + lines = [ + f"⚠️ Heartbeat Fail", + f"", + f"🔧 {heartbeat_id} | 🤖 {agent}", + f"📌 Status: {status} | 🏷 {category}", + ] + if duration_s: + lines.append(f"⏱ {duration_s}") + if result.get("provider_id"): + lines.append(f"🔗 Provider: {result['provider_id']}:{result.get('model', '?')}") + if result.get("attempt_number"): + lines.append(f"🔄 Attempt #{result['attempt_number']}") + if error_preview: + lines.append(f"") + lines.append(f"📝
{error_preview}
") + + text = "\n".join(lines) + + from notifications import send_telegram_alert + sent = send_telegram_alert(text) + if sent: + print(f"[heartbeat_runner] alert sent for {heartbeat_id} fail ({category})", flush=True) + else: + print(f"[heartbeat_runner] alert NOT sent (Telegram not configured) for {heartbeat_id}", flush=True) + return sent + + +# ── Success report ─────────────────────────────────────────────────────────── + +def _extract_progress_preview(result: dict, limit: int = 3) -> str: + """Extract a short user-facing progress preview from provider output.""" + raw = result.get("result") or result.get("output") or result.get("handler_result") or "" + if isinstance(raw, dict): + raw = json.dumps(raw, ensure_ascii=False) + raw = str(raw).strip() + if not raw: + return "" + if raw.startswith("{"): + try: + data = json.loads(raw) + if isinstance(data, dict): + for key in ("summary", "result", "message", "answer", "progress", "report"): + value = data.get(key) + if isinstance(value, str) and value.strip(): + raw = value.strip() + break + except (json.JSONDecodeError, TypeError): + pass + lines = [line.strip(" -•*") for line in raw.splitlines()] + lines = [line for line in lines if line and not line.lower().startswith(("[fallback]", "stop_reason", "session_id"))] + preview_lines = [] + for line in lines: + line = " ".join(line.split()) + if line and line not in preview_lines: + preview_lines.append(line) + if len(preview_lines) >= limit: + break + return "\n".join(f"• {line}" for line in preview_lines[:limit]) + + +def _step_report_success(heartbeat_id: str, result: dict) -> bool: + """Send a compact Telegram notification when a heartbeat run completes. + + Sent only for status = 'success'. + Returns True if sent, False if not (Telegram not configured, status not success). + """ + status = result.get("status", "fail") + if status != "success": + return False + + # Debounce by heartbeat_id — skip if same heartbeat reported <30s ago + _last_reported = getattr(_step_report_success, "_last_sent", {}) + now = datetime.now(timezone.utc) + last = _last_reported.get(heartbeat_id) + if last and (now - last).total_seconds() < 30: + return False + _last_reported[heartbeat_id] = now + _step_report_success._last_sent = _last_reported # type: ignore[attr-defined] + + agent = result.get("agent", heartbeat_id) + duration_s = "" + if result.get("duration_ms"): + duration_s = f"{result['duration_ms'] / 1000:.1f}s" + + cost_str = "" + if result.get("cost_usd") is not None and result["cost_usd"] > 0: + cost_str = f" | 💰 US${result['cost_usd']:.4f}" + + provider_str = "" + if result.get("provider_id"): + provider_str = f" | 🔗 {result['provider_id']}:{result.get('model', '?')}" + + lines = [ + f"✅ Heartbeat OK", + f"", + f"🔧 {heartbeat_id} | 🤖 {agent}", + f"⏱ {duration_s}{cost_str}{provider_str}", + ] + + progress = _extract_progress_preview(result) + if progress: + lines.extend([ + "", + "📌 Progresso:", + progress[:650], + ]) + else: + lines.extend([ + "", + "📌 Progresso: execução concluída sem saída detalhada.", + ]) + + if result.get("tokens_in") is not None or result.get("tokens_out") is not None: + tok_in = result.get("tokens_in") or 0 + tok_out = result.get("tokens_out") or 0 + lines.append(f"📊 Tokens: {tok_in:,} in / {tok_out:,} out") + + text = "\n".join(lines) + + from notifications import send_telegram_alert + sent = send_telegram_alert(text) + if sent: + print(f"[heartbeat_runner] success report sent for {heartbeat_id}", flush=True) + else: + print(f"[heartbeat_runner] success report NOT sent (Telegram not configured) for {heartbeat_id}", flush=True) + return sent + + # ── System heartbeat dispatcher ─────────────────────────────────────────────── # Map heartbeat_id → Python module (relative to heartbeat_runner.py's directory) @@ -465,13 +730,48 @@ def run_heartbeat(heartbeat_id: str, triggered_by: str = "manual", trigger_id: s task_id = None try: - # Special case: agent='system' heartbeats run a Python script directly - # instead of invoking Claude. The script path is resolved by heartbeat id. - if hb["agent"] == "system": + # Special case: legacy agent='system' heartbeats without explicit handler + # run a Python script directly, resolved by heartbeat id. + # System heartbeats with `handler` use the in-process handler path below. + if hb["agent"] == "system" and not hb.get("handler"): full_prompt = f"[system heartbeat] {heartbeat_id}" result = _run_system_heartbeat(heartbeat_id, hb["timeout_seconds"]) result["agent"] = "system" result["started_at"] = started_at + elif hb.get("handler"): + # In-process handlers do not have/need a Claude agent identity. + _handler_ref = hb.get("handler") or "" + full_prompt = f"[handler heartbeat] {heartbeat_id}" + print(f"[heartbeat_runner] step7 in-process handler={_handler_ref}", flush=True) + import importlib + import time as _time + _t0 = _time.time() + try: + _mod_name, _fn_name = _handler_ref.rsplit(".", 1) + _mod = importlib.import_module(_mod_name) + _fn = getattr(_mod, _fn_name) + _handler_result = _fn() + _duration_ms = round((_time.time() - _t0) * 1000) + result = { + "status": "success", + "error": None, + "agent": hb.get("agent", "system"), + "duration_ms": _duration_ms, + "started_at": started_at, + "handler_result": _handler_result, + } + print(f"[heartbeat_runner] step7 in-process handler done duration_ms={_duration_ms}", flush=True) + except Exception as _h_exc: + import traceback + _duration_ms = round((_time.time() - _t0) * 1000) + result = { + "status": "fail", + "error": traceback.format_exc(), + "agent": hb.get("agent", "system"), + "duration_ms": _duration_ms, + "started_at": started_at, + } + print(f"[heartbeat_runner] step7 in-process handler failed: {_h_exc}", flush=True) else: # Step 1 identity = step1_load_identity(hb["agent"]) @@ -485,6 +785,18 @@ def run_heartbeat(heartbeat_id: str, triggered_by: str = "manual", trigger_id: s inbox = step3_query_inbox(hb["agent"], conn) print(f"[heartbeat_runner] step3 inbox={len(inbox)}", flush=True) + # Cost guard: executor agents don't burn tokens "deciding to skip". + # If there's no assigned work and no pending approvals, skip the + # Claude invocation entirely (silent, ~zero cost). State-monitor + # agents (Linear/Stripe/etc.) opt out via STATE_MONITOR_AGENTS. + if (not inbox and not approvals + and hb["agent"] not in STATE_MONITOR_AGENTS): + print(f"[heartbeat_runner] cost-guard: empty inbox/approvals for {hb['agent']}, skipping without Claude", flush=True) + result = {"status": "success", "error": None, "agent": hb["agent"], + "duration_ms": 0, "output": '{"action":"skip","reason":"no assigned work"}'} + step8_persist(run_id, heartbeat_id, result, trigger_id, triggered_by, "", conn) + return + # Step 4 decision_ctx = step4_pick_priority(identity, approvals, inbox, hb["decision_prompt"]) print(f"[heartbeat_runner] step4 decision context assembled", flush=True) @@ -502,57 +814,18 @@ def run_heartbeat(heartbeat_id: str, triggered_by: str = "manual", trigger_id: s full_prompt = step6_assemble_context(identity, decision_ctx, hb.get("goal_id")) print(f"[heartbeat_runner] step6 prompt assembled ({len(full_prompt)} chars)", flush=True) - # Step 7 — in-process handler OR Claude CLI subprocess - _handler_ref = hb.get("handler") or "" - if _handler_ref: - # Wave 2.2r: in-process Python handler (e.g. plugin_integration_health.tick) - # Format: "module_name.function_name" - print(f"[heartbeat_runner] step7 in-process handler={_handler_ref}", flush=True) - import importlib - import time as _time - _t0 = _time.time() - try: - _mod_name, _fn_name = _handler_ref.rsplit(".", 1) - _mod = importlib.import_module(_mod_name) - _fn = getattr(_mod, _fn_name) - _handler_result = _fn() - _duration_ms = round((_time.time() - _t0) * 1000) - invoke_result = { - "status": "success", - "error": None, - "agent": hb.get("agent", "system"), - "duration_ms": _duration_ms, - "started_at": started_at, - "handler_result": _handler_result, - } - print(f"[heartbeat_runner] step7 in-process handler done duration_ms={_duration_ms}", flush=True) - except Exception as _h_exc: - import traceback - _duration_ms = round((_time.time() - _t0) * 1000) - invoke_result = { - "status": "fail", - "error": traceback.format_exc(), - "agent": hb.get("agent", "system"), - "duration_ms": _duration_ms, - "started_at": started_at, - } - print(f"[heartbeat_runner] step7 in-process handler failed: {_h_exc}", flush=True) - invoke_result["agent"] = hb.get("agent", "system") - invoke_result["started_at"] = started_at - result = invoke_result - else: - # Standard Claude CLI subprocess - print(f"[heartbeat_runner] step7 invoking claude agent={hb['agent']} max_turns={hb['max_turns']} timeout={hb['timeout_seconds']}s", flush=True) - invoke_result = step7_invoke_claude( - agent=hb["agent"], - prompt=full_prompt, - max_turns=hb["max_turns"], - timeout_seconds=hb["timeout_seconds"], - ) - invoke_result["agent"] = hb["agent"] - invoke_result["started_at"] = started_at - result = invoke_result - print(f"[heartbeat_runner] step7 done status={result['status']} duration_ms={result.get('duration_ms')}", flush=True) + # Step 7 — Claude CLI subprocess + print(f"[heartbeat_runner] step7 invoking claude agent={hb['agent']} max_turns={hb['max_turns']} timeout={hb['timeout_seconds']}s", flush=True) + invoke_result = step7_invoke_claude( + agent=hb["agent"], + prompt=full_prompt, + max_turns=hb["max_turns"], + timeout_seconds=hb["timeout_seconds"], + ) + invoke_result["agent"] = hb["agent"] + invoke_result["started_at"] = started_at + result = invoke_result + print(f"[heartbeat_runner] step7 done status={result['status']} duration_ms={result.get('duration_ms')}", flush=True) except Exception as exc: import traceback @@ -569,6 +842,12 @@ def run_heartbeat(heartbeat_id: str, triggered_by: str = "manual", trigger_id: s step8_persist(run_id, heartbeat_id, result, trigger_id, triggered_by, full_prompt, conn) print(f"[heartbeat_runner] step8 persisted run_id={run_id} status={result['status']}", flush=True) + # Notifications — success or failure via centralized module + try: + _send_heartbeat_notification(heartbeat_id, hb["agent"], result, conn) + except Exception as notif_exc: + print(f"[heartbeat_runner] notification error (non-fatal): {notif_exc}", flush=True) + # Step 9 step9_release_checkout(task_id, run_id, conn) print(f"[heartbeat_runner] step9 checkout released", flush=True) @@ -581,6 +860,48 @@ def run_heartbeat(heartbeat_id: str, triggered_by: str = "manual", trigger_id: s return run_id +def _send_heartbeat_notification(heartbeat_id: str, agent: str, result: dict, conn=None): + """Outcome-driven notification + kanban movement. + + Policy (decided with Felipe 2026-06-17): no more "Heartbeat OK". We only + message Telegram when an agent actually advanced/finished a task (the result) + or got blocked and needs intervention. Skips and empty runs stay silent. + See heartbeat_outcome.apply_outcome for the agent JSON contract. + """ + from heartbeat_outcome import apply_outcome + + own_conn = conn is None + if own_conn: + conn = _get_db() + try: + spec = apply_outcome(heartbeat_id, agent, result, conn) + finally: + if own_conn: + conn.close() + + if not spec: + return # silence — nothing worth reporting + + kind = spec.get("kind") + if kind == "result": + from notifications import notify_agent_result + notify_agent_result(spec["agent"], spec.get("ticket_title", ""), + spec.get("new_status", ""), spec.get("summary", "")) + elif kind == "blocked": + from notifications import notify_agent_blocked + notify_agent_blocked(spec["agent"], spec.get("ticket_title", ""), + spec.get("reason", ""), spec.get("needs", ""), + ticket_id=spec.get("ticket_id", "")) + elif kind == "tech_fail": + from notifications import notify_heartbeat_failure + notify_heartbeat_failure( + heartbeat_id=heartbeat_id, agent=agent, + error=spec.get("error", "unknown"), + duration_ms=result.get("duration_ms") or 0, + attempt=result.get("attempt_number", 0), + ) + + def main(): parser = argparse.ArgumentParser(description="Heartbeat Runner — 9-step proactive agent protocol") parser.add_argument("--heartbeat-id", required=True, help="Heartbeat ID (e.g. atlas-4h)") diff --git a/dashboard/backend/heartbeat_schema.py b/dashboard/backend/heartbeat_schema.py index 2a797d0d..0fdbf3ca 100644 --- a/dashboard/backend/heartbeat_schema.py +++ b/dashboard/backend/heartbeat_schema.py @@ -22,14 +22,15 @@ class HeartbeatConfig(BaseModel): id: Annotated[str, Field(min_length=1, max_length=100, pattern=r"^[a-z0-9-]+$")] agent: Annotated[str, Field(min_length=1, max_length=100)] interval_seconds: Annotated[int, Field(ge=60)] - max_turns: Annotated[int, Field(ge=1, le=100)] = 10 + max_turns: Annotated[int, Field(ge=0, le=100)] = 10 timeout_seconds: Annotated[int, Field(ge=30, le=3600)] = 600 lock_timeout_seconds: Annotated[int, Field(ge=60)] = 1800 wake_triggers: Annotated[List[WakeTrigger], Field(min_length=1)] enabled: bool = False goal_id: Optional[str] = None required_secrets: List[str] = Field(default_factory=list) - decision_prompt: Annotated[str, Field(min_length=20)] + decision_prompt: str + handler: Optional[str] = None source_plugin: Optional[str] = None # AC4: set to plugin slug for plugin-contributed heartbeats @field_validator("agent") @@ -70,6 +71,18 @@ def triggers_must_be_valid(cls, v: list) -> list: @model_validator(mode="after") def interval_trigger_requires_interval_field(self) -> "HeartbeatConfig": + if self.handler: + if self.max_turns != 0: + raise ValueError("Heartbeats with handler must set max_turns=0") + return self + + if self.max_turns < 1: + raise ValueError("Claude heartbeats without handler must set max_turns >= 1") + if len(self.decision_prompt.strip()) < 20: + raise ValueError( + "Claude heartbeats without handler must provide decision_prompt " + "with at least 20 characters" + ) return self diff --git a/dashboard/backend/models.py b/dashboard/backend/models.py index a151bcd8..e3a9be64 100644 --- a/dashboard/backend/models.py +++ b/dashboard/backend/models.py @@ -1222,3 +1222,41 @@ def to_dict(self) -> dict: "detail": json.loads(self.detail_json or "{}"), "created_at": self.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ") if self.created_at else None, } + + +class SocialPost(db.Model): + """Queue of social media posts waiting to be published. + + Status flow: pending → publishing → published | failed | cancelled + """ + + __tablename__ = "social_posts" + + id = db.Column(db.Integer, primary_key=True) + platform = db.Column(db.String(20), nullable=False, default="twitter") # twitter, linkedin, etc. + account_index = db.Column(db.Integer, nullable=False, default=1) # SOCIAL_TWITTER_ + text = db.Column(db.Text, nullable=False) + media_path = db.Column(db.String(500), nullable=True) # local file path for attached image + status = db.Column(db.String(20), nullable=False, default="pending") + scheduled_at = db.Column(db.DateTime, nullable=True) # NULL = publish immediately + published_at = db.Column(db.DateTime, nullable=True) + error = db.Column(db.Text, nullable=True) + external_id = db.Column(db.String(100), nullable=True) # tweet id, etc. + created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) + retry_count = db.Column(db.Integer, nullable=False, default=0) + + def to_dict(self) -> dict: + return { + "id": self.id, + "platform": self.platform, + "account_index": self.account_index, + "text": self.text, + "media_path": self.media_path, + "status": self.status, + "scheduled_at": self.scheduled_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ") if self.scheduled_at else None, + "published_at": self.published_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ") if self.published_at else None, + "error": self.error, + "external_id": self.external_id, + "created_at": self.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ") if self.created_at else None, + "retry_count": self.retry_count, + } diff --git a/dashboard/backend/nexus_orchestrator.py b/dashboard/backend/nexus_orchestrator.py new file mode 100644 index 00000000..66dba21a --- /dev/null +++ b/dashboard/backend/nexus_orchestrator.py @@ -0,0 +1,138 @@ +"""Nexus orchestrator — "cada agente na sua hora, só com task real". + +Replaces the old model (18 autopilot heartbeats firing every 15 min, in +parallel, burning tokens to decide "skip"). This is a cheap Python handler +(no LLM) that runs on a heartbeat interval and: + + 1. Looks at the real queue — open/in_progress tickets assigned to an agent, + not locked, highest priority first, preferring those tied to active goals. + 2. Picks ONE and dispatches that agent's heartbeat in the background. + 3. Does nothing (zero cost) when the queue is empty. + +So agents only spend tokens when there's actual work, and they run one at a +time instead of stampeding. The agent run itself moves the kanban and reports +the result/blocker (see heartbeat_outcome + heartbeat_runner). + +Tunables (env): + ORCHESTRATOR_DRY_RUN=1 → log what it would dispatch, don't run the agent + ORCHESTRATOR_MAX_INFLIGHT → max concurrent agent runs it will start (default 1) +""" + +from __future__ import annotations + +import os +import sqlite3 +import threading +from pathlib import Path + +WORKSPACE = Path(__file__).resolve().parents[2] +DB_PATH = WORKSPACE / "dashboard" / "data" / "evonexus.db" + +# Agents the orchestrator will dispatch. State monitors (atlas/flux) keep their +# own scheduled heartbeats and are not driven by the queue. +_EXCLUDE_AGENTS = {"system"} + +# Track in-flight runs started by the orchestrator (process-local). +_inflight: set[str] = set() +_inflight_lock = threading.Lock() + + +def _get_db() -> sqlite3.Connection: + conn = sqlite3.connect(str(DB_PATH), timeout=30) + conn.row_factory = sqlite3.Row + return conn + + +def _next_ticket(conn) -> sqlite3.Row | None: + """Highest-priority actionable ticket assigned to an agent. + + Prefers tickets linked to an active goal, then priority_rank, then age. + """ + # Advance the board: 'open' first, then 'review', then 'in_progress' (push them + # toward resolved). Anti-loop guard: skip tickets touched in the last 15 min, so + # a ticket that stays in_progress isn't hammered every cycle. 'blocked' is never + # auto-processed — it waits for the human. + return conn.execute( + """ + SELECT t.id, t.title, t.assignee_agent, t.priority, t.goal_id + FROM tickets t + LEFT JOIN goals g ON g.id = t.goal_id + WHERE t.status IN ('open', 'review', 'in_progress') + AND t.locked_at IS NULL + AND t.assignee_agent IS NOT NULL + AND t.assignee_agent NOT IN ('system') + AND (t.status = 'open' + OR t.updated_at IS NULL + OR t.updated_at < datetime('now', '-15 minutes')) + ORDER BY + CASE WHEN g.status = 'active' THEN 0 ELSE 1 END, + CASE t.status WHEN 'open' THEN 0 WHEN 'review' THEN 1 ELSE 2 END, + COALESCE(t.priority_rank, 0) DESC, + t.created_at ASC + LIMIT 1 + """ + ).fetchone() + + +def _heartbeat_id_for_agent(conn, agent: str) -> str | None: + """Find a heartbeat to run for this agent (prefer the autopilot one).""" + row = conn.execute( + "SELECT id FROM heartbeats WHERE agent = ? " + "ORDER BY CASE WHEN id LIKE 'autopilot-%' THEN 0 ELSE 1 END LIMIT 1", + (agent,), + ).fetchone() + return row["id"] if row else None + + +def _dispatch_agent(heartbeat_id: str) -> None: + """Run the agent heartbeat in a background thread, releasing inflight after.""" + def _run(): + try: + from heartbeat_runner import run_heartbeat + run_heartbeat(heartbeat_id, triggered_by="orchestrator") + except Exception as exc: # noqa: BLE001 + print(f"[orchestrator] run failed for {heartbeat_id}: {exc}", flush=True) + finally: + with _inflight_lock: + _inflight.discard(heartbeat_id) + + threading.Thread(target=_run, name=f"orch-{heartbeat_id}", daemon=True).start() + + +def tick() -> dict: + """One orchestration cycle. Cheap; safe to call frequently.""" + max_inflight = int(os.environ.get("ORCHESTRATOR_MAX_INFLIGHT", "1")) + dry_run = os.environ.get("ORCHESTRATOR_DRY_RUN", "0").lower() in ("1", "true", "yes") + + with _inflight_lock: + inflight_now = len(_inflight) + if inflight_now >= max_inflight: + return {"dispatched": None, "reason": f"max_inflight reached ({inflight_now})"} + + conn = _get_db() + try: + ticket = _next_ticket(conn) + if not ticket: + return {"dispatched": None, "reason": "queue empty"} + + agent = ticket["assignee_agent"] + hb_id = _heartbeat_id_for_agent(conn, agent) + if not hb_id: + return {"dispatched": None, "reason": f"no heartbeat for agent {agent}", + "ticket": ticket["id"]} + + with _inflight_lock: + if hb_id in _inflight: + return {"dispatched": None, "reason": f"{hb_id} already running"} + if not dry_run: + _inflight.add(hb_id) + + if dry_run: + return {"dispatched": None, "dry_run": True, "would_dispatch": hb_id, + "agent": agent, "ticket": ticket["id"], "title": ticket["title"]} + + _dispatch_agent(hb_id) + return {"dispatched": hb_id, "agent": agent, "ticket": ticket["id"], + "title": ticket["title"]} + finally: + conn.close() diff --git a/dashboard/backend/notifications.py b/dashboard/backend/notifications.py new file mode 100644 index 00000000..9dbf45e2 --- /dev/null +++ b/dashboard/backend/notifications.py @@ -0,0 +1,306 @@ +#!/usr/bin/env python3 +""" +notifications.py — Canal único de feedback pro Telegram. + +Centraliza TODAS as notificações positivas e negativas do EvoNexus. +Qualquer módulo pode chamar: + from notifications import notify_success, notify_failure, notify_info + +Estratégia de debounce por categoria: +- Heartbeat success: 30s por heartbeat_id (evita spam de 15min interval) +- Task completion: 60s por task_id +- Rotina: 120s por rotina +- Aprovação pendente: sem debounce (sempre notifica) +- Relatório horário: sem debounce +""" +from __future__ import annotations + +import json +import os +import time +import urllib.parse +import urllib.request +from datetime import datetime, timezone +from pathlib import Path + +WORKSPACE = Path(__file__).resolve().parent.parent.parent + +# ── Load .env so TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID are available ── +_env_file = WORKSPACE / ".env" +if _env_file.exists(): + for _line in _env_file.read_text(encoding="utf-8").splitlines(): + _line = _line.strip() + if _line and not _line.startswith("#") and "=" in _line: + _k, _v = _line.split("=", 1) + os.environ.setdefault(_k.strip(), _v.strip()) + +# ── Debounce state ────────────────────────────────────────────────────────── +# In-memory only — resets on restart. Good enough for debounce within a session. +_debounce_state: dict[str, float] = {} +DEFAULT_DEBOUNCE = { + "heartbeat_success": 30, + "heartbeat_failure": 900, # 15min por heartbeat — evita spam em falhas recorrentes + "task_success": 60, + "task_failure": 0, + "routine_success": 120, + "routine_failure": 0, + "approval_pending": 0, # always notify + "hourly_report": 0, # always notify + "agent_event": 60, +} + + +def _should_send(category: str, key: str) -> bool: + """Check debounce. Returns True if should send.""" + debounce_secs = DEFAULT_DEBOUNCE.get(category, 0) + if debounce_secs == 0: + return True + now = time.time() + full_key = f"{category}:{key}" + last = _debounce_state.get(full_key, 0) + if now - last >= debounce_secs: + _debounce_state[full_key] = now + return True + return False + + +def _send_telegram(text: str) -> bool: + """Send Telegram message. Returns True on success.""" + token = os.environ.get("TELEGRAM_BOT_TOKEN", "") + cid = os.environ.get("TELEGRAM_CHAT_ID", "") + if not token or not cid: + return False + try: + payload = urllib.parse.urlencode({ + "chat_id": cid, + "text": text, + "parse_mode": "HTML", + "disable_web_page_preview": True, + }).encode() + url = f"https://api.telegram.org/bot{token}/sendMessage" + req = urllib.request.Request(url, data=payload, method="POST") + with urllib.request.urlopen(req, timeout=15) as resp: + return resp.status == 200 + except Exception: + return False + + +def send_telegram_alert(text: str) -> bool: + """Send a preformatted Telegram alert. + + Used by heartbeat_runner and other modules that already build the final + HTML message. Returns False only when Telegram credentials are missing or + Telegram rejects/fails the request. + """ + return _send_telegram(text) + + +def send_whatsapp(text: str, phone: str) -> bool: + """Send a WhatsApp message via Evolution Go API. + + Reads EVOLUTION_GO_URL and EVOLUTION_GO_KEY from environment (.env). + Returns True if sent, False on any failure. + """ + import json + import urllib.request + import urllib.error + + url = os.environ.get("EVOLUTION_GO_URL", "").rstrip("/") + key = os.environ.get("EVOLUTION_GO_KEY", "") + if not url or not key: + return False + try: + payload = json.dumps({"number": phone, "text": text}).encode("utf-8") + req = urllib.request.Request( + f"{url}/message/sendText/nature", + data=payload, + method="POST", + headers={"apikey": key, "Content-Type": "application/json"}, + ) + with urllib.request.urlopen(req, timeout=15) as resp: + return resp.status in (200, 201) + except Exception: + return False + + +def _esc(s: str) -> str: + """Escape HTML special chars for Telegram parse_mode=HTML.""" + return (s or "").replace("&", "&").replace("<", "<").replace(">", ">") + + +def notify_agent_result(agent: str, ticket_title: str, new_status: str, + summary: str) -> bool: + """Outcome-driven: an agent advanced/finished a task. Report the RESULT. + + This is the signal Felipe wants — what got done, not that a heartbeat ran. + """ + status_emoji = "✅" if new_status in ("resolved", "closed") else "🟢" + status_label = { + "resolved": "concluída", "closed": "fechada", "review": "em revisão", + "in_progress": "em andamento", "blocked": "bloqueada", + }.get(new_status, new_status) + title = f" · {_esc(ticket_title)}" if ticket_title else "" + text = ( + f"{status_emoji} {_esc(agent)}{title}\n" + f"➡️ {status_label}\n\n" + f"{_esc(summary[:600])}" + ) + return _send_telegram(text) + + +def notify_agent_blocked(agent: str, ticket_title: str, reason: str, + needs: str = "", ticket_id: str = "") -> bool: + """Outcome-driven: an agent is blocked and needs Felipe to intervene. + + The ticket id is embedded as a marker so a Telegram REPLY to this message is + routed back to the ticket (telegram_provider_bot unblocks it with the reply). + """ + title = f" · {_esc(ticket_title)}" if ticket_title else "" + needs_line = f"\n\n🙋 Preciso de você: {_esc(needs[:300])}" if needs else "" + hint = "\n\nResponda (reply) esta mensagem para desbloquear." if ticket_id else "" + marker = f"\n#tkt:{ticket_id}" if ticket_id else "" + text = ( + f"🔴 {_esc(agent)} bloqueado{title}\n\n" + f"{_esc(reason[:400])}" + f"{needs_line}" + f"{hint}" + f"{marker}" + ) + return _send_telegram(text) + + +def _now() -> str: + return datetime.now(timezone.utc).strftime("%H:%M UTC") + + +# ── Public API ────────────────────────────────────────────────────────────── + +def notify_heartbeat_success(heartbeat_id: str, agent: str, duration_ms: int, + model: str = "", cost_usd: float = 0, + tokens_in: int = 0, tokens_out: int = 0) -> bool: + """Report heartbeat success to Telegram.""" + if not _should_send("heartbeat_success", heartbeat_id): + return False + + dur = f"{duration_ms / 1000:.1f}s" if duration_ms else "?" + cost = f" | 💰 ${cost_usd:.4f}" if cost_usd and cost_usd > 0 else "" + model_str = f" | 🔗 {model}" if model else "" + tok = "" + if tokens_in and tokens_out: + tok = f"\n📊 {tokens_in:,} in / {tokens_out:,} out" + + text = ( + f"✅ Heartbeat OK\n\n" + f"🔧 {heartbeat_id} | 🤖 {agent}\n" + f"⏱ {dur}{cost}{model_str}{tok}" + ) + return _send_telegram(text) + + +def notify_heartbeat_failure(heartbeat_id: str, agent: str, error: str, + duration_ms: int = 0, attempt: int = 0) -> bool: + """Report heartbeat failure to Telegram.""" + if not _should_send("heartbeat_failure", heartbeat_id): + return False + + dur = f"{duration_ms / 1000:.1f}s" if duration_ms else "?" + att = f" | 🔄 attempt #{attempt}" if attempt > 1 else "" + err = error[:200].replace("\n", " ") if error else "unknown" + + text = ( + f"⚠️ Heartbeat FAIL\n\n" + f"🔧 {heartbeat_id} | 🤖 {agent}\n" + f"⏱ {dur}{att}\n" + f"📝 {err}" + ) + return _send_telegram(text) + + +def notify_task_success(task_name: str, task_type: str = "", + result_preview: str = "") -> bool: + """Report task completion to Telegram.""" + if not _should_send("task_success", task_name): + return False + + preview = f"\n📋 {result_preview[:150]}" if result_preview else "" + text = ( + f"✅ Task Completa\n\n" + f"📌 {task_name} ({task_type}){preview}" + ) + return _send_telegram(text) + + +def notify_task_failure(task_name: str, error: str) -> bool: + """Report task failure to Telegram.""" + if not _should_send("task_failure", f"{task_name}:{int(time.time())}"): + return False + + text = ( + f"❌ Task Falhou\n\n" + f"📌 {task_name}\n" + f"📝 {error[:200]}" + ) + return _send_telegram(text) + + +def notify_routine_success(routine_name: str, duration_s: float = 0) -> bool: + """Report routine completion to Telegram.""" + if not _should_send("routine_success", routine_name): + return False + + dur = f"⏱ {duration_s:.0f}s" if duration_s > 0 else "" + text = ( + f"✅ Rotina OK\n\n" + f"🔄 {routine_name} {dur}" + ) + return _send_telegram(text) + + +def notify_approval_pending(item_name: str, item_type: str = "post", + details: str = "", approve_cmd: str = "", + reject_cmd: str = "") -> bool: + """Report pending approval — ALWAYS sends (no debounce).""" + text = ( + f"🔔 Aprovação Pendente\n\n" + f"📝 {item_name} ({item_type})\n" + ) + if details: + text += f"📋 {details[:300]}\n" + text += f"\n" + if approve_cmd: + text += f"✅ {approve_cmd}\n" + if reject_cmd: + text += f"❌ {reject_cmd}\n" + if not approve_cmd and not reject_cmd: + text += f"\nResponda com o ID para aprovar/reprovar" + + return _send_telegram(text) + + +def notify_hourly_report(report_text: str) -> bool: + """Send hourly activity report — ALWAYS sends.""" + text = f"📊 Relatório Horário\n\n{report_text}" + return _send_telegram(text) + + +def notify_agent_event(agent: str, event: str, details: str = "") -> bool: + """Report agent events (started, completed action, etc).""" + if not _should_send("agent_event", f"{agent}:{event}"): + return False + + text = ( + f"🤖 {agent}\n" + f"📌 {event}\n" + ) + if details: + text += f"📋 {details[:200]}" + + return _send_telegram(text) + + +def notify_info(title: str, message: str) -> bool: + """Generic info notification.""" + if not _should_send("info", f"{title}:{int(time.time())}"): + return False + text = f"ℹ️ {title}\n\n{message[:400]}" + return _send_telegram(text) diff --git a/dashboard/backend/plugin_integration_health.py b/dashboard/backend/plugin_integration_health.py index decec246..14c93f80 100644 --- a/dashboard/backend/plugin_integration_health.py +++ b/dashboard/backend/plugin_integration_health.py @@ -18,7 +18,7 @@ WORKSPACE = Path(__file__).resolve().parent.parent.parent PLUGINS_DIR = WORKSPACE / "plugins" -DB_PATH = WORKSPACE / "dashboard" / "data" / "dashboard.db" +DB_PATH = WORKSPACE / "dashboard" / "data" / "evonexus.db" # Hard cap on HTTP timeout regardless of what the manifest declares (ADR decision 4) _MAX_TIMEOUT_SECONDS = 10 diff --git a/dashboard/backend/plugin_schema.py b/dashboard/backend/plugin_schema.py index 1c30bf31..7f49f1e3 100644 --- a/dashboard/backend/plugin_schema.py +++ b/dashboard/backend/plugin_schema.py @@ -62,6 +62,10 @@ class Capability(str, Enum): # Wave 2.1 — full-screen plugin UI pages + writable data ui_pages = "ui_pages" writable_data = "writable_data" + # B2.0 — unauthenticated public pages served by the host (token-bound) + public_pages = "public_pages" + # B3 — safe uninstall with data preservation and 3-step wizard + safe_uninstall = "safe_uninstall" class PluginMcpServer(BaseModel): @@ -303,6 +307,13 @@ class ReadonlyQuery(BaseModel): id: Annotated[str, Field(min_length=1, max_length=100)] description: Annotated[str, Field(min_length=1, max_length=500)] sql: Annotated[str, Field(min_length=1)] + # B2.0: expose this query on the public portal without host auth. + # Value is the PluginPublicPage.id that gates access. + public_via: Optional[str] = None + # B2.0: named SQL parameter in ``sql`` that receives the URL token value. + # Required when public_via is set. The parameter must appear in ``sql`` + # as :token_param (e.g. ``WHERE magic_link_token = :token``). + bind_token_param: Optional[str] = None @field_validator("id") @classmethod @@ -566,6 +577,14 @@ class PluginWritableResource(BaseModel): ) # Optional JSON Schema for payload validation (jsonschema library) json_schema: Optional[WritableResourceJsonSchema] = None + # Wave 2.1.x: optional endpoint-level RBAC. When set, only authenticated + # users whose ``current_user.role`` is in this list may POST/PUT/DELETE this + # resource. Empty/None means any authenticated user passes (legacy default). + # Role 'admin' always passes regardless of the list (super-user override). + # Plugins use this to gate writable resources by role without needing a host + # PR or app-layer wrapper. See evonexus-plugin-nutri for split-endpoint + # patterns (patients_admin vs patients_clinical). + requires_role: Optional[List[Annotated[str, Field(min_length=1, max_length=64)]]] = None @field_validator("id") @classmethod @@ -585,6 +604,232 @@ def table_pattern(cls, v: str) -> str: ) return v + @field_validator("requires_role") + @classmethod + def requires_role_pattern(cls, v: Optional[List[str]]) -> Optional[List[str]]: + if v is None: + return v + for role in v: + if not re.match(r"^[a-z][a-z0-9-]*$", role): + raise ValueError( + f"requires_role entry '{role}' must match ^[a-z][a-z0-9-]*$ (kebab-case)" + ) + return v + + +class PluginPublicPageTokenSource(BaseModel): + """Token source declaration for a public page (B2.0). + + The host validates the incoming token against ``column`` in ``table`` + using a parametric query. Table must be slug-prefixed (enforced by the + PluginManifest validator ``public_pages_tables_slug_prefixed``). + + B2.0 v1 deliberately does NOT support a ``revoked_when`` SQL fragment to + prevent SQL injection. Revocation is the plugin's responsibility: nulling + or rotating the token column value causes the next request to 404. + """ + + # Plugin-owned table containing the token column (validated slug-prefixed) + table: Annotated[str, Field(min_length=1, max_length=200)] + # Column in ``table`` that holds the token value + column: Annotated[str, Field(min_length=1, max_length=100)] + + @field_validator("table") + @classmethod + def table_identifier(cls, v: str) -> str: + if not re.match(r"^[a-z][a-z0-9_]*$", v): + raise ValueError( + f"token_source.table '{v}' must match ^[a-z][a-z0-9_]*$" + ) + return v + + @field_validator("column") + @classmethod + def column_identifier(cls, v: str) -> str: + if not re.match(r"^[a-z][a-z0-9_]*$", v): + raise ValueError( + f"token_source.column '{v}' must match ^[a-z][a-z0-9_]*$" + ) + return v + + +class PluginPublicPage(BaseModel): + """A public (unauthenticated) page declared in plugin.yaml under public_pages (B2.0). + + The host registers ``/p/{slug}/{route_prefix}/{token}`` as a public route + and validates the token against ``token_source.column`` in ``token_source.table`` + on every request. Only B2.0 (read-only, no PIN) is supported in v1. + B2.1 (PIN + writable + auto_set_columns) is deferred. + """ + + # Unique identifier within this plugin's public_pages list + id: Annotated[str, Field(min_length=1, max_length=100)] + # Human-readable label for audit logs and admin UI + description: Annotated[str, Field(min_length=1, max_length=500)] + # URL prefix segment, without leading/trailing slashes (e.g. "portal") + route_prefix: Annotated[str, Field(min_length=1, max_length=100)] + # Token source — which plugin table/column the URL token is validated against + token_source: PluginPublicPageTokenSource + # Plugin JS bundle path (must be under ui/public/) + bundle: Annotated[str, Field(min_length=1, max_length=500)] + # Web component tag name registered by the bundle + custom_element_name: Annotated[str, Field(min_length=1, max_length=200)] + # auth_mode: only "token" is supported in B2.0 (B2.1 will add "pin") + auth_mode: Literal["token"] = "token" + # Rate limit override per page (requests/minute/IP); defaults to global limiter + rate_limit_per_ip: Optional[int] = None + # Optional action name to write to the audit log on each page view + audit_action: Optional[str] = None + + @field_validator("id") + @classmethod + def id_pattern(cls, v: str) -> str: + if not re.match(r"^[a-z0-9_]+$", v): + raise ValueError(f"PluginPublicPage id '{v}' must match ^[a-z0-9_]+$") + return v + + @field_validator("route_prefix") + @classmethod + def route_prefix_clean(cls, v: str) -> str: + """No leading/trailing slashes; only lowercase alphanum + hyphens.""" + v = v.strip("/") + if not re.match(r"^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$", v): + raise ValueError( + f"route_prefix '{v}' must be lowercase alphanum+hyphens, no slashes" + ) + return v + + @field_validator("bundle") + @classmethod + def bundle_in_public_subtree(cls, v: str) -> str: + """Bundle must live under ui/public/ to prevent leaking authenticated bundles.""" + if not v.startswith("ui/public/"): + raise ValueError( + f"PluginPublicPage bundle '{v}' must start with 'ui/public/' " + "(authenticated ui_pages bundles are not accessible from public routes)." + ) + ext = Path(v).suffix.lower() + if ext not in {".js", ".mjs"}: + raise ValueError( + f"PluginPublicPage bundle '{v}' must have a .js or .mjs extension." + ) + return v + + @field_validator("custom_element_name") + @classmethod + def custom_element_name_has_hyphen(cls, v: str) -> str: + if "-" not in v: + raise ValueError( + f"custom_element_name '{v}' must contain at least one hyphen " + "(Web Components specification requirement)." + ) + return v + + @field_validator("rate_limit_per_ip") + @classmethod + def rate_limit_positive(cls, v: Optional[int]) -> Optional[int]: + if v is not None and v < 1: + raise ValueError("rate_limit_per_ip must be a positive integer") + return v + + +class PluginPreUninstallHook(BaseModel): + """Pre-uninstall hook declaration (B3 safe_uninstall). + + Executed as a sandboxed subprocess before the uninstall wizard proceeds. + The hook must produce a file in ``output_dir`` when ``must_produce_file`` + is true — if it does not, the uninstall is blocked. + """ + + # Relative path to the hook script inside the plugin directory + script: Annotated[str, Field(min_length=1, max_length=500)] + # Output directory pattern (supports {slug} and {timestamp} interpolation) + output_dir: Annotated[str, Field(min_length=1, max_length=500)] + # Seconds before the subprocess is killed (max 600) + timeout_seconds: int = 600 + # If true, uninstall is blocked when the hook exits cleanly but produces no file + must_produce_file: bool = True + + @field_validator("script") + @classmethod + def script_relative(cls, v: str) -> str: + if v.startswith("/") or ".." in v: + raise ValueError( + f"pre_uninstall_hook.script '{v}' must be relative and must not traverse upward" + ) + return v + + @field_validator("timeout_seconds") + @classmethod + def timeout_in_range(cls, v: int) -> int: + if not 1 <= v <= 600: + raise ValueError("timeout_seconds must be between 1 and 600") + return v + + +class PluginUserConfirmation(BaseModel): + """User confirmation gate for safe_uninstall (B3). + + Defines the checkbox label and the exact phrase the user must type + to enable the Uninstall button. Phrase matching is case-sensitive. + """ + + checkbox_label: Annotated[str, Field(min_length=1, max_length=1000)] + typed_phrase: Annotated[str, Field(min_length=1, max_length=200)] + + +class PluginSafeUninstall(BaseModel): + """Safe uninstall declaration for plugins holding regulated data (B3). + + When ``enabled`` is true the host enforces: + 1. A 3-step wizard (pre-hook → checkbox → typed phrase + ZIP password). + 2. Preserved tables are NOT dropped and are renamed ``_orphan_{slug}_{table}``. + 3. Host-entity cascades respect ``preserved_host_entities`` filters. + 4. Reinstall detects orphaned tables and restores access after SHA256 verify. + + Plugins not declaring this block continue to use the default cascade-DELETE. + """ + + enabled: bool = False + # Human-readable regulatory reason shown to the admin before they confirm + reason: Optional[str] = None + # Pre-uninstall hook run before the wizard + pre_uninstall_hook: Optional[PluginPreUninstallHook] = None + # Checkbox + typed phrase gate + user_confirmation: Optional[PluginUserConfirmation] = None + # Tables that must NOT be dropped on uninstall (renamed to _orphan_{slug}_{table}) + preserved_tables: List[str] = Field(default_factory=list) + # Host-managed entity classes to partially preserve (table → WHERE clause EXCLUDING rows to delete) + # Dict mapping host table name to a SQL WHERE expression for rows that SHOULD be preserved. + # e.g. {"tickets": "source_plugin = 'nutri' AND linked_resource LIKE 'nutri_patients/%'"} + preserved_host_entities: Dict[str, str] = Field(default_factory=dict) + # If true, Uninstall button is completely disabled in the UI (for active audit windows, etc.) + block_uninstall: bool = False + + @field_validator("preserved_tables") + @classmethod + def table_names_identifier(cls, v: List[str]) -> List[str]: + for name in v: + if not re.match(r"^[a-z][a-z0-9_]*$", name): + raise ValueError( + f"preserved_tables entry '{name}' must match ^[a-z][a-z0-9_]*$" + ) + return v + + @field_validator("preserved_host_entities") + @classmethod + def host_entity_tables_known(cls, v: Dict[str, str]) -> Dict[str, str]: + _ALLOWED_HOST_TABLES = frozenset({ + "triggers", "tickets", "goal_tasks", "goals", "projects", "missions" + }) + for table in v: + if table not in _ALLOWED_HOST_TABLES: + raise ValueError( + f"preserved_host_entities key '{table}' is not a known host entity table. " + f"Allowed: {sorted(_ALLOWED_HOST_TABLES)}" + ) + return v + class PluginUIEntryPoints(BaseModel): """Typed container for ui_entry_points in plugin.yaml (Wave 2.1). @@ -655,6 +900,16 @@ class PluginManifest(BaseModel): # env_vars_needed is kept as deprecated warning-only for backwards compatibility. integrations: Optional[List["PluginIntegration"]] = None + # --- B2.0: Public pages (unauthenticated, token-bound) --- + # Declared under public_pages: in plugin.yaml. + # Requires Capability.public_pages in capabilities list. + public_pages: Optional[List[PluginPublicPage]] = None + + # --- B3: Safe uninstall with data preservation --- + # Declared under safe_uninstall: in plugin.yaml. + # Requires Capability.safe_uninstall in capabilities list. + safe_uninstall: Optional[PluginSafeUninstall] = None + @field_validator("id") @classmethod def slug_pattern(cls, v: str) -> str: @@ -802,6 +1057,133 @@ def pages_bundle_paths_unique(self) -> "PluginManifest": return self + @model_validator(mode="after") + def safe_uninstall_requires_capability(self) -> "PluginManifest": + """B3: safe_uninstall block requires Capability.safe_uninstall in capabilities.""" + if self.safe_uninstall and Capability.safe_uninstall not in self.capabilities: + raise ValueError( + "safe_uninstall is declared but Capability.safe_uninstall is missing " + "from capabilities list." + ) + return self + + @model_validator(mode="after") + def safe_uninstall_preserved_tables_slug_prefixed(self) -> "PluginManifest": + """B3: preserved_tables must start with {slug_under}.""" + if not self.safe_uninstall or not self.safe_uninstall.preserved_tables: + return self + slug_under = self.id.replace("-", "_") + "_" + for table in self.safe_uninstall.preserved_tables: + if not table.lower().startswith(slug_under): + raise ValueError( + f"safe_uninstall.preserved_tables entry '{table}' does not start " + f"with required prefix '{slug_under}'. " + "Preserved tables must be plugin-owned." + ) + return self + + @model_validator(mode="after") + def safe_uninstall_enabled_requires_confirmation(self) -> "PluginManifest": + """B3: if safe_uninstall.enabled is true, user_confirmation is required.""" + su = self.safe_uninstall + if su and su.enabled and not su.block_uninstall and not su.user_confirmation: + raise ValueError( + "safe_uninstall.enabled is true but user_confirmation is not declared. " + "Admin must confirm with a checkbox + typed phrase." + ) + return self + + @model_validator(mode="after") + def readonly_data_no_orphan_table_references(self) -> "PluginManifest": + """Vault B3.S4: readonly_data SQL must not reference _orphan_* tables. + + Orphan tables are renamed on uninstall to prevent hostile reinstall from + accessing them via readonly_data declarations. + """ + if not self.readonly_data: + return self + _TABLE_RE = re.compile( + r"\b(?:FROM|JOIN)\s+([a-zA-Z_][a-zA-Z0-9_]*)", + re.IGNORECASE, + ) + for query in self.readonly_data: + tables = _TABLE_RE.findall(query.sql) + for table in tables: + if table.lower().startswith("_orphan_"): + raise ValueError( + f"ReadonlyQuery '{query.id}' references orphan table '{table}'. " + "Queries must not reference _orphan_* tables — these are preserved " + "from a previous uninstall and are inaccessible under the plugin namespace." + ) + return self + + @model_validator(mode="after") + def public_pages_require_capability(self) -> "PluginManifest": + """B2.0: public_pages block requires Capability.public_pages in capabilities.""" + if self.public_pages and Capability.public_pages not in self.capabilities: + raise ValueError( + "public_pages is declared but Capability.public_pages is missing " + "from capabilities list." + ) + return self + + @model_validator(mode="after") + def public_pages_tables_slug_prefixed(self) -> "PluginManifest": + """B2.0: token_source.table must start with {slug_under} (same guard as readonly/writable).""" + if not self.public_pages: + return self + slug_under = self.id.replace("-", "_") + "_" + for page in self.public_pages: + table = page.token_source.table + if not table.lower().startswith(slug_under): + raise ValueError( + f"PluginPublicPage '{page.id}' token_source.table '{table}' " + f"does not start with required prefix '{slug_under}'. " + "Public page token sources must only reference the plugin's own tables." + ) + return self + + @model_validator(mode="after") + def public_pages_ids_unique(self) -> "PluginManifest": + """B2.0: public page ids and route_prefixes must be unique within a plugin.""" + if not self.public_pages: + return self + seen_ids: set[str] = set() + seen_prefixes: set[str] = set() + for page in self.public_pages: + if page.id in seen_ids: + raise ValueError( + f"Duplicate PluginPublicPage id '{page.id}' in public_pages." + ) + if page.route_prefix in seen_prefixes: + raise ValueError( + f"Duplicate PluginPublicPage route_prefix '{page.route_prefix}' in public_pages." + ) + seen_ids.add(page.id) + seen_prefixes.add(page.route_prefix) + return self + + @model_validator(mode="after") + def readonly_public_via_references_valid_page(self) -> "PluginManifest": + """B2.0: readonly_data[].public_via must reference a declared public_pages[].id.""" + has_public_via = [q for q in self.readonly_data if q.public_via] + if not has_public_via: + return self + page_ids = {p.id for p in (self.public_pages or [])} + for query in has_public_via: + if query.public_via not in page_ids: + raise ValueError( + f"ReadonlyQuery '{query.id}' references public_via='{query.public_via}' " + "which is not declared in public_pages." + ) + if not query.bind_token_param: + raise ValueError( + f"ReadonlyQuery '{query.id}' has public_via set but bind_token_param " + "is missing. The query must declare which SQL parameter receives the token." + ) + return self + + def load_plugin_manifest(plugin_dir: Path) -> PluginManifest: """Load and validate plugin.yaml from a plugin directory. diff --git a/dashboard/backend/provider_fallback.py b/dashboard/backend/provider_fallback.py new file mode 100644 index 00000000..73b999b0 --- /dev/null +++ b/dashboard/backend/provider_fallback.py @@ -0,0 +1,774 @@ +"""Provider Fallback Engine — automatic 429/quota detection & provider rotation. + +Reads the active provider from config/providers.json and its fallback_models / +fallback_providers configuration. When a subprocess call returns 429/quota +errors, this module cycles to the next model (within the same provider) or +to the next provider entirely. + +Cooldown tracking prevents thrashing — a model/provider that 429'd gets a +cooldown window before we try it again. + +Usage: + from provider_fallback import FallbackEngine + + engine = FallbackEngine() + for attempt in engine.attempts(prompt, max_turns=10, timeout=600): + result = attempt.run() + if result["status"] == "success": + break + # engine automatically records 429 and advances to next model/provider +""" + +from __future__ import annotations + +import json +import os +import re +import shutil +import signal +import subprocess +import threading +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import Iterator + +WORKSPACE = Path(__file__).resolve().parent.parent.parent +PROVIDERS_CONFIG = WORKSPACE / "config" / "providers.json" + + +def _usable_secret(value: str | None) -> bool: + if not value: + return False + value = value.strip() + return value not in {"[REDACTED]", "REDACTED", "your_bot_token_here", "your_chat_id_here"} + + +def _load_workspace_env() -> None: + """Load root .env without depending on python-dotenv.""" + env_file = WORKSPACE / ".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) + + +_load_workspace_env() + +# ── Per-model inflight lock ────────────────────────────────────────────────── +# Prevents two heartbeat threads from picking the same model at the same +# instant and getting back-to-back 429s. Each call acquires the model key for +# the duration of its HTTP round-trip; the next caller will pick a different +# model from the chain instead of doubling up. +_inflight_locks: dict[str, threading.Lock] = {} +_inflight_locks_guard = threading.Lock() + + +def _model_lock(model_key: str) -> threading.Lock: + with _inflight_locks_guard: + lk = _inflight_locks.get(model_key) + if lk is None: + lk = threading.Lock() + _inflight_locks[model_key] = lk + return lk + +# ── Error patterns that trigger fallback ────────────────────────────────────── + +_429_PATTERNS = [ + re.compile(r"429", re.IGNORECASE), + re.compile(r"rate.?limit", re.IGNORECASE), + re.compile(r"quota", re.IGNORECASE), + re.compile(r"too many requests", re.IGNORECASE), + re.compile(r"resource.?exhausted", re.IGNORECASE), + re.compile(r"capacity", re.IGNORECASE), + re.compile(r"overloaded", re.IGNORECASE), + re.compile(r"service.?unavailable", re.IGNORECASE), + re.compile(r"temporarily.?unavailable", re.IGNORECASE), + re.compile(r"insufficient_quota", re.IGNORECASE), + re.compile(r"billing.?limit", re.IGNORECASE), + re.compile(r"plan.?limit", re.IGNORECASE), + re.compile(r"maximum combo retry limit reached", re.IGNORECASE), +] + +# Fatal errors that should NOT trigger fallback (auth / config issues) +_FATAL_PATTERNS = [ + re.compile(r"401", re.IGNORECASE), + re.compile(r"403", re.IGNORECASE), + re.compile(r"invalid.?api.?key", re.IGNORECASE), + re.compile(r"authentication", re.IGNORECASE), + re.compile(r"unauthorized", re.IGNORECASE), + re.compile(r"forbidden", re.IGNORECASE), +] + + +def is_429_error(error_text: str) -> bool: + """Check if error text indicates a 429/rate-limit/quota issue.""" + if not error_text: + return False + has_429 = any(p.search(error_text) for p in _429_PATTERNS) + has_fatal = any(p.search(error_text) for p in _FATAL_PATTERNS) + if has_fatal and not has_429: + return False + return has_429 + + +# ── Cooldown tracking ───────────────────────────────────────────────────────── + +_cooldowns: dict[str, float] = {} +DEFAULT_COOLDOWN_SECONDS = 60 # 1 min — faster rotation through the NVIDIA chain + + +def set_cooldown(key: str, duration_seconds: float = DEFAULT_COOLDOWN_SECONDS): + _cooldowns[key] = time.time() + duration_seconds + + +def is_on_cooldown(key: str) -> bool: + deadline = _cooldowns.get(key) + if deadline is None: + return False + if time.time() > deadline: + _cooldowns.pop(key, None) + return False + return True + + +def clear_cooldown(key: str): + _cooldowns.pop(key, None) + + +def clear_all_cooldowns(): + _cooldowns.clear() + + +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. + + Reused as-is for opencode (validated 2026-07-12, spike/opencode-runtime): + same embed-in-prompt pattern works unmodified, no adaptation needed. + """ + 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}" + ) + + +# ── Config reading ───────────────────────────────────────────────────────────── + +def _read_providers_config() -> dict: + try: + if PROVIDERS_CONFIG.is_file(): + return json.loads(PROVIDERS_CONFIG.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + pass + return {"active_provider": "nvidia", "providers": {}} + + +# ── Default fallback chains ─────────────────────────────────────────────────── + +# NVIDIA: models are independent — quota on one doesn't block others. +# Primary is GLM 5.1 on NVIDIA; if quota/rate-limit hits, rotate inside +# NVIDIA first, then leave the provider only after these core models fail. +# NVIDIA chain — 12 models validated on integrate.api.nvidia.com/v1. +# Two principles shape the order: +# 1. Don't double up on the same model — multiple agents pulling the same +# model at once produces burst 429s that waste quotas. The dispatcher +# uses per-model inflight locks above to stagger picks; this list is the +# fallback rotation when one model hits quota. +# 2. Variety matters more than "best first" because of cooldown windows — +# if z-ai/glm-5.1 just bounced, we should be able to keep working +# without leaving NVIDIA. Hence deepseek + kimi + mistral + stepfun all +# having slots equal to their flagship counterparts. +# +# Felipe-specified order (validated 2026-06-16, returning 200 on /chat/completions). +NVIDIA_MODEL_CHAIN = [ + "stepfun-ai/step-3.7-flash", # 1 — StepFun 3.7 Flash + "deepseek-ai/deepseek-v4-flash", # 2 — DeepSeek V4 Flash + "z-ai/glm-5.1", # 3 — Z.AI GLM 5.1 (flagship) + "moonshotai/kimi-k2.6", # 4 — Moonshot Kimi K2.6 + "nvidia/nemotron-3-ultra-550b-a55b", # 5 — NVIDIA Nemotron 3 Ultra 550B + "nvidia/nemotron-3-super-120b-a12b", # 6 — NVIDIA Nemotron 3 Super 120B + "qwen/qwen3.5-122b-a10b", # 7 — Qwen 3.5 122B A10B + "qwen/qwen3.5-397b-a17b", # 8 — Qwen 3.5 397B A17B (big MoE) + "openai/gpt-oss-120b", # 9 — OpenAI GPT-OSS 120B + "microsoft/phi-4-multimodal-instruct", # 10 — Microsoft Phi-4 Multimodal + "stepfun-ai/step-3.5-flash", # 11 — StepFun 3.5 Flash + "minimaxai/minimax-m3", # 12 — MiniMax M3 +] + +# Provider chain: NVIDIA (12 models) → OpenRouter (owl-alpha + nex-n2-pro free) → Claude +# NVIDIA is always first — OpenRouter only as last resort when all 12 NVIDIA models are on cooldown. +# OpenRouter models must be FREE (no paid tier) to avoid burning credits unintentionally. +DEFAULT_PROVIDER_CHAIN = [ + { + "provider_id": "nvidia", + "cli_command": "openclaude", + "base_url": "https://integrate.api.nvidia.com/v1", + "env_vars": { + "CLAUDE_CODE_USE_OPENAI": "1", + "OPENAI_BASE_URL": "https://integrate.api.nvidia.com/v1", + }, + "model_chain": NVIDIA_MODEL_CHAIN, + }, + { + "provider_id": "openrouter", + "cli_command": "openclaude", + "base_url": "https://openrouter.ai/api/v1", + "env_vars": { + "CLAUDE_CODE_USE_OPENAI": "1", + "OPENAI_BASE_URL": "https://openrouter.ai/api/v1", + }, + "model_chain": [ + "openrouter/owl-alpha", + "nex-agi/nex-n2-pro:free", + ], + }, + { + "provider_id": "anthropic", + "cli_command": "claude", + "base_url": None, + "env_vars": {}, + "model_chain": [None], + }, +] + + +def _resolve_provider_chain(config: dict) -> list[dict]: + """Build the provider chain from config, falling back to defaults.""" + active_id = config.get("active_provider", "nvidia") + providers = config.get("providers", {}) + active_prov = providers.get(active_id, {}) + + explicit_chain = active_prov.get("fallback_providers") + if isinstance(explicit_chain, list) and explicit_chain: + chain = [_build_provider_entry(active_id, providers)] + for pid in explicit_chain: + if pid in providers: + chain.append(_build_provider_entry(pid, providers)) + return chain if len(chain) >= 2 else DEFAULT_PROVIDER_CHAIN + + return DEFAULT_PROVIDER_CHAIN + + +def _build_provider_entry(provider_id: str, providers: dict) -> dict: + prov = providers.get(provider_id, {}) + env_vars = {k: v for k, v in prov.get("env_vars", {}).items() + if v and k not in ("OPENAI_API_KEY", "OPENAI_MODEL")} + + primary_model = prov.get("default_model") or prov.get("env_vars", {}).get("OPENAI_MODEL") + model_chain = [] + if primary_model: + model_chain.append(primary_model) + for fallback_model in prov.get("fallback_models", []): + if fallback_model and fallback_model not in model_chain: + model_chain.append(fallback_model) + + if not model_chain and prov.get("cli_command") == "claude": + model_chain = [None] + + return { + "provider_id": provider_id, + "cli_command": prov.get("cli_command", "openclaude"), + "base_url": prov.get("default_base_url") or prov.get("env_vars", {}).get("OPENAI_BASE_URL"), + "env_vars": env_vars, + "model_chain": model_chain, + } + + +def _get_api_key(provider_id: str, config: dict) -> str: + if provider_id in {"codex_auth", "anthropic"}: + return "" + + prov = config.get("providers", {}).get(provider_id, {}) + env_vars = prov.get("env_vars", {}) + base_url = (env_vars.get("OPENAI_BASE_URL") or prov.get("default_base_url") or "").lower() + + # A chave do próprio provider vence. config/providers.json pode conter + # placeholders [REDACTED] — _usable_secret filtra e caímos no env do + # processo. A NVIDIA_API_KEY do env só vale para endpoints da NVIDIA: + # antes ela tinha precedência global e sequestrava chamadas a outros + # gateways (omnirouter recebia a chave NVIDIA → 401). + for key_name in ("OPENAI_API_KEY", "NVIDIA_API_KEY", "GEMINI_API_KEY"): + val = env_vars.get(key_name, "") + if _usable_secret(val): + return val + + fallback_keys = ["OPENAI_API_KEY", "GEMINI_API_KEY"] + if "nvidia.com" in base_url: + fallback_keys.insert(0, "NVIDIA_API_KEY") + for key_name in fallback_keys: + value = os.environ.get(key_name, "") + if _usable_secret(value): + return value + + return "" + + +# ── Attempt record ───────────────────────────────────────────────────────────── + +@dataclass +class FallbackAttempt: + attempt_number: int + provider_id: str + model: str | None + cli_command: str + prompt: str + max_turns: int + timeout_seconds: int + agent: str = "" + env_overrides: dict = field(default_factory=dict) + _result: dict | None = field(default=None, repr=False) + + def run(self) -> dict: + self._result = _invoke_cli( + cli_command=self.cli_command, + prompt=self.prompt, + max_turns=self.max_turns, + timeout_seconds=self.timeout_seconds, + agent=self.agent, + env_overrides=self.env_overrides, + provider_id=self.provider_id, + model=self.model, + ) + return self._result + + @property + def result(self) -> dict | None: + return self._result + + @property + def is_429(self) -> bool: + if not self._result: + return False + error = self._result.get("error") or "" + output = self._result.get("output") or "" + return is_429_error(error) or is_429_error(output) + + @property + def is_fatal(self) -> bool: + """Fatal means auth/config errors that are unlikely to be fixed by another model.""" + if not self._result: + return False + if self._result.get("status") == "success": + return False + if self.is_429: + return False + error = (self._result.get("error") or self._result.get("output") or "").lower() + fatal_terms = ( + "invalid_api_key", "invalid api key", "401", "403", + "unauthorized", "forbidden", "authentication", "api key", + "no usable api key", "has no usable api key", + ) + return any(term in error for term in fatal_terms) + + +# ── Core invocation ───────────────────────────────────────────────────────────── + +def _invoke_cli( + cli_command: str, + prompt: str, + max_turns: int, + timeout_seconds: int, + agent: str = "", + env_overrides: dict | None = None, + provider_id: str | None = None, + model: str | None = None, +) -> dict: + cli_bin = shutil.which(cli_command) + if not cli_bin: + return { + "status": "fail", + "error": f"{cli_command} binary not found in PATH", + "output": "", + "duration_ms": 0, + "tokens_in": None, "tokens_out": None, "cost_usd": None, + } + + output_mode = "envelope" + if cli_command == "opencode": + # opencode não tem --max-turns nem --dangerously-skip-permissions — o + # equivalente de bypass de permissão é --auto (validado spike + # 2026-07-12). Seleção de modelo é via -m provider/model, não por + # env var OPENAI_MODEL — provider_id precisa bater com uma entry em + # opencode.json (ver opencode.json na raiz do workspace). + prompt = _embed_agent_for_openclaude(prompt, agent) + agent = "" + model_ref = f"{provider_id}/{model}" if model else f"{provider_id}/auto" + cmd = [cli_bin, "run", prompt, "-m", model_ref, "--format", "json", "--auto"] + output_mode = "opencode-ndjson" + else: + cmd = [cli_bin, "--print", "--max-turns", str(max_turns), + "--dangerously-skip-permissions", "--output-format", "json"] + if cli_command == "openclaude": + prompt = _embed_agent_for_openclaude(prompt, agent) + agent = "" + if agent: + cmd.extend(["--agent", agent]) + cmd.extend(["--", prompt]) + + run_env = dict(os.environ) + if env_overrides: + for k, v in env_overrides.items(): + if v is not None: + run_env[k] = str(v) + # Impede o CLI de se auto-migrar pro instalador nativo no meio da run + # (mata o processo com exit 1). + run_env["DISABLE_AUTOUPDATER"] = "1" + + # Acquire a per-model inflight lock so concurrent calls cannot pick the same + # model at the same instant (the canonical cause of burst 429s on shared + # providers). Non-blocking acquire: if another worker is mid-flight against + # this model, return a sentinel and let the caller skip to the next chain + # member instead of doubling up the quota burn. + model_key = f"{provider_id}:{model}" if model else f"{provider_id}:native-{os.getpid()}" + lk = _model_lock(model_key) + if not lk.acquire(blocking=False): + return { + "status": "fail", + "error": f"model {model} busy (concurrent call in flight) — skip to next model", + "output": "", + "duration_ms": 0, + "tokens_in": None, "tokens_out": None, "cost_usd": None, + "fallback_exhausted": False, + "skip_advance_model": True, + } + try: + return _invoke_cli_run(cmd, run_env, timeout_seconds, WORKSPACE, output_mode=output_mode) + finally: + lk.release() + + # The CLI emits a JSON result envelope (--output-format json) carrying token + # usage and cost — parse it so heartbeat runs land accurate numbers on the + # /costs page instead of nulls. Best-effort: never let parsing break a run. + tokens_in = tokens_out = cost_usd = None + try: + envelope = json.loads(output) + usage = envelope.get("usage") or {} + tokens_in = usage.get("input_tokens") + tokens_out = usage.get("output_tokens") + cost_usd = envelope.get("total_cost_usd") + except (json.JSONDecodeError, AttributeError, TypeError): + pass + + return { + "status": status, "output": output, "error": error, + "duration_ms": duration_ms, + "tokens_in": tokens_in, "tokens_out": tokens_out, "cost_usd": cost_usd, + } + + + +def _parse_opencode_ndjson(output: str) -> dict: + """opencode --format json emits one JSON event per line (step_start, text, + step_finish, ...) instead of Claude Code's single envelope. Validated + 2026-07-12 against a real OmniRoute call — step_finish carries + tokens.{input,output} and cost; text events carry the assistant's reply. + """ + tokens_in = tokens_out = cost_usd = None + text_parts: list[str] = [] + saw_error = False + error_message = "" + for line in output.splitlines(): + line = line.strip() + if not line: + continue + try: + event = json.loads(line) + except (json.JSONDecodeError, TypeError): + continue + etype = event.get("type") + part = event.get("part") or {} + if etype == "text": + t = part.get("text") + if t: + text_parts.append(t) + elif etype == "error": + saw_error = True + err = event.get("error") or {} + msg = err.get("data", {}).get("message") if isinstance(err.get("data"), dict) else None + error_message = msg or err.get("name") or error_message + elif etype == "step_finish": + usage = part.get("tokens") or {} + if usage.get("input") is not None: + tokens_in = usage.get("input") + if usage.get("output") is not None: + tokens_out = usage.get("output") + if part.get("cost") is not None: + cost_usd = part.get("cost") + return { + "text": "\n".join(text_parts), + "tokens_in": tokens_in, "tokens_out": tokens_out, "cost_usd": cost_usd, + "saw_error": saw_error, "error_message": error_message, + } + + +def _invoke_cli_run(cmd: list, run_env: dict, timeout_seconds: int, workspace: Path, + output_mode: str = "envelope") -> dict: + """Inner run — assume the per-model inflight lock is already held. + Holds the subprocess, parses tokens, applies backoff on 429, returns dict. + """ + import subprocess as _sp + start_time = time.time() + proc = None + output = "" + error = None + status = "success" + + try: + proc = _sp.Popen( + cmd, stdout=_sp.PIPE, stderr=_sp.PIPE, + text=True, cwd=str(workspace), start_new_session=True, env=run_env, + ) + try: + stdout, stderr = proc.communicate(timeout=timeout_seconds) + output = stdout or "" + if proc.returncode != 0: + status = "fail" + error = stderr[:2000] if stderr else f"exit code {proc.returncode}" + except _sp.TimeoutExpired: + try: + os.killpg(os.getpgid(proc.pid), signal.SIGKILL) + except (ProcessLookupError, OSError): + proc.kill() + try: + proc.communicate(timeout=5) + except _sp.TimeoutExpired: + pass + status = "timeout" + error = f"Killed after {timeout_seconds}s timeout" + except Exception as exc: + status = "fail" + error = str(exc) + + duration_ms = int((time.time() - start_time) * 1000) + + tokens_in = tokens_out = cost_usd = None + if output_mode == "opencode-ndjson": + parsed = _parse_opencode_ndjson(output) + tokens_in, tokens_out, cost_usd = parsed["tokens_in"], parsed["tokens_out"], parsed["cost_usd"] + if parsed["saw_error"]: + status = "fail" + # troca o "exit code 1" genérico pela mensagem real do evento de + # erro do opencode — is_429_error()/is_fatal também escaneiam + # `output` (o ndjson bruto), então a detecção de 429/fatal já + # funcionava antes disso; isso só melhora a legibilidade do log. + if parsed["error_message"] and (not error or error.startswith("exit code")): + error = parsed["error_message"] + elif not error: + error = "opencode emitted an error event in the ndjson stream" + if status == "success" and not parsed["text"] and tokens_in is None: + # nem texto nem step_finish — stream vazio/quebrado, não confia + status = "fail" + error = error or "opencode produced no text/step_finish events" + else: + try: + envelope = json.loads(output) + usage = envelope.get("usage") or {} + tokens_in = usage.get("input_tokens") + tokens_out = usage.get("output_tokens") + cost_usd = envelope.get("total_cost_usd") + if status != "success" and envelope.get("type") == "result" and envelope.get("result") and envelope.get("is_error") is False: + status = "success" + error = None + except (json.JSONDecodeError, AttributeError, TypeError): + pass + + return { + "status": status, "output": output, "error": error, + "duration_ms": duration_ms, + "tokens_in": tokens_in, "tokens_out": tokens_out, "cost_usd": cost_usd, + } + + +# ── Fallback Engine ───────────────────────────────────────────────────────────── + +class FallbackEngine: + """Iterates through provider + model chains, falling back on 429/quota errors.""" + + def __init__(self, provider_chain: list[dict] | None = None): + if provider_chain is None: + config = _read_providers_config() + provider_chain = _resolve_provider_chain(config) + self.provider_chain = provider_chain + self._attempts_log: list[FallbackAttempt] = [] + + def attempts( + self, + prompt: str, + max_turns: int = 10, + timeout_seconds: int = 600, + agent: str = "", + force_provider: str | None = None, + force_model: str | None = None, + ) -> Iterator[FallbackAttempt]: + config = _read_providers_config() + attempt_num = 0 + + chain = self.provider_chain + if force_provider: + chain = [p for p in chain if p["provider_id"] == force_provider] + if not chain and force_provider == "nvidia": + chain = [DEFAULT_PROVIDER_CHAIN[0]] + + for provider_entry in chain: + provider_id = provider_entry["provider_id"] + cli_command = provider_entry["cli_command"] + base_url = provider_entry.get("base_url") + model_chain = provider_entry.get("model_chain", [None]) + base_env = dict(provider_entry.get("env_vars", {})) + + if base_url: + base_env["OPENAI_BASE_URL"] = base_url + + api_key = _get_api_key(provider_id, config) + if api_key: + base_env["OPENAI_API_KEY"] = api_key + # openclaude ≥0.18 detects the NVIDIA NIM base URL and requires + # the key in NVIDIA_API_KEY — derive it so auth doesn't fail and + # wrongly skip to the next provider. Mirrors provider-config.js. + if "nvidia.com" in (base_url or "") and "NVIDIA_API_KEY" not in base_env: + base_env["NVIDIA_API_KEY"] = api_key + + for model in model_chain: + if force_model and model != force_model: + continue + + cooldown_key = f"{provider_id}:{model}" if model else provider_id + if is_on_cooldown(cooldown_key): + continue + + # Provider-level cooldown (NVIDIA exempt — models independent) + if is_on_cooldown(provider_id) and provider_id != "nvidia": + continue + + attempt_num += 1 + + env_overrides = dict(base_env) + if model: + env_overrides["OPENAI_MODEL"] = model + + attempt = FallbackAttempt( + attempt_number=attempt_num, + provider_id=provider_id, + model=model, + cli_command=cli_command, + prompt=prompt, + max_turns=max_turns, + timeout_seconds=timeout_seconds, + agent=agent, + env_overrides=env_overrides, + ) + self._attempts_log.append(attempt) + yield attempt + + # After caller runs the attempt, check result + if attempt.result is None: + continue + + if attempt.result.get("status") == "success": + return + + if attempt.is_429: + set_cooldown(cooldown_key) + print(f"[fallback] 429 on {provider_id}:{model} — " + f"cooldown {DEFAULT_COOLDOWN_SECONDS}s, trying next", flush=True) + continue + + if attempt.is_fatal: + print(f"[fallback] fatal error on {provider_id}:{model} — " + f"skipping to next provider", flush=True) + break + + continue + + @property + def attempts_log(self) -> list[FallbackAttempt]: + return list(self._attempts_log) + + def last_successful_attempt(self) -> FallbackAttempt | None: + for a in reversed(self._attempts_log): + if a.result and a.result.get("status") == "success": + return a + return None + + +# ── Convenience: single call with automatic fallback ─────────────────────────── + +def invoke_with_fallback( + prompt: str, + max_turns: int = 10, + timeout_seconds: int = 600, + agent: str = "", + force_provider: str | None = None, + force_model: str | None = None, +) -> dict: + """Invoke CLI with automatic 429 fallback. Returns the first successful result.""" + engine = FallbackEngine() + last_result = None + + for attempt in engine.attempts( + prompt=prompt, max_turns=max_turns, timeout_seconds=timeout_seconds, + agent=agent, force_provider=force_provider, force_model=force_model, + ): + result = attempt.run() + result["provider_id"] = attempt.provider_id + result["model"] = attempt.model + result["attempt_number"] = attempt.attempt_number + + if result["status"] == "success": + if attempt.attempt_number > 1: + print(f"[fallback] SUCCESS on attempt #{attempt.attempt_number} " + f"({attempt.provider_id}:{attempt.model})", flush=True) + return result + + if result.get("skip_advance_model"): + print(f"[fallback] skip {attempt.provider_id}:{attempt.model} ({result.get('error')})", flush=True) + continue + + last_result = result + print(f"[fallback] attempt #{attempt.attempt_number} failed " + f"({attempt.provider_id}:{attempt.model}) status={result['status']}", flush=True) + + if last_result: + last_result["fallback_exhausted"] = True + last_result["total_attempts"] = len(engine.attempts_log) + else: + last_result = { + "status": "fail", + "error": "No attempts made — all providers on cooldown or no chain configured", + "output": "", "duration_ms": 0, + "fallback_exhausted": True, "total_attempts": 0, + } + + return last_result diff --git a/dashboard/backend/rate_limit.py b/dashboard/backend/rate_limit.py new file mode 100644 index 00000000..c1b49939 --- /dev/null +++ b/dashboard/backend/rate_limit.py @@ -0,0 +1,26 @@ +"""Shared Flask-Limiter instance for EvoNexus. + +Placing the limiter here (rather than in app.py directly) breaks the +circular-import chain: app.py initialises it, route blueprints import it. + +Usage in a blueprint:: + + from rate_limit import limiter + + @bp.route("/api/shares//view") + @limiter.limit("60 per minute") + def view_share(token: str): + ... +""" + +from flask_limiter import Limiter +from flask_limiter.util import get_remote_address + +# Uninitialised instance — app.py calls limiter.init_app(app) at startup. +limiter = Limiter( + get_remote_address, + # Default: generous to avoid false positives on authenticated API routes. + # Individual endpoints override with @limiter.limit() decorators. + default_limits=["600 per minute"], + storage_uri="memory://", +) diff --git a/dashboard/backend/routes/backups.py b/dashboard/backend/routes/backups.py index 22948994..784d34c5 100644 --- a/dashboard/backend/routes/backups.py +++ b/dashboard/backend/routes/backups.py @@ -114,6 +114,8 @@ def _run(): zip_path.unlink(missing_ok=True) _running_jobs["backup"] = {"status": "done"} except Exception as e: + import traceback + traceback.print_exc() _running_jobs["backup"] = {"status": "error", "error": str(e)} _running_jobs["backup"] = {"status": "running"} @@ -126,11 +128,14 @@ def _run(): @bp.route("/api/backups/status") def backup_status(): - """Check status of running backup job.""" + """Check status of a running job. ?type=backup (default) or ?type=restore.""" denied = _require("config", "view") if denied: return denied - job = _running_jobs.get("backup", {"status": "idle"}) + job_type = request.args.get("type", "backup") + if job_type not in ("backup", "restore"): + return jsonify({"error": "Invalid type. Use 'backup' or 'restore'"}), 400 + job = _running_jobs.get(job_type, {"status": "idle"}) return jsonify(job) @@ -150,6 +155,11 @@ def restore_backup(filename): if mode not in ("merge", "replace"): return jsonify({"error": "Invalid mode. Use 'merge' or 'replace'"}), 400 + # Capture the app object here — the worker thread has no request/app + # context, and _post_restore_migrate needs one (current_app, db.create_all). + from flask import current_app + app = current_app._get_current_object() + def _run(): try: import sys @@ -159,10 +169,13 @@ def _run(): # After restore, run auto-migrate to fix schema differences # (old backups may have missing columns or corrupted data) - _post_restore_migrate() + with app.app_context(): + _post_restore_migrate() _running_jobs["restore"] = {"status": "done", "mode": mode} except Exception as e: + import traceback + traceback.print_exc() _running_jobs["restore"] = {"status": "error", "error": str(e)} _running_jobs["restore"] = {"status": "running"} diff --git a/dashboard/backend/routes/brain_repo.py b/dashboard/backend/routes/brain_repo.py index 5bd8dd03..a4f28857 100644 --- a/dashboard/backend/routes/brain_repo.py +++ b/dashboard/backend/routes/brain_repo.py @@ -348,7 +348,9 @@ def connect(): # + first commit + push) moved to job_runner.enqueue_bootstrap below — it's the # slow part and Cloudflare's 100 s request limit was killing first-time connects. bootstrap_pending = False + clone_pending = False bootstrap_params: dict | None = None + clone_params: dict | None = None if create_repo: try: from brain_repo.github_api import create_private_repo, get_github_username @@ -401,6 +403,12 @@ def connect(): abort(400, description="Repository must be private") repo_owner = repo_info.get("owner", {}).get("login", "") repo_name = repo_info.get("name", "") + clone_pending = True + clone_params = { + "token": token, + "repo_url": repo_url, + "repo_name": repo_name, + } # Encrypt and store token. # @@ -471,6 +479,17 @@ def connect(): ) except ImportError: log.error("connect: job_runner unavailable — bootstrap will not run") + elif clone_pending and clone_params is not None: + try: + from brain_repo import job_runner + from flask import current_app + job_runner.enqueue_clone( + current_app._get_current_object(), # type: ignore[attr-defined] + current_user.id, + **clone_params, + ) + except ImportError: + log.error("connect: job_runner unavailable — clone will not run") return jsonify(config.to_dict()) diff --git a/dashboard/backend/routes/heartbeats.py b/dashboard/backend/routes/heartbeats.py index e53842e6..c71aa201 100644 --- a/dashboard/backend/routes/heartbeats.py +++ b/dashboard/backend/routes/heartbeats.py @@ -390,3 +390,50 @@ def reindex_heartbeats(): return jsonify({"reindexed": len(yaml_cfg.heartbeats)}) except Exception as exc: return jsonify({"error": str(exc)}), 500 + + +# ── Approval Queue ────────────────────────────────────────────────────────── + +@bp.route("/api/approvals") +def list_approvals(): + """List pending approval items.""" + from routes._helpers import WORKSPACE + import sys + sys.path.insert(0, str(WORKSPACE / "ADWs" / "routines")) + from approval_queue import get_pending, get_stats + + return jsonify({ + "pending": get_pending(), + "stats": get_stats(), + }) + + +@bp.route("/api/approvals//approve", methods=["POST"]) +def approve_item(item_id): + """Approve a queued item.""" + from routes._helpers import WORKSPACE + import sys + sys.path.insert(0, str(WORKSPACE / "ADWs" / "routines")) + from approval_queue import approve + + ok = approve(item_id) + if not ok: + return jsonify({"error": "Item not found or already decided"}), 404 + return jsonify({"status": "approved", "id": item_id}) + + +@bp.route("/api/approvals//reject", methods=["POST"]) +def reject_item(item_id): + """Reject a queued item.""" + data = request.get_json() or {} + reason = data.get("reason", "") + + from routes._helpers import WORKSPACE + import sys + sys.path.insert(0, str(WORKSPACE / "ADWs" / "routines")) + from approval_queue import reject + + ok = reject(item_id, reason) + if not ok: + return jsonify({"error": "Item not found or already decided"}), 404 + return jsonify({"status": "rejected", "id": item_id}) diff --git a/dashboard/backend/routes/instagram.py b/dashboard/backend/routes/instagram.py new file mode 100644 index 00000000..eec0faef --- /dev/null +++ b/dashboard/backend/routes/instagram.py @@ -0,0 +1,685 @@ +"""Instagram Graph API routes — webhooks, publish, comments, DMs.""" + +import hashlib +import hmac +import json +import logging +import os +import threading +import urllib.error +import urllib.parse +import urllib.request +from datetime import datetime, timezone +from pathlib import Path + +import requests as http +from flask import Blueprint, jsonify, request, current_app + +log = logging.getLogger(__name__) + +bp = Blueprint("instagram_api", __name__) + +WORKSPACE = Path(__file__).resolve().parent.parent.parent.parent +ENV_PATH = WORKSPACE / ".env" + +# ── Helpers ────────────────────────────────────────────────────────────────── + +GRAPH_BASE = "https://graph.facebook.com/v25.0" + + +def _read_env() -> dict: + env = {} + if not ENV_PATH.exists(): + return env + with open(ENV_PATH) as f: + for line in f: + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + env[key.strip()] = value.strip().strip('"') + return env + + +def _get_ig_accounts() -> list[dict]: + """Return all configured Instagram accounts from env.""" + env = _read_env() + accounts = [] + # Find all SOCIAL_INSTAGRAM_N_ indices + import re + pattern = re.compile(r"^SOCIAL_INSTAGRAM_(\d+)_") + indices = set() + for key in env: + m = pattern.match(key) + if m: + indices.add(int(m.group(1))) + for idx in sorted(indices): + prefix = f"SOCIAL_INSTAGRAM_{idx}" + token = env.get(f"{prefix}_ACCESS_TOKEN", "") + account_id = env.get(f"{prefix}_ACCOUNT_ID", "") + label = env.get(f"{prefix}_LABEL", f"Conta {idx}") + if token: + accounts.append({ + "index": idx, + "label": label, + "access_token": token, + "account_id": account_id, + }) + return accounts + + +def _graph_get(path: str, params: dict, token: str) -> dict: + """Make a GET request to Graph API.""" + params["access_token"] = token + url = f"{GRAPH_BASE}/{path}?{urllib.parse.urlencode(params)}" + with urllib.request.urlopen(url, timeout=30) as resp: + return json.loads(resp.read()) + + +def _graph_post(path: str, data: dict, token: str) -> dict: + """Make a POST request to Graph API.""" + data["access_token"] = token + body = urllib.parse.urlencode(data).encode() + req = urllib.request.Request(f"{GRAPH_BASE}/{path}", data=body, method="POST") + with urllib.request.urlopen(req, timeout=30) as resp: + return json.loads(resp.read()) + + +def _graph_post_json(path: str, payload: dict, token: str) -> dict: + """Make a POST request with JSON body to Graph API.""" + payload["access_token"] = token + body = json.dumps(payload).encode() + req = urllib.request.Request( + f"{GRAPH_BASE}/{path}", + data=body, + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=30) as resp: + return json.loads(resp.read()) + + +# ── Webhook Verification (GET) ────────────────────────────────────────────── + +@bp.route("/api/instagram/webhook", methods=["GET"]) +def instagram_webhook_verify(): + """Verify Instagram/Meta webhook subscription. + + Meta sends a GET request with: + hub.mode=subscribe + hub.challenge= + hub.verify_token= + """ + mode = request.args.get("hub.mode", "") + challenge = request.args.get("hub.challenge", "") + verify_token = request.args.get("hub.verify_token", "") + + env = _read_env() + expected_token = env.get("INSTAGRAM_WEBHOOK_VERIFY_TOKEN", "") + + if mode == "subscribe" and verify_token == expected_token: + log.info("Instagram webhook verified successfully") + return challenge, 200 + + log.warning("Instagram webhook verification failed: mode=%s token_match=%s", mode, verify_token == expected_token) + return jsonify({"error": "Verification failed"}), 403 + + +# ── Webhook Receiver (POST) ───────────────────────────────────────────────── + +@bp.route("/api/instagram/webhook", methods=["POST"]) +def instagram_webhook_receive(): + """Receive Instagram webhook events (comments, messages). + + Meta sends POST with JSON body containing entry changes. + We process asynchronously and return 200 immediately. + """ + # Validate signature if app secret is set + env = _read_env() + app_secret = env.get("META_APP_SECRET", "") + + if app_secret: + signature = request.headers.get("X-Hub-Signature-256", "") + if not _verify_signature(request.data, signature, app_secret): + log.warning("Instagram webhook: invalid signature") + return jsonify({"status": "ok"}), 200 # Don't reveal invalid sig + + try: + data = request.get_json(silent=True) or {} + except Exception: + log.warning("Instagram webhook: failed to parse JSON body") + return jsonify({"status": "ok"}), 200 + + log.info("Instagram webhook received: object=%s entries=%d", + data.get("object"), len(data.get("entry", []))) + + # Process asynchronously + app = current_app._get_current_object() + + def _process(): + with app.app_context(): + _handle_webhook_entries(data, app) + + thread = threading.Thread(target=_process, daemon=True) + thread.start() + + return jsonify({"status": "ok"}), 200 + + +def _verify_signature(payload: bytes, signature: str, app_secret: str) -> bool: + """Verify X-Hub-Signature-256 from Meta.""" + if not signature.startswith("sha256="): + return False + expected = hmac.new(app_secret.encode(), payload, hashlib.sha256).hexdigest() + return hmac.compare_digest(f"sha256={expected}", signature) + + +def _handle_webhook_entries(data: dict, app): + """Process webhook entries — comments and messages.""" + from models import db # Import here to avoid circular deps + + for entry in data.get("entry", []): + entry_id = entry.get("id", "") + changes = entry.get("changes", []) + + for change in changes: + field = change.get("field", "") + value = change.get("value", {}) + + if field == "comments": + _handle_comment(value, entry_id, app) + elif field == "messages": + _handle_message(value, entry_id, app) + elif field == "mentions": + _handle_mention(value, entry_id, app) + else: + log.info("Instagram webhook: unhandled field=%s", field) + + +def _handle_comment(value: dict, page_id: str, app): + """Handle a new comment on an Instagram media.""" + comment_id = value.get("id", "") + text = value.get("text", "") + media_id = value.get("media", {}).get("id", "") + from_user = value.get("from", {}).get("username", "") + + log.info("Instagram comment: id=%s from=%s media=%s text=%s", + comment_id, from_user, media_id, text[:80]) + + # Store in activity log + try: + from models import ActivityLog + log_entry = ActivityLog( + source="instagram", + event_type="comment", + external_id=comment_id, + from_user=from_user, + content=text, + media_id=media_id, + page_id=page_id, + created_at=datetime.now(timezone.utc), + ) + from models import db + db.session.add(log_entry) + db.session.commit() + except Exception as e: + log.warning("Failed to log Instagram comment: %s", e) + + # Auto-reply logic: if comment contains certain keywords, reply + # This can be extended with AI agent integration + _maybe_auto_reply_comment(comment_id, text, from_user, app) + + +def _handle_message(value: dict, page_id: str, app): + """Handle a new DM on Instagram.""" + message = value.get("message", {}) + message_id = message.get("id", "") + text = message.get("text", "") + from_user = value.get("from", {}).get("username", "") + + log.info("Instagram DM: id=%s from=%s text=%s", message_id, from_user, text[:80]) + + # Store in activity log + try: + from models import ActivityLog + log_entry = ActivityLog( + source="instagram", + event_type="dm", + external_id=message_id, + from_user=from_user, + content=text, + page_id=page_id, + created_at=datetime.now(timezone.utc), + ) + from models import db + db.session.add(log_entry) + db.session.commit() + except Exception as e: + log.warning("Failed to log Instagram DM: %s", e) + + # Auto-reply with link + _maybe_auto_reply_dm(message_id, text, from_user, app) + + +def _handle_mention(value: dict, page_id: str, app): + """Handle Instagram mention.""" + media_id = value.get("media_id", "") + log.info("Instagram mention: media_id=%s", media_id) + + +def _maybe_auto_reply_comment(comment_id: str, text: str, from_user: str, app): + """Auto-reply to a comment if it matches rules.""" + env = _read_env() + auto_reply_enabled = env.get("INSTAGRAM_AUTO_REPLY_COMMENTS", "false").lower() == "true" + if not auto_reply_enabled: + return + + reply_text = env.get("INSTAGRAM_COMMENT_REPLY_TEXT", "") + if not reply_text: + return + + accounts = _get_ig_accounts() + if not accounts: + return + + token = accounts[0]["access_token"] + + try: + result = _graph_post( + f"{comment_id}/replies", + {"message": reply_text}, + token, + ) + log.info("Instagram comment reply sent: %s", result.get("id")) + except Exception as e: + log.warning("Failed to reply to comment %s: %s", comment_id, e) + + +def _maybe_auto_reply_dm(message_id: str, text: str, from_user: str, app): + """Auto-reply to a DM with configured link.""" + env = _read_env() + auto_reply_enabled = env.get("INSTAGRAM_AUTO_REPLY_DM", "false").lower() == "true" + if not auto_reply_enabled: + return + + reply_text = env.get("INSTAGRAM_DM_REPLY_TEXT", "") + if not reply_text: + return + + accounts = _get_ig_accounts() + if not accounts: + return + + token = accounts[0]["access_token"] + ig_account_id = accounts[0]["account_id"] + + try: + # Send DM reply via Instagram Messaging API + payload = { + "recipient": {"id": from_user}, + "message": {"text": reply_text}, + } + result = _graph_post_json( + f"{ig_account_id}/messages", + payload, + token, + ) + log.info("Instagram DM reply sent: %s", result.get("id")) + except Exception as e: + log.warning("Failed to reply to DM from %s: %s", from_user, e) + + +# ── API: Account Info ─────────────────────────────────────────────────────── + +@bp.route("/api/instagram/accounts") +def instagram_accounts(): + """List configured Instagram accounts.""" + accounts = _get_ig_accounts() + # Don't expose tokens + return jsonify({ + "accounts": [ + {"index": a["index"], "label": a["label"], "account_id": a["account_id"]} + for a in accounts + ] + }) + + +@bp.route("/api/instagram/profile/") +def instagram_profile(account_idx): + """Get Instagram profile info.""" + accounts = _get_ig_accounts() + account = next((a for a in accounts if a["index"] == account_idx), None) + if not account: + return jsonify({"error": "Account not found"}), 404 + + try: + result = _graph_get( + account["account_id"], + {"fields": "id,username,name,biography,followers_count,follows_count,media_count,profile_picture_url,website"}, + account["access_token"], + ) + return jsonify(result) + except urllib.error.HTTPError as e: + return jsonify({"error": e.read().decode()}), e.code + + +# ── API: Publish ──────────────────────────────────────────────────────────── + +@bp.route("/api/instagram/publish", methods=["POST"]) +def instagram_publish(): + """Publish a post to Instagram. + + Expects JSON: + { + "account_idx": 1, + "image_url": "https://...", // or video_url + "caption": "Post caption", + "is_reel": false, + "share_to_feed": true // for reels + } + """ + data = request.get_json(silent=True) or {} + account_idx = data.get("account_idx", 1) + image_url = data.get("image_url", "") + video_url = data.get("video_url", "") + caption = data.get("caption", "") + is_reel = data.get("is_reel", False) + share_to_feed = data.get("share_to_feed", True) + + if not image_url and not video_url: + return jsonify({"error": "image_url or video_url is required"}), 400 + + accounts = _get_ig_accounts() + account = next((a for a in accounts if a["index"] == account_idx), None) + if not account: + return jsonify({"error": "Account not found"}), 404 + + token = account["access_token"] + ig_id = account["account_id"] + + try: + if is_reel or video_url: + # Publish video/reel + media_url = video_url or image_url + # Step 1: Create video container + container = _graph_post( + f"{ig_id}/media", + { + "media_type": "REELS" if is_reel else "VIDEO", + "video_url": media_url, + "caption": caption, + "share_to_feed": str(share_to_feed).lower(), + }, + token, + ) + creation_id = container.get("id") + + # Step 2: Wait and check status + import time + for _ in range(30): + status = _graph_get( + creation_id, + {"fields": "status_code"}, + token, + ) + if status.get("status_code") == "FINISHED": + break + if status.get("status_code") == "ERROR": + return jsonify({"error": "Video processing failed", "status": status}), 500 + time.sleep(2) + + # Step 3: Publish + result = _graph_post( + f"{ig_id}/media_publish", + {"creation_id": creation_id}, + token, + ) + else: + # Publish image + # Step 1: Create media container + container = _graph_post( + f"{ig_id}/media", + { + "image_url": image_url, + "caption": caption, + }, + token, + ) + creation_id = container.get("id") + + # Step 2: Publish + result = _graph_post( + f"{ig_id}/media_publish", + {"creation_id": creation_id}, + token, + ) + + log.info("Instagram post published: %s", result.get("id")) + return jsonify({"id": result.get("id"), "status": "published"}) + + except urllib.error.HTTPError as e: + error_body = e.read().decode() + log.error("Instagram publish failed: %s", error_body) + return jsonify({"error": error_body}), e.code + except Exception as e: + log.error("Instagram publish failed: %s", e) + return jsonify({"error": str(e)}), 500 + + +# ── API: Comments ─────────────────────────────────────────────────────────── + +@bp.route("/api/instagram/comments//") +def instagram_comments(account_idx, media_id): + """Get comments on a media.""" + accounts = _get_ig_accounts() + account = next((a for a in accounts if a["index"] == account_idx), None) + if not account: + return jsonify({"error": "Account not found"}), 404 + + try: + result = _graph_get( + f"{media_id}/comments", + {"fields": "id,text,username,timestamp,like_count,replies{id,text,username,timestamp}", "limit": 50}, + account["access_token"], + ) + return jsonify(result) + except urllib.error.HTTPError as e: + return jsonify({"error": e.read().decode()}), e.code + + +@bp.route("/api/instagram/comments//reply", methods=["POST"]) +def instagram_reply_comment(account_idx): + """Reply to a comment. + + Expects JSON: + { + "comment_id": "...", + "message": "Reply text" + } + """ + data = request.get_json(silent=True) or {} + comment_id = data.get("comment_id", "") + message = data.get("message", "") + + if not comment_id or not message: + return jsonify({"error": "comment_id and message are required"}), 400 + + accounts = _get_ig_accounts() + account = next((a for a in accounts if a["index"] == account_idx), None) + if not account: + return jsonify({"error": "Account not found"}), 404 + + try: + result = _graph_post( + f"{comment_id}/replies", + {"message": message}, + account["access_token"], + ) + return jsonify({"id": result.get("id"), "status": "replied"}) + except urllib.error.HTTPError as e: + return jsonify({"error": e.read().decode()}), e.code + + +@bp.route("/api/instagram/comments//hide", methods=["POST"]) +def instagram_hide_comment(account_idx): + """Hide a comment. + + Expects JSON: {"comment_id": "..."} + """ + data = request.get_json(silent=True) or {} + comment_id = data.get("comment_id", "") + + accounts = _get_ig_accounts() + account = next((a for a in accounts if a["index"] == account_idx), None) + if not account: + return jsonify({"error": "Account not found"}), 404 + + try: + result = _graph_post( + comment_id, + {"hidden": "true"}, + account["access_token"], + ) + return jsonify(result) + except urllib.error.HTTPError as e: + return jsonify({"error": e.read().decode()}), e.code + + +# ── API: DMs ──────────────────────────────────────────────────────────────── + +@bp.route("/api/instagram/dm//send", methods=["POST"]) +def instagram_send_dm(account_idx): + """Send a DM to a user. + + Expects JSON: + { + "recipient_username": "username", + "message": "Hello! Check out: https://..." + } + """ + data = request.get_json(silent=True) or {} + recipient = data.get("recipient_username", "") + message = data.get("message", "") + + if not recipient or not message: + return jsonify({"error": "recipient_username and message are required"}), 400 + + accounts = _get_ig_accounts() + account = next((a for a in accounts if a["index"] == account_idx), None) + if not account: + return jsonify({"error": "Account not found"}), 404 + + token = account["access_token"] + ig_id = account["account_id"] + + try: + # First, get the user's IG ID from username + user_search = _graph_get( + "ig_users", + {"username": recipient}, + token, + ) + user_id = user_search.get("id") + + if not user_id: + # Try searching via the user's IG ID + return jsonify({"error": f"User '{recipient}' not found"}), 404 + + # Send the message + payload = { + "recipient": {"id": user_id}, + "message": {"text": message}, + } + result = _graph_post_json( + f"{ig_id}/messages", + payload, + token, + ) + return jsonify({"id": result.get("id"), "status": "sent"}) + except urllib.error.HTTPError as e: + error_body = e.read().decode() + # If user search fails, try sending directly with username + if "ig_users" in str(e.url): + try: + payload = { + "recipient": {"username": recipient}, + "message": {"text": message}, + } + result = _graph_post_json( + f"{ig_id}/messages", + payload, + token, + ) + return jsonify({"id": result.get("id"), "status": "sent"}) + except Exception as e2: + return jsonify({"error": str(e2)}), 500 + return jsonify({"error": error_body}), e.code + + +@bp.route("/api/instagram/dm//conversations") +def instagram_conversations(account_idx): + """Get DM conversations.""" + accounts = _get_ig_accounts() + account = next((a for a in accounts if a["index"] == account_idx), None) + if not account: + return jsonify({"error": "Account not found"}), 404 + + try: + result = _graph_get( + f"{account['account_id']}/conversations", + {"fields": "id,participants,messages{id,text,from,to,timestamp}", "limit": 25}, + account["access_token"], + ) + return jsonify(result) + except urllib.error.HTTPError as e: + return jsonify({"error": e.read().decode()}), e.code + + +# ── API: Media ────────────────────────────────────────────────────────────── + +@bp.route("/api/instagram/media/") +def instagram_media(account_idx): + """Get recent media for an account.""" + accounts = _get_ig_accounts() + account = next((a for a in accounts if a["index"] == account_idx), None) + if not account: + return jsonify({"error": "Account not found"}), 404 + + limit = request.args.get("limit", 25, type=int) + + try: + result = _graph_get( + f"{account['account_id']}/media", + { + "fields": "id,caption,media_type,media_url,thumbnail_url,permalink,timestamp,like_count,comments_count", + "limit": limit, + }, + account["access_token"], + ) + return jsonify(result) + except urllib.error.HTTPError as e: + return jsonify({"error": e.read().decode()}), e.code + + +# ── API: Insights ─────────────────────────────────────────────────────────── + +@bp.route("/api/instagram/insights/") +def instagram_insights(account_idx): + """Get account insights.""" + accounts = _get_ig_accounts() + account = next((a for a in accounts if a["index"] == account_idx), None) + if not account: + return jsonify({"error": "Account not found"}), 404 + + try: + result = _graph_get( + f"{account['account_id']}/insights", + { + "metric": "impressions,reach,profile_views,follower_count", + "period": "day", + }, + account["access_token"], + ) + return jsonify(result) + except urllib.error.HTTPError as e: + return jsonify({"error": e.read().decode()}), e.code diff --git a/dashboard/backend/routes/integrations.py b/dashboard/backend/routes/integrations.py index 972cb049..d876c22a 100644 --- a/dashboard/backend/routes/integrations.py +++ b/dashboard/backend/routes/integrations.py @@ -23,7 +23,7 @@ WORKSPACE = Path(__file__).resolve().parent.parent.parent.parent SKILLS_DIR = WORKSPACE / ".claude" / "skills" PLUGINS_DIR = WORKSPACE / "plugins" -DB_PATH = WORKSPACE / "dashboard" / "data" / "dashboard.db" +DB_PATH = WORKSPACE / "dashboard" / "data" / "evonexus.db" # # Each entry declares the env vars that must all be set for the integration to diff --git a/dashboard/backend/routes/mempalace.py b/dashboard/backend/routes/mempalace.py index a20e947a..ee64250c 100644 --- a/dashboard/backend/routes/mempalace.py +++ b/dashboard/backend/routes/mempalace.py @@ -30,7 +30,38 @@ def _mempalace_available(): return False, None +# Fontes seedadas no primeiro uso — as memórias do workspace e dos agentes +# são o motivo de existir da base (recall semântico após /clear). O usuário +# pode remover/adicionar pela UI normalmente; o seed só acontece quando +# sources.json ainda não existe. +DEFAULT_SOURCES = [ + ("memory", "Memória do workspace", "memoria"), + (".claude/agent-memory", "Memória dos agentes", "agentes"), + ("workspace/development", "Artefatos de desenvolvimento", "desenvolvimento"), +] + + +def _seed_default_sources(): + now = datetime.now(timezone.utc).isoformat() + sources = [] + for rel, label, wing in DEFAULT_SOURCES: + path = (WORKSPACE / rel).resolve() + if path.is_dir(): + sources.append({ + "path": str(path), + "label": label, + "wing": wing, + "added_at": now, + "last_indexed": None, + }) + if sources: + _save_sources(sources) + return sources + + def _load_sources(): + if not SOURCES_FILE.exists(): + return _seed_default_sources() try: return json.loads(SOURCES_FILE.read_text(encoding="utf-8")) except Exception: @@ -45,8 +76,18 @@ def _save_sources(sources): def _get_mining_status(): try: status = json.loads(MINING_STATUS_FILE.read_text(encoding="utf-8")) - # Check if process is still alive pid = status.get("pid") + # O worker publica phase="done" antes de sair — o check de PID sozinho + # não basta: o child exitado vira zumbi (ninguém dá wait()) e + # os.kill(pid, 0) segue passando, prendendo a UI em "in progress". + if status.get("phase") in ("done", "error"): + if pid: + try: + os.waitpid(pid, os.WNOHANG) # reap do zumbi (best-effort) + except (OSError, ChildProcessError): + pass + MINING_STATUS_FILE.unlink(missing_ok=True) + return None if pid: try: os.kill(pid, 0) diff --git a/dashboard/backend/routes/overview.py b/dashboard/backend/routes/overview.py index 79df49ba..51853d98 100644 --- a/dashboard/backend/routes/overview.py +++ b/dashboard/backend/routes/overview.py @@ -88,8 +88,18 @@ def _build_routines(raw_metrics: dict) -> list[dict]: """Transform raw metrics into routines table.""" routines = [] for name, v in sorted(raw_metrics.items(), key=lambda x: x[1].get("last_run", ""), reverse=True): - rate = v.get("success_rate", 0) - status = "healthy" if rate >= 90 else ("warning" if rate >= 50 else "critical") + # Derived fresh from raw counts, not the stored success_rate field — + # see the comment in Routines.tsx's transformRoutineMetrics for why + # (one routine used to write it as a 0-1 fraction instead of 0-100). + runs_v = v.get("runs", 0) + rate = round((v.get("successes", 0) / runs_v) * 100, 1) if runs_v > 0 else 0 + last_success = v.get("last_success") + if last_success is False: + status = "critical" + elif last_success is True and rate < 90: + status = "warning" + else: + status = "healthy" if rate >= 90 else ("warning" if rate >= 50 else "critical") routines.append({ "name": name, "last_run": (v.get("last_run") or "")[:16], diff --git a/dashboard/backend/routes/plugin_public_pages.py b/dashboard/backend/routes/plugin_public_pages.py new file mode 100644 index 00000000..3b88264d --- /dev/null +++ b/dashboard/backend/routes/plugin_public_pages.py @@ -0,0 +1,431 @@ +"""Plugin public pages — unauthenticated token-bound portals (B2.0). + +Routes registered here bypass the ``before_request`` auth gate in ``app.py``. +The host validates the URL token against a plugin-declared column in a +plugin-owned table on every request. + +B2.0 scope (read-only, no PIN): + GET /p/// — serve portal bundle + GET /p////data — serve public readonly query + GET /p//public-assets/ — serve ui/public/ static assets + +B2.1 (PIN + writable + token-bind) is deferred. + +Security controls applied here: + - Rate limit 60 req/min/IP (from rate_limit.py) on portal + data endpoints + - Vault §B2.S2: Referrer-Policy, Cache-Control no-store, HSTS on every response + - Token validated parametrically (no SQL injection risk on token value) + - table/column identifiers validated via PluginPublicPage schema at install time + - Path traversal prevented by realpath + startswith containment check + - MIME whitelist on public asset serving +""" + +from __future__ import annotations + +import os +import sqlite3 +from pathlib import Path +from typing import Any, Dict, Optional + +from flask import Blueprint, abort, jsonify, request, Response, after_this_request + +from models import audit +from rate_limit import limiter + +bp = Blueprint("plugin_public_pages", __name__) + +# Resolved once at module load; identical to plugins.py pattern. +WORKSPACE = Path(__file__).resolve().parent.parent.parent.parent +PLUGINS_DIR = WORKSPACE / "plugins" +DB_PATH = WORKSPACE / "dashboard" / "data" / "evonexus.db" + +# --------------------------------------------------------------------------- +# Module-level public prefix cache. +# Updated on install/uninstall via register_public_prefix / unregister_public_prefix. +# Read by app.py before_request middleware to bypass auth for /p/... paths. +# --------------------------------------------------------------------------- + +# Set of string prefixes, each entry like "/p/nutri/portal" +_PLUGIN_PUBLIC_PREFIXES: set[str] = set() + + +def register_public_prefix(slug: str, route_prefix: str) -> None: + """Add a plugin's public route prefix to the auth bypass cache. + + Called by plugin_loader.py (or routes/plugins.py) after a successful install. + """ + _PLUGIN_PUBLIC_PREFIXES.add(f"/p/{slug}/{route_prefix}") + + +def unregister_public_prefix(slug: str, route_prefix: str) -> None: + """Remove a plugin's public route prefix from the auth bypass cache. + + Called by routes/plugins.py during uninstall. + """ + _PLUGIN_PUBLIC_PREFIXES.discard(f"/p/{slug}/{route_prefix}") + + +def get_public_prefixes() -> frozenset[str]: + """Read-only snapshot of the current public prefix set. + + Used by app.py before_request middleware. + """ + return frozenset(_PLUGIN_PUBLIC_PREFIXES) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _security_headers(response: Response) -> Response: + """Vault §B2.S2: mandatory security headers on all public-page responses.""" + response.headers["Referrer-Policy"] = "no-referrer" + response.headers["Cache-Control"] = "no-store, private, no-cache, must-revalidate" + response.headers["Pragma"] = "no-cache" + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["Strict-Transport-Security"] = "max-age=63072000; includeSubDomains" + return response + + +def _get_db() -> sqlite3.Connection: + conn = sqlite3.connect(str(DB_PATH), timeout=10) + conn.row_factory = sqlite3.Row + return conn + + +def _load_page_config(slug: str, route_prefix: str) -> Optional[Dict[str, Any]]: + """Return the installed public_pages config for the given slug + route_prefix. + + Reads from the manifest stored in plugins_installed (same pattern as plugins.py). + Returns None if not found or not installed. + """ + import json as _json + conn = _get_db() + try: + row = conn.execute( + "SELECT manifest_json FROM plugins_installed WHERE slug = ? AND status = 'active'", + (slug,), + ).fetchone() + if not row: + return None + manifest = _json.loads(row["manifest_json"]) + for page in manifest.get("public_pages") or []: + if page.get("route_prefix") == route_prefix: + return page + return None + finally: + conn.close() + + +def _validate_token(page_config: Dict[str, Any], token: str) -> bool: + """Validate the URL token against the plugin-declared token_source column. + + Uses a parametric query — only the `?` value is user-supplied. + Table and column names come from the manifest (validated at install by + PluginPublicPage schema; both are slug-prefixed and identifier-safe). + """ + token_source = page_config.get("token_source", {}) + table = token_source.get("table", "") + column = token_source.get("column", "") + + if not table or not column: + return False + + # Identifiers are validated at install time (PluginPublicPage schema) to + # match ^[a-z][a-z0-9_]*$ — safe to interpolate here. + sql = f"SELECT 1 FROM {table} WHERE {column} = ?" # noqa: S608 — identifiers whitelisted at install + + # The plugin DB is kept inside the plugin's own data directory. + # EvoNexus uses the shared evonexus.db for all plugin tables (no per-plugin DB). + conn = _get_db() + try: + row = conn.execute(sql, (token,)).fetchone() + return row is not None + except sqlite3.OperationalError: + # Table doesn't exist yet (e.g. install in progress) — fail closed. + return False + finally: + conn.close() + + +def _serve_bundle(slug: str, bundle_path: str) -> Response: + """Serve a plugin's ui/public/ bundle file (no auth check needed here — + caller already verified token; bundle is the entire page shell). + + ``bundle_path`` is relative to the plugin dir (e.g. "ui/public/portal.js"). + """ + plugin_dir = PLUGINS_DIR / slug + ui_public_root = os.path.realpath(str(plugin_dir / "ui" / "public")) + # Strip "ui/public/" prefix to get the sub-path + relative = bundle_path[len("ui/public/"):] + requested = os.path.realpath(os.path.join(ui_public_root, relative)) + + # Containment check — must stay inside plugins/{slug}/ui/public/ + if not requested.startswith(ui_public_root + os.sep) and requested != ui_public_root: + abort(404) + + if not os.path.isfile(requested): + abort(404) + + ext = os.path.splitext(requested)[1].lower() + mime_map = { + ".js": "application/javascript; charset=utf-8", + ".mjs": "application/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".html": "text/html; charset=utf-8", + } + mime = mime_map.get(ext) + if not mime: + abort(404) + + with open(requested, "rb") as fh: + content = fh.read() + + resp = Response(content, mimetype=mime) + resp.headers["X-Content-Type-Options"] = "nosniff" + # Content-Security-Policy: restrict resource loading to same origin. + # 'unsafe-inline' is included for inline scripts in plugin bundles (Web Component pattern). + resp.headers["Content-Security-Policy"] = ( + "default-src 'self'; script-src 'self' 'unsafe-inline'; " + "style-src 'self' 'unsafe-inline'; img-src 'self' data:; " + "connect-src 'self'; frame-ancestors 'none'" + ) + return resp + + +# --------------------------------------------------------------------------- +# Endpoints +# --------------------------------------------------------------------------- + +@bp.route("/p///", methods=["GET"]) +@limiter.limit("60 per minute") +def portal_page(slug: str, route_prefix: str, token: str): + """Serve the plugin portal page after validating the URL token. + + Flow: + 1. Load page config from plugins_installed manifest. + 2. Validate token against token_source.column (parametric SQL). + 3. Serve the plugin's ui/public/ bundle. + 4. Apply security headers. + """ + @after_this_request + def _headers(response: Response) -> Response: + return _security_headers(response) + + page_config = _load_page_config(slug, route_prefix) + if not page_config: + return jsonify({"error": "Link inválido ou expirado", "code": "not_found"}), 404 + + if not _validate_token(page_config, token): + ip = request.remote_addr or "-" + audit( + None, + page_config.get("audit_action") or "portal_view_denied", + f"plugins/{slug}/public_pages/{route_prefix}", + detail=f"token={token[:8]}... ip={ip} reason=token_invalid", + ) + return jsonify({"error": "Link inválido ou expirado", "code": "not_found"}), 404 + + # Token valid — log successful view + ip = request.remote_addr or "-" + ua = (request.headers.get("User-Agent", "-") or "-")[:200] + audit( + None, + page_config.get("audit_action") or "portal_view", + f"plugins/{slug}/public_pages/{route_prefix}", + detail=f"token={token[:8]}... ip={ip} ua={ua[:80]}", + ) + + bundle_path = page_config.get("bundle", "") + + # Wave 2.1.x — content negotiation: browsers send Accept: text/html and + # expect a rendered page; programmatic clients (or asset prefetchers) can + # fetch the raw bundle by NOT sending text/html in Accept (or by hitting + # /p/{slug}/public-assets/{file} directly, which does not require a token). + # + # When the caller wants HTML, generate a minimal shell that loads the bundle + # and instantiates the declared custom element. Without this, browsers see + # the JS source. With this, the portal renders. + accept = (request.headers.get("Accept") or "").lower() + wants_html = "text/html" in accept and "application/javascript" not in accept + if wants_html: + return _serve_html_shell(slug, page_config, token) + + return _serve_bundle(slug, bundle_path) + + +def _serve_html_shell(slug: str, page_config: dict, token: str) -> Response: + """Render a minimal HTML page that boots the plugin's custom element. + + The shell is generated server-side so plugin authors only ship a JS bundle + (custom element definition). The bundle is fetched as a module from + /p/{slug}/public-assets/{file} (no auth required for assets — they contain + no patient data; data lives behind the token-gated /data endpoint). + + The custom element receives the URL token via ``data-token`` attribute so + the bundle does not need to re-parse window.location. + """ + from html import escape as h + bundle_path = page_config.get("bundle", "") or "" + if not bundle_path.startswith("ui/public/"): + abort(404) + bundle_relative = bundle_path[len("ui/public/"):] + custom_element = page_config.get("custom_element_name") or "" + if not custom_element or not all(c.isalnum() or c in "-" for c in custom_element): + # Defense in depth — schema already validates this on install + abort(500) + label = page_config.get("description") or page_config.get("label") or "Portal" + + # CSP for the shell: + # - script-src 'self' allows the bundle module from same origin (public-assets/) + # - style-src 'self' 'unsafe-inline' covers minimal inline shell styling + # - img-src 'self' data: covers logos embedded as data URIs (brand workaround) + # - connect-src 'self' so the bundle can fetch /p/{slug}/{route}/{token}/data + asset_url = f"/p/{h(slug)}/public-assets/{h(bundle_relative)}" + body = ( + "" + "" + "" + "" + "" + f"{h(label)}" + "" + "" + f"<{custom_element} data-token=\"{h(token)}\" data-slug=\"{h(slug)}\">" + f"" + "" + ) + resp = Response(body, mimetype="text/html; charset=utf-8") + resp.headers["X-Content-Type-Options"] = "nosniff" + resp.headers["Content-Security-Policy"] = ( + "default-src 'self'; " + "script-src 'self' 'unsafe-inline'; " + "style-src 'self' 'unsafe-inline'; " + "img-src 'self' data:; " + "connect-src 'self'; " + "frame-ancestors 'none'" + ) + return resp + + +@bp.route("/p////data", methods=["GET"]) +@limiter.limit("120 per minute") +def portal_data(slug: str, route_prefix: str, token: str): + """Serve public readonly query results bound to the URL token. + + Requires a ``query_id`` query-string param that matches a declared + readonly_data entry with ``public_via`` pointing to this page. + """ + @after_this_request + def _headers(response: Response) -> Response: + return _security_headers(response) + + query_id = request.args.get("query_id", "").strip() + if not query_id: + return jsonify({"error": "query_id is required", "code": "bad_request"}), 400 + + page_config = _load_page_config(slug, route_prefix) + if not page_config: + return jsonify({"error": "Link inválido ou expirado", "code": "not_found"}), 404 + + if not _validate_token(page_config, token): + return jsonify({"error": "Link inválido ou expirado", "code": "not_found"}), 404 + + # Load readonly_data entries from the manifest to find the matching public query + import json as _json + conn_meta = _get_db() + try: + row = conn_meta.execute( + "SELECT manifest_json FROM plugins_installed WHERE slug = ? AND status = 'active'", + (slug,), + ).fetchone() + if not row: + return jsonify({"error": "Plugin not found", "code": "not_found"}), 404 + manifest = _json.loads(row["manifest_json"]) + finally: + conn_meta.close() + + # Find the query + public_page_id = page_config.get("id") + query_spec = None + for q in manifest.get("readonly_data") or []: + if q.get("id") == query_id and q.get("public_via") == public_page_id: + query_spec = q + break + + if not query_spec: + return jsonify({"error": "Query not found or not public", "code": "not_found"}), 404 + + bind_param = query_spec.get("bind_token_param") + sql = query_spec.get("sql", "") + + # Execute query with token bound to the declared parameter + conn_data = _get_db() + try: + if bind_param: + rows = conn_data.execute(sql, {bind_param: token}).fetchall() + else: + rows = conn_data.execute(sql).fetchall() + results = [dict(r) for r in rows] + except sqlite3.OperationalError as exc: + return jsonify({"error": "Query execution failed", "detail": str(exc)}), 500 + finally: + conn_data.close() + + return jsonify({"query_id": query_id, "rows": results}) + + +@bp.route("/p//public-assets/", methods=["GET"]) +def portal_static(slug: str, subpath: str): + """Serve plugin static assets from ui/public/ (no token required). + + CSS, images, and other non-JS assets referenced by the portal bundle. + Path must stay within plugins/{slug}/ui/public/ (containment check). + """ + @after_this_request + def _headers(response: Response) -> Response: + return _security_headers(response) + + plugin_dir = PLUGINS_DIR / slug + ui_public_root = os.path.realpath(str(plugin_dir / "ui" / "public")) + requested = os.path.realpath(os.path.join(ui_public_root, subpath)) + + # Containment check + if not requested.startswith(ui_public_root + os.sep): + abort(404) + + if not os.path.isfile(requested): + abort(404) + + ext = os.path.splitext(requested)[1].lower() + mime_map = { + ".js": "application/javascript; charset=utf-8", + ".mjs": "application/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".webp": "image/webp", + ".json": "application/json; charset=utf-8", + ".html": "text/html; charset=utf-8", + ".ico": "image/x-icon", + ".woff2": "font/woff2", + ".woff": "font/woff", + ".ttf": "font/ttf", + } + mime = mime_map.get(ext) + if not mime: + abort(404) + + with open(requested, "rb") as fh: + content = fh.read() + + resp = Response(content, mimetype=mime) + resp.headers["X-Content-Type-Options"] = "nosniff" + # Static assets can be cached by the browser (shorter TTL for public portal) + resp.headers["Cache-Control"] = "public, max-age=300" + return resp diff --git a/dashboard/backend/routes/plugins.py b/dashboard/backend/routes/plugins.py index a7f33cdc..5b92d728 100644 --- a/dashboard/backend/routes/plugins.py +++ b/dashboard/backend/routes/plugins.py @@ -13,8 +13,11 @@ import os import shutil import sqlite3 +import subprocess +import tempfile import threading import time +import uuid from datetime import datetime, timezone from pathlib import Path from typing import Any @@ -778,6 +781,40 @@ def install_plugin(): except RuntimeError as exc: return jsonify({"error": str(exc)}), 409 + # B3: Check for orphaned tables from a previous uninstall (safe_uninstall). + # If orphans exist, verify SHA256 to prevent hostile reinstall (Vault B3.S3). + _orphan_check_conn = _get_db() + try: + _orphan_rows = _orphan_check_conn.execute( + "SELECT tablename, original_sha256, original_plugin_version FROM plugin_orphans " + "WHERE slug = ? AND recovered_at IS NULL", + (slug,), + ).fetchall() + except Exception: + _orphan_rows = [] + finally: + _orphan_check_conn.close() + + if _orphan_rows: + # Verify SHA256: the plugin being installed must match what was originally installed. + _install_sha256 = tarball_sha256 or "" + _original_sha256s = {row[1] for row in _orphan_rows if row[1]} + if _original_sha256s and _install_sha256: + if _install_sha256 not in _original_sha256s: + _admin_confirm = data.get("confirmed_sha256_change", False) + if not _admin_confirm: + return jsonify({ + "error": "sha256_mismatch", + "detail": ( + "Source changed since last install — possible hostile reinstall. " + "This plugin has orphaned tables from a previous install. " + "Pass confirmed_sha256_change=true to override (will be audited)." + ), + "orphaned_tables": [row[0] for row in _orphan_rows], + "expected_sha256": list(_original_sha256s), + "provided_sha256": _install_sha256, + }), 409 + plugin_dir = PLUGINS_DIR / slug state: dict[str, Any] = { "slug": slug, @@ -788,6 +825,40 @@ def install_plugin(): conn = _get_db() try: + # B3: Recover orphaned tables BEFORE copying/migrating (Vault B3.S3). + # Rename _orphan_{slug}_{table} back to {table} so install.sql can use them. + _recovered_tables: list[str] = [] + if _orphan_rows: + _recovery_conn = _get_db() + try: + for _orphan_row in _orphan_rows: + _orig_table = _orphan_row[0] + _orphan_table_name = f"_orphan_{slug}_{_orig_table}" + _existing = { + row[0] for row in _recovery_conn.execute( + "SELECT name FROM sqlite_master WHERE type='table'" + ).fetchall() + } + if _orphan_table_name in _existing: + _recovery_conn.execute( + f"ALTER TABLE {_orphan_table_name} RENAME TO {_orig_table}" + ) + _recovery_conn.commit() + _recovered_tables.append(_orig_table) + logger.info("B3 reinstall: recovered orphaned table '%s'", _orig_table) + + # Mark orphans as recovered in plugin_orphans + if _recovered_tables: + _now = _now_iso() + for _t in _recovered_tables: + _recovery_conn.execute( + "UPDATE plugin_orphans SET recovered_at = ? WHERE slug = ? AND tablename = ?", + (_now, slug, _t), + ) + _recovery_conn.commit() + finally: + _recovery_conn.close() + # --- Step: copy plugin source to plugins/{slug}/ --- plugin_dir.mkdir(parents=True, exist_ok=True) @@ -1164,9 +1235,172 @@ def uninstall_plugin(slug: str): if not plugin_dir.exists(): return jsonify({"error": f"Plugin '{slug}' not found"}), 404 + # --- B3: safe_uninstall enforcement --- + # Load the installed manifest to check if safe_uninstall capability is declared. + _force_uninstall = os.environ.get("EVONEXUS_ALLOW_FORCE_UNINSTALL", "").strip() == "1" + _manifest_for_b3: dict = {} + _safe_uninstall_spec: dict = {} + try: + _manifest_conn = _get_db() + _manifest_row = _manifest_conn.execute( + "SELECT manifest_json FROM plugins_installed WHERE slug = ?", (slug,) + ).fetchone() + _manifest_conn.close() + if _manifest_row: + _manifest_for_b3 = json.loads(_manifest_row["manifest_json"] or "{}") + _safe_uninstall_spec = _manifest_for_b3.get("safe_uninstall") or {} + except Exception as _exc: + logger.warning("B3: could not load manifest for safe_uninstall check: %s", _exc) + + _su_enabled = _safe_uninstall_spec.get("enabled", False) + _block_uninstall = _safe_uninstall_spec.get("block_uninstall", False) + + if _block_uninstall and not _force_uninstall: + return jsonify({ + "error": "uninstall_blocked", + "detail": _safe_uninstall_spec.get("reason", "Plugin has declared block_uninstall: true."), + "code": "blocked", + }), 409 + + if _su_enabled and not _force_uninstall: + # Vault B3.S1: backend enforcement — require admin + confirmation_phrase + exported_at + if not hasattr(current_user, "role") or getattr(current_user, "role", None) != "admin": + return jsonify({ + "error": "admin_required", + "detail": "Only admin users may uninstall plugins with safe_uninstall enabled.", + "code": "forbidden", + }), 403 + + _body = request.get_json(force=True, silent=True) or {} + _phrase_required = (_safe_uninstall_spec.get("user_confirmation") or {}).get("typed_phrase", "") + _phrase_given = _body.get("confirmation_phrase", "") + if _phrase_required and _phrase_given != _phrase_required: + return jsonify({ + "error": "confirmation_phrase_mismatch", + "detail": f"Typed phrase must be exactly: {_phrase_required}", + "code": "bad_request", + }), 400 + + _exported_at = _body.get("exported_at", "") + if _exported_at: + if not os.path.exists(_exported_at): + return jsonify({ + "error": "export_file_not_found", + "detail": f"Export file not found at path: {_exported_at}", + "code": "bad_request", + }), 400 + + # Vault B3.S1: zip_password must be present (the actual encryption happens in the pre-hook) + _zip_password = _body.get("zip_password", "") + if not _zip_password: + return jsonify({ + "error": "zip_password_required", + "detail": "A ZIP password is required to encrypt the export archive.", + "code": "bad_request", + }), 400 + + if _force_uninstall: + # Vault B3.S6: force-uninstall MUST produce an audit row with reason + _force_reason = (request.get_json(force=True, silent=True) or {}).get("force_reason", "") + logger.warning( + "FORCE UNINSTALL activated for '%s' (EVONEXUS_ALLOW_FORCE_UNINSTALL=1). reason=%r user=%s", + slug, _force_reason, getattr(current_user, "username", "unknown"), + ) + # --- End B3 enforcement gate --- + conn = _get_db() + _orphan_records: list[str] = [] # B3: populated during orphan table rename phase try: - # Pre-uninstall hook + # B3: Sandboxed pre-uninstall hook (Vault B3.S2) + # Run BEFORE the legacy hook so it has access to DB state. + _su_hook_spec = _safe_uninstall_spec.get("pre_uninstall_hook") or {} + if _su_enabled and not _force_uninstall and _su_hook_spec: + _hook_script = _su_hook_spec.get("script", "") + _hook_output_dir_template = _su_hook_spec.get("output_dir", "") + _hook_timeout = _su_hook_spec.get("timeout_seconds", 600) + _must_produce = _su_hook_spec.get("must_produce_file", True) + _hook_script_path = plugin_dir / _hook_script + + if _hook_script_path.exists(): + _ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") + _output_dir_str = _hook_output_dir_template.format(slug=slug, timestamp=_ts) + _output_dir_path = (WORKSPACE / _output_dir_str).resolve() + _output_dir_path.mkdir(parents=True, exist_ok=True) + + # Create a read-only copy of the DB for the hook (Vault B3.S2) + _db_readonly_path = "" + try: + _tmp_db = tempfile.NamedTemporaryFile(suffix=".db", delete=False) + _tmp_db.close() + _tmp_db_path = _tmp_db.name + _src_conn = sqlite3.connect(str(DB_PATH)) + _bk_conn = sqlite3.connect(_tmp_db_path) + _src_conn.backup(_bk_conn) + _src_conn.close() + _bk_conn.close() + _db_readonly_path = _tmp_db_path + except Exception as _dbe: + logger.warning("B3: could not create DB snapshot for hook: %s", _dbe) + + # Vault B3.S2: locked-down env — NO BRAIN_REPO_MASTER_KEY + _hook_env = { + "PATH": os.environ.get("PATH", "/usr/bin:/bin"), + "PLUGIN_SLUG": slug, + "PLUGIN_VERSION": _manifest_for_b3.get("version", ""), + "OUTPUT_DIR": str(_output_dir_path), + "DB_READONLY_PATH": _db_readonly_path, + } + + try: + _proc = subprocess.run( + ["python3", str(_hook_script_path)], + cwd=str(plugin_dir), + env=_hook_env, + capture_output=True, + text=True, + timeout=_hook_timeout, + ) + _hook_stdout = _proc.stdout[:5000] + _hook_stderr = _proc.stderr[:5000] + _hook_exit = _proc.returncode + + _audit(conn, slug, "safe_uninstall_hook", { + "exit_code": _hook_exit, + "stdout": _hook_stdout, + "stderr": _hook_stderr, + "output_dir": str(_output_dir_path), + }) + + if _hook_exit != 0: + return jsonify({ + "error": "pre_hook_failed", + "detail": "Pre-uninstall hook failed — uninstall aborted to prevent data loss.", + "exit_code": _hook_exit, + "stderr": _hook_stderr, + }), 400 + + if _must_produce: + _produced = any(_output_dir_path.iterdir()) if _output_dir_path.exists() else False + if not _produced: + return jsonify({ + "error": "pre_hook_no_output", + "detail": "Pre-uninstall hook produced no files — uninstall aborted to prevent data loss.", + }), 400 + + except subprocess.TimeoutExpired: + return jsonify({ + "error": "pre_hook_timeout", + "detail": f"Pre-uninstall hook exceeded timeout of {_hook_timeout}s.", + }), 400 + finally: + # Clean up DB snapshot + if _db_readonly_path: + try: + os.unlink(_db_readonly_path) + except Exception: + pass + + # Legacy pre-uninstall hook (non-B3 path) pre_hook = plugin_dir / "hooks" / "pre-uninstall.sh" if pre_hook.exists(): try: @@ -1219,14 +1453,75 @@ def uninstall_plugin(slug: str): # Delete host rows this plugin seeded (goals/tasks/triggers capabilities). # DELETE WHERE source_plugin = ? leaves user-created rows untouched. # Order matters because of FKs: children → parents. + # B3: respect preserved_host_entities filters from safe_uninstall spec. + _preserved_host_entities = _safe_uninstall_spec.get("preserved_host_entities") or {} for _tbl in ("triggers", "tickets", "goal_tasks", "goals", "projects", "missions"): try: - conn.execute(f"DELETE FROM {_tbl} WHERE source_plugin = ?", (slug,)) + _where = "source_plugin = ?" + if _tbl in _preserved_host_entities and not _force_uninstall: + # Preserve rows matching the declared WHERE clause. + # Only the base condition (source_plugin = ?) is parameterized; + # the preservation clause comes from the manifest (validated at install). + _preserve_clause = _preserved_host_entities[_tbl] + _where = f"(source_plugin = ?) AND NOT ({_preserve_clause})" + conn.execute(f"DELETE FROM {_tbl} WHERE {_where}", (slug,)) conn.commit() except Exception as exc: logger.warning("Uninstall: failed to clean %s: %s", _tbl, exc) - # SQL uninstall + # B3: Rename preserved tables to _orphan_{slug}_{tablename} BEFORE SQL uninstall. + # This removes them from the plugin namespace (Vault B3.S4) and records them + # in plugin_orphans so reinstall can detect and recover them. + _preserved_tables = _safe_uninstall_spec.get("preserved_tables") or [] + if _preserved_tables and _su_enabled and not _force_uninstall: + _orphan_conn = sqlite3.connect(str(DB_PATH)) + try: + _existing_tables_set = { + row[0] for row in _orphan_conn.execute( + "SELECT name FROM sqlite_master WHERE type='table'" + ).fetchall() + } + _user_id = getattr(current_user, "id", None) + _plugin_version = _manifest_for_b3.get("version", "") + _plugin_sha256 = _manifest_for_b3.get("source_sha256", "") + _plugin_publisher_url = _manifest_for_b3.get("source_url", "") + + for _orig_table in _preserved_tables: + if _orig_table not in _existing_tables_set: + logger.info("B3: preserved table '%s' does not exist, skipping", _orig_table) + continue + _orphan_name = f"_orphan_{slug}_{_orig_table}" + try: + # Rename to orphan name + _orphan_conn.execute(f"ALTER TABLE {_orig_table} RENAME TO {_orphan_name}") + _orphan_conn.commit() + logger.info("B3: renamed '%s' to '%s'", _orig_table, _orphan_name) + + # Record in plugin_orphans + _orphan_conn.execute( + "INSERT OR REPLACE INTO plugin_orphans " + "(id, slug, tablename, orphaned_at, orphaned_by_user_id, " + " original_plugin_version, original_sha256, original_publisher_url) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ( + str(uuid.uuid4()), + slug, + _orig_table, + _now_iso(), + _user_id, + _plugin_version, + _plugin_sha256, + _plugin_publisher_url, + ), + ) + _orphan_conn.commit() + _orphan_records.append(_orig_table) + except Exception as _te: + logger.warning("B3: failed to rename table '%s': %s", _orig_table, _te) + finally: + _orphan_conn.close() + + # SQL uninstall (runs after preserved tables are renamed — DROP won't touch them) uninstall_sql = plugin_dir / "migrations" / "uninstall.sql" if uninstall_sql.exists(): try: @@ -1294,10 +1589,15 @@ def uninstall_plugin(slug: str): # Reload scheduler _reload_scheduler() - _audit(conn, slug, "uninstall", { + _audit_action = "plugin_uninstall_safe" if (_su_enabled and not _force_uninstall) else "uninstall" + if _force_uninstall: + _audit_action = "plugin_uninstall_force" + _audit(conn, slug, _audit_action, { "removed_env_keys": _removed_env_keys, "removed_health_cache_count": _health_cache_removed, "mcp_audit": _mcp_audit, + "preserved_tables": _orphan_records, + "force_uninstall": _force_uninstall, }, success=True) invalidate_agent_meta_cache() return jsonify({ @@ -1305,6 +1605,7 @@ def uninstall_plugin(slug: str): "status": "uninstalled", "mcp_audit": _mcp_audit, "removed_env_keys": _removed_env_keys, + "preserved_tables": _orphan_records, }) except Exception as exc: @@ -1820,10 +2121,17 @@ def readonly_data(slug: str, query_name: str): if not sql: return jsonify({"error": "Invalid query declaration"}), 500 - # Build query params from request.args — only declared params allowed + # Build query params from request.args — only declared params allowed. + # Wave 2.1.x reserved params (current_user_id, current_user_role) are + # injected server-side below and MUST NOT come from the client. + _RESERVED_PARAMS = {"current_user_id", "current_user_role"} declared_params = query_decl.get("params", {}) params: dict = {} for key, value in request.args.items(): + if key in _RESERVED_PARAMS: + return jsonify({ + "error": f"Parameter '{key}' is reserved and cannot be supplied by the client" + }), 400 if key not in declared_params: return jsonify({"error": f"Parameter '{key}' not declared in manifest"}), 400 params[key] = value @@ -1845,6 +2153,15 @@ def readonly_data(slug: str, query_name: str): elif ":limit" in sql: params["limit"] = 1000 + # Wave 2.1.x — auto-inject current_user identity bind params (Gap 5 fix + # from evonexus-plugin-nutri Step 3). Plugins reference these as + # :current_user_id and :current_user_role in their SQL to enforce + # server-side scoping (e.g. `WHERE primary_nutritionist_id = :current_user_id`). + # These keys are reserved — manifest params with the same name are + # silently overridden. Always present, regardless of declaration. + params["current_user_id"] = getattr(current_user, "id", None) + params["current_user_role"] = getattr(current_user, "role", "viewer") + try: conn = _get_db() cur = conn.execute(sql, params) @@ -1926,6 +2243,19 @@ def writable_data(slug: str, resource_id: str): ) return jsonify({"error": "Internal manifest error"}), 500 + # Wave 2.1.x — endpoint-level RBAC enforcement (Gap 1 fix from + # evonexus-plugin-nutri Step 3 RBAC decision). When requires_role is set + # in the manifest, only users whose role is in the list may mutate. + # 'admin' always passes (super-user override). + requires_role = resource_decl.get("requires_role") + if requires_role: + actor_role = getattr(current_user, "role", "viewer") + if actor_role != "admin" and actor_role not in requires_role: + return jsonify({ + "error": f"Resource '{resource_id}' requires role in {requires_role}, " + f"current role is '{actor_role}'" + }), 403 + allowed_columns: list[str] = resource_decl.get("allowed_columns") or [] method = request.method diff --git a/dashboard/backend/routes/providers.py b/dashboard/backend/routes/providers.py index a2127ef8..65a00665 100644 --- a/dashboard/backend/routes/providers.py +++ b/dashboard/backend/routes/providers.py @@ -17,7 +17,7 @@ import urllib.parse from pathlib import Path -from flask import Blueprint, jsonify, redirect, request, session +from flask import Blueprint, jsonify, request, session, url_for from flask_login import login_required from routes._helpers import WORKSPACE @@ -25,14 +25,18 @@ bp = Blueprint("providers", __name__) PROVIDERS_CONFIG = WORKSPACE / "config" / "providers.json" +PROVIDERS_EXAMPLE_CONFIG = WORKSPACE / "config" / "providers.example.json" OPENAI_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" OPENAI_AUTH_URL = "https://auth.openai.com/oauth/authorize" OPENAI_TOKEN_URL = "https://auth.openai.com/oauth/token" +OPENAI_BROWSER_REDIRECT_URI = "http://localhost:1455/auth/callback" CODEX_AUTH_FILE = Path.home() / ".codex" / "auth.json" +_CODEX_DEVICE_PROCESS = None +_CODEX_DEVICE_OUTPUT = "" # Allowlisted CLI commands — only these binaries can be spawned -ALLOWED_CLI_COMMANDS = frozenset({"claude", "openclaude"}) +ALLOWED_CLI_COMMANDS = frozenset({"claude", "openclaude", "opencode"}) # Allowlisted env var names — only these can be injected into subprocess ALLOWED_ENV_VARS = frozenset({ @@ -49,6 +53,7 @@ "CODEX_API_KEY", "GEMINI_API_KEY", "GEMINI_MODEL", + "NVIDIA_API_KEY", "AWS_REGION", "AWS_BEARER_TOKEN_BEDROCK", "ANTHROPIC_VERTEX_PROJECT_ID", @@ -56,16 +61,72 @@ }) +def _merge_provider_defaults(config: dict) -> tuple[dict, bool]: + """Merge providers.example.json into an existing config without overwriting secrets.""" + if not PROVIDERS_EXAMPLE_CONFIG.is_file(): + return config, False + + try: + defaults = json.loads(PROVIDERS_EXAMPLE_CONFIG.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return config, False + + changed = False + if not isinstance(config, dict): + config = {} + changed = True + + for key, value in defaults.items(): + if key == "providers": + continue + if key not in config: + config[key] = value + changed = True + + providers = config.setdefault("providers", {}) + if not isinstance(providers, dict): + config["providers"] = {} + providers = config["providers"] + changed = True + + for provider_id, default_provider in (defaults.get("providers") or {}).items(): + if provider_id not in providers or not isinstance(providers.get(provider_id), dict): + providers[provider_id] = default_provider + changed = True + continue + + provider = providers[provider_id] + for key, value in default_provider.items(): + if key == "env_vars": + env = provider.setdefault("env_vars", {}) + if not isinstance(env, dict): + provider["env_vars"] = {} + env = provider["env_vars"] + changed = True + for env_key, env_default in (value or {}).items(): + if env_key not in env: + env[env_key] = env_default + changed = True + elif key not in provider: + provider[key] = value + changed = True + + return config, changed + + def _read_config() -> dict: """Read providers.json. If missing, copy from providers.example.json.""" try: if not PROVIDERS_CONFIG.is_file(): - example = PROVIDERS_CONFIG.parent / "providers.example.json" - if example.is_file(): + if PROVIDERS_EXAMPLE_CONFIG.is_file(): import shutil as _shutil - _shutil.copy2(example, PROVIDERS_CONFIG) + _shutil.copy2(PROVIDERS_EXAMPLE_CONFIG, PROVIDERS_CONFIG) if PROVIDERS_CONFIG.is_file(): - return json.loads(PROVIDERS_CONFIG.read_text(encoding="utf-8")) + config = json.loads(PROVIDERS_CONFIG.read_text(encoding="utf-8")) + config, changed = _merge_provider_defaults(config) + if changed: + _write_config(config) + return config except (json.JSONDecodeError, OSError): pass return {"active_provider": "anthropic", "providers": {}} @@ -102,6 +163,8 @@ def _run_cli_version(command: str, env: dict | None = None) -> dict: result = subprocess.run(["openclaude", "--version"], **run_kwargs) # noqa: S603, S607 elif command == "claude": result = subprocess.run(["claude", "--version"], **run_kwargs) # noqa: S603, S607 + elif command == "opencode": + result = subprocess.run(["opencode", "--version"], **run_kwargs) # noqa: S603, S607 else: return {"installed": False, "version": None, "path": None} @@ -169,6 +232,64 @@ def _save_codex_auth(tokens: dict): CODEX_AUTH_FILE.write_text(json.dumps(auth_data, indent=2), encoding="utf-8") +def _external_url(endpoint: str) -> str: + """Build an externally visible URL behind Traefik/nginx when available.""" + proto = request.headers.get("X-Forwarded-Proto", request.scheme) + host = request.headers.get("X-Forwarded-Host", request.host) + if host: + return urllib.parse.urlunparse((proto, host, url_for(endpoint), "", "", "")) + return url_for(endpoint, _external=True) + + +def _exchange_openai_code(code: str, code_verifier: str, redirect_uri: str): + import requests as http_req + + return http_req.post(OPENAI_TOKEN_URL, data={ + "grant_type": "authorization_code", + "code": code, + "redirect_uri": redirect_uri, + "client_id": OPENAI_CLIENT_ID, + "code_verifier": code_verifier, + }, timeout=30) + + +def _activate_codex_provider(): + config = _read_config() + providers = config.get("providers", {}) + config["active_provider"] = "codex_auth" if "codex_auth" in providers else "openai" + _write_config(config) + + +def _strip_ansi(text: str) -> str: + return re.sub(r"\x1b\[[0-9;]*m", "", text or "") + + +def _codex_auth_has_token() -> bool: + if not CODEX_AUTH_FILE.is_file(): + return False + try: + auth = json.loads(CODEX_AUTH_FILE.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return False + tokens = auth.get("tokens", {}) + return bool( + tokens.get("access_token") + or auth.get("openai-codex", {}).get("access") + or auth.get("access_token") + ) + + +def _terminate_codex_device_process(): + global _CODEX_DEVICE_PROCESS + proc = _CODEX_DEVICE_PROCESS + if proc and proc.poll() is None: + try: + proc.terminate() + except OSError: + pass + _CODEX_DEVICE_PROCESS = None + + # ── Endpoints ────────────────────────────────────────────── @@ -180,16 +301,22 @@ def list_providers(): active = config.get("active_provider", "anthropic") providers = config.get("providers", {}) - # Check CLI installation status for both binaries + # Check CLI installation status for each binary claude_status = _check_cli("claude") openclaude_status = _check_cli("openclaude") + opencode_status = _check_cli("opencode") + cli_status_by_command = { + "claude": claude_status, + "openclaude": openclaude_status, + "opencode": opencode_status, + } result = [] for key, prov in providers.items(): cli = prov.get("cli_command", "claude") if cli not in ALLOWED_CLI_COMMANDS: continue - cli_status = claude_status if cli == "claude" else openclaude_status + cli_status = cli_status_by_command.get(cli, claude_status) # Mask env var values for API response env_vars = prov.get("env_vars", {}) @@ -230,6 +357,7 @@ def list_providers(): "active_provider": active, "claude_installed": claude_status["installed"], "openclaude_installed": openclaude_status["installed"], + "opencode_installed": opencode_status["installed"], }) @@ -317,6 +445,15 @@ def update_provider_config(provider_id): # Reject values with shell metacharacters if not isinstance(value, str) or re.search(r'[;&|`$\n\r]', value): continue + # Never wipe a stored secret with an empty field — the UI submits + # blank password inputs when the user edits other fields (e.g. model), + # which would silently erase the saved key. + if ( + value == "" + and existing.get(key) + and any(tag in key for tag in ("KEY", "SECRET", "TOKEN")) + ): + continue existing[key] = value provider["env_vars"] = existing @@ -374,11 +511,16 @@ def openai_auth_start(): session["openai_code_verifier"] = code_verifier session["openai_oauth_state"] = state + # This OAuth client is registered by OpenAI/Codex for the local CLI + # callback. Hydra rejects arbitrary public dashboard domains with + # authorize_hydra_invalid_request, so browser auth must keep the registered + # localhost redirect and let the user paste the final URL back into the UI. + session["openai_redirect_uri"] = OPENAI_BROWSER_REDIRECT_URI params = { "response_type": "code", "client_id": OPENAI_CLIENT_ID, - "redirect_uri": "http://localhost:1455/auth/callback", + "redirect_uri": OPENAI_BROWSER_REDIRECT_URI, "scope": "openid profile email offline_access api.connectors.read api.connectors.invoke", "code_challenge": code_challenge, "code_challenge_method": "S256", @@ -387,15 +529,47 @@ def openai_auth_start(): "codex_cli_simplified_flow": "true", } url = f"{OPENAI_AUTH_URL}?{urllib.parse.urlencode(params)}" - return jsonify({"authorize_url": url}) + return jsonify({"authorize_url": url, "redirect_uri": OPENAI_BROWSER_REDIRECT_URI}) + + +@bp.route("/auth/callback") +@login_required +def openai_auth_callback(): + """Complete Codex OAuth when OpenAI redirects back to the dashboard.""" + code = request.args.get("code") + state = request.args.get("state") + expected_state = session.pop("openai_oauth_state", None) + code_verifier = session.pop("openai_code_verifier", None) + redirect_uri = session.pop("openai_redirect_uri", None) or OPENAI_BROWSER_REDIRECT_URI + + if not code: + return "Codex OAuth falhou: callback sem codigo de autorizacao.", 400 + if expected_state and state != expected_state: + return "Codex OAuth falhou: state invalido.", 400 + if not code_verifier: + return "Codex OAuth falhou: sessao expirada. Volte ao dashboard e inicie o login novamente.", 400 + + resp = _exchange_openai_code(code, code_verifier, redirect_uri) + if resp.status_code != 200: + return f"Codex OAuth falhou na troca de token (HTTP {resp.status_code}).", 400 + + _save_codex_auth(resp.json()) + _activate_codex_provider() + return ( + "" + "Codex OAuth conectado" + "" + "

Codex OAuth conectado

" + "

Token salvo. Você já pode voltar ao EvoNexus.

" + "" + "" + ) @bp.route("/api/providers/openai/auth-complete", methods=["POST"]) @login_required def openai_auth_complete(): """Receive callback URL pasted by user, extract code, exchange for tokens.""" - import requests as http_req - data = request.get_json(silent=True) or {} callback_url = data.get("callback_url", "") @@ -411,26 +585,16 @@ def openai_auth_complete(): return jsonify({"error": "Sessao expirada - inicie o login novamente"}), 400 session.pop("openai_oauth_state", None) + redirect_uri = session.pop("openai_redirect_uri", None) or OPENAI_BROWSER_REDIRECT_URI - resp = http_req.post(OPENAI_TOKEN_URL, data={ - "grant_type": "authorization_code", - "code": code, - "redirect_uri": "http://localhost:1455/auth/callback", - "client_id": OPENAI_CLIENT_ID, - "code_verifier": code_verifier, - }, timeout=30) + resp = _exchange_openai_code(code, code_verifier, redirect_uri) if resp.status_code != 200: return jsonify({"error": f"Falha na troca de token (HTTP {resp.status_code})"}), 400 _save_codex_auth(resp.json()) - config = _read_config() - # Use dedicated codex_auth provider key when OAuth is used (falls back to - # openai for backward compatibility if codex_auth is not configured). - providers = config.get("providers", {}) - config["active_provider"] = "codex_auth" if "codex_auth" in providers else "openai" - _write_config(config) + _activate_codex_provider() return jsonify({"status": "ok", "message": "Autenticado com sucesso!"}) @@ -438,73 +602,122 @@ def openai_auth_complete(): @bp.route("/api/providers/openai/device-start", methods=["POST"]) @login_required def openai_device_start(): - """Start device auth flow.""" - import requests as http_req + """Start Codex device auth through the official Codex CLI. - resp = http_req.post("https://auth.openai.com/deviceauth/usercode", json={ - "client_id": OPENAI_CLIENT_ID, - }, timeout=15) + Direct POSTs to auth.openai.com/deviceauth/usercode are Cloudflare-gated + for ordinary HTTP clients. The Codex CLI performs the supported device + flow and persists ~/.codex/auth.json after authorization. + """ + global _CODEX_DEVICE_PROCESS, _CODEX_DEVICE_OUTPUT - if resp.status_code != 200: - return jsonify({"error": "Device auth nao disponivel para sua organizacao"}), 400 + codex_bin = shutil.which("codex") + if not codex_bin: + return jsonify({ + "error": "Codex CLI nao esta instalado no container", + "hint": "Atualize para uma imagem que inclua @openai/codex.", + }), 503 + + _terminate_codex_device_process() + _CODEX_DEVICE_OUTPUT = "" - data = resp.json() - session["openai_device_auth_id"] = data["device_auth_id"] - session["openai_device_user_code"] = data["user_code"] + try: + proc = subprocess.Popen( # noqa: S603 + [codex_bin, "login", "--device-auth"], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + env={**os.environ, "HOME": str(Path.home())}, + ) + except OSError as exc: + return jsonify({"error": f"Falha ao iniciar Codex CLI: {exc}"}), 500 + + _CODEX_DEVICE_PROCESS = proc + deadline = time.time() + 20 + output = "" + user_code = None + verification_url = "https://auth.openai.com/codex/device" + + while time.time() < deadline: + line = proc.stdout.readline() if proc.stdout else "" + if line: + output += line + clean = _strip_ansi(output) + url_match = re.search(r"https://auth\.openai\.com/codex/device", clean) + code_match = re.search(r"\b([A-Z0-9]{4}-[A-Z0-9]{5})\b", clean) + if url_match: + verification_url = url_match.group(0) + if code_match: + user_code = code_match.group(1) + break + elif proc.poll() is not None: + break + else: + time.sleep(0.1) + + _CODEX_DEVICE_OUTPUT = output + if not user_code: + rc = proc.poll() + _CODEX_DEVICE_PROCESS = None + return jsonify({ + "error": "Codex CLI nao retornou um codigo de device auth", + "exit_code": rc, + "output": _strip_ansi(output)[-1000:], + }), 500 + + session["openai_device_auth_started_at"] = int(time.time()) return jsonify({ - "user_code": data["user_code"], - "verification_url": "https://auth.openai.com/codex/device", - "interval": data.get("interval", 5), - "expires_in": data.get("expires_in", 900), + "user_code": user_code, + "verification_url": verification_url, + "interval": 5, + "expires_in": 900, + "method": "codex_cli", }) @bp.route("/api/providers/openai/device-poll", methods=["POST"]) @login_required def openai_device_poll(): - """Poll for device auth authorization.""" - import requests as http_req + """Poll the Codex CLI device-auth subprocess.""" + global _CODEX_DEVICE_PROCESS, _CODEX_DEVICE_OUTPUT - device_auth_id = session.get("openai_device_auth_id") - user_code = session.get("openai_device_user_code") - if not device_auth_id: + if not session.get("openai_device_auth_started_at"): return jsonify({"status": "error", "message": "Nenhum login pendente"}), 400 - resp = http_req.post("https://auth.openai.com/deviceauth/token", json={ - "device_auth_id": device_auth_id, - "user_code": user_code, - }, timeout=15) + if _codex_auth_has_token(): + _terminate_codex_device_process() + _activate_codex_provider() + session.pop("openai_device_auth_started_at", None) + return jsonify({"status": "authorized"}) - if resp.status_code in (403, 404): + proc = _CODEX_DEVICE_PROCESS + if not proc: return jsonify({"status": "pending"}) - if resp.status_code != 200: - return jsonify({"status": "error", "message": "Polling falhou"}), 500 - - auth_data = resp.json() - - token_resp = http_req.post(OPENAI_TOKEN_URL, data={ - "grant_type": "authorization_code", - "code": auth_data["authorization_code"], - "code_verifier": auth_data["code_verifier"], - "client_id": OPENAI_CLIENT_ID, - }, timeout=15) + rc = proc.poll() + if rc is None: + return jsonify({"status": "pending"}) - if token_resp.status_code != 200: - return jsonify({"status": "error", "message": "Token exchange falhou"}), 500 + try: + rest = proc.stdout.read() if proc.stdout else "" + except Exception: + rest = "" + _CODEX_DEVICE_OUTPUT += rest + _CODEX_DEVICE_PROCESS = None - _save_codex_auth(token_resp.json()) + if _codex_auth_has_token(): + _activate_codex_provider() + session.pop("openai_device_auth_started_at", None) + return jsonify({"status": "authorized"}) - config = _read_config() - providers = config.get("providers", {}) - config["active_provider"] = "codex_auth" if "codex_auth" in providers else "openai" - _write_config(config) - - session.pop("openai_device_auth_id", None) - session.pop("openai_device_user_code", None) + return jsonify({ + "status": "error", + "message": "Codex device auth terminou sem salvar token", + "exit_code": rc, + "output": _strip_ansi(_CODEX_DEVICE_OUTPUT)[-1000:], + }), 500 - return jsonify({"status": "authorized"}) @bp.route("/api/providers/openai/status") diff --git a/dashboard/backend/routes/routines.py b/dashboard/backend/routes/routines.py index 0a535d95..0cc86ac9 100644 --- a/dashboard/backend/routes/routines.py +++ b/dashboard/backend/routes/routines.py @@ -124,6 +124,8 @@ def list_adws(): "flux-2": {"per_image": 0.03, "input_per_1m": 0, "output_per_1m": 0}, "flux.2": {"per_image": 0.03, "input_per_1m": 0, "output_per_1m": 0}, "gpt-5-image": {"per_image": 0.04, "input_per_1m": 0.005, "output_per_1m": 0.015}, + # OpenAI Images API — billed per token (text input / image output rates). + "gpt-image-2": {"per_image": 0.0, "input_per_1m": 5.0, "output_per_1m": 40.0}, "seedream": {"per_image": 0.02, "input_per_1m": 0, "output_per_1m": 0}, "riverflow": {"per_image": 0.02, "input_per_1m": 0, "output_per_1m": 0}, } @@ -147,7 +149,13 @@ def _estimate_image_cost(entry: dict) -> float: return 0.03 cost = pricing["per_image"] - if total_tokens > 0 and pricing["input_per_1m"] > 0: + prompt_tokens = tokens.get("prompt_tokens", 0) + completion_tokens = tokens.get("completion_tokens", 0) + if prompt_tokens and completion_tokens and pricing.get("output_per_1m", 0) > 0: + # Split billing: text input vs image output rates (OpenAI Images API) + cost += (prompt_tokens / 1_000_000) * pricing["input_per_1m"] + cost += (completion_tokens / 1_000_000) * pricing["output_per_1m"] + elif total_tokens > 0 and pricing["input_per_1m"] > 0: cost += (total_tokens / 1_000_000) * pricing["input_per_1m"] return round(cost, 6) diff --git a/dashboard/backend/routes/shares.py b/dashboard/backend/routes/shares.py index 458b6550..901ebb07 100644 --- a/dashboard/backend/routes/shares.py +++ b/dashboard/backend/routes/shares.py @@ -5,10 +5,11 @@ from datetime import datetime, timezone, timedelta from pathlib import Path -from flask import Blueprint, jsonify, request, Response +from flask import Blueprint, jsonify, request, Response, after_this_request from flask_login import login_required, current_user from models import db, FileShare, audit, has_workspace_folder_access +from rate_limit import limiter from routes.auth_routes import require_permission bp = Blueprint("shares", __name__) @@ -184,6 +185,7 @@ def revoke_share(token: str): # ── Public endpoint (no auth required) ────────────────────────────────────── @bp.route("/api/shares//view", methods=["GET"]) +@limiter.limit("60 per minute") def view_share(token: str): """Serve the file content for a valid share token. No authentication required.""" share = FileShare.query.filter_by(token=token).first() @@ -214,6 +216,16 @@ def view_share(token: str): ua = (request.headers.get("User-Agent", "-") or "-")[:200] audit(None, "share_view", "shares", detail=f"token={token} ip={ip} ua={ua[:80]}") + # Vault §2.S2: security headers on all public share responses. + @after_this_request + def _add_security_headers(response): + response.headers["Referrer-Policy"] = "no-referrer" + response.headers["Cache-Control"] = "no-store, private, no-cache, must-revalidate" + response.headers["Pragma"] = "no-cache" + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["Strict-Transport-Security"] = "max-age=63072000; includeSubDomains" + return response + suffix = full.suffix.lower() # HTML/HTM: serve raw so browser renders it as a full page diff --git a/dashboard/frontend/src/App.tsx b/dashboard/frontend/src/App.tsx index d6f411a2..da519aa0 100644 --- a/dashboard/frontend/src/App.tsx +++ b/dashboard/frontend/src/App.tsx @@ -41,6 +41,7 @@ const HeartbeatsList = lazyDefault(() => import('./pages/Heartbeats')) const HeartbeatDetail = lazyNamed(() => import('./pages/Heartbeats'), 'HeartbeatDetail') const Activity = lazyDefault(() => import('./pages/Activity')) const Goals = lazyDefault(() => import('./pages/Goals')) +const Kanban = lazyDefault(() => import('./pages/Kanban')) const Plugins = lazyDefault(() => import('./pages/Plugins')) const PluginDetail = lazyDefault(() => import('./pages/PluginDetail')) const McpServers = lazyDefault(() => import('./pages/McpServers')) @@ -266,12 +267,14 @@ function AppContent() { {hasPermission('users', 'manage') && } />} {hasPermission('workspace', 'manage') && } />} } /> + } /> } /> } /> } /> {/* Wave 2.1: full-screen plugin UI pages (catch-all, must come after /plugins/:slug) */} } /> {hasPermission('tickets', 'view') && } />} + {hasPermission('tickets', 'view') && } />} {hasPermission('tickets', 'view') && } />} {hasPermission('tickets', 'view') && } />} {hasPermission('knowledge', 'view') && ( diff --git a/dashboard/frontend/src/components/AgentChat.tsx b/dashboard/frontend/src/components/AgentChat.tsx index b60ff664..c6d28a6c 100644 --- a/dashboard/frontend/src/components/AgentChat.tsx +++ b/dashboard/frontend/src/components/AgentChat.tsx @@ -2,13 +2,14 @@ import { useEffect, useRef, useState, useCallback } from 'react' import { useToast } from './Toast' import Markdown from './Markdown' import { AgentAvatar } from './AgentAvatar' +import TerminalHudPanel from './TerminalHudPanel' import { useNotifications } from '../context/NotificationContext' import { Send, Square, ChevronDown, ChevronRight, FileCode, Terminal as TermIcon, CheckCircle2, Paperclip, X, File as FileIcon, ImageIcon, Upload, Ticket as TicketIcon, Plus, ShieldAlert, Check, Ban, - Pencil, Copy, FileText, Edit2, + Pencil, Copy, FileText, Edit2, Mic, Loader2, } from 'lucide-react' interface SkillItem { @@ -26,6 +27,21 @@ interface SlashPopup { anchorStart: number } +// Same shape as AgentTerminal's HudState — kept as a separate local type +// (not a shared import) since these two components don't otherwise share +// types and a one-off duplication is simpler than introducing a shared +// types module for a single interface. +interface HudState { + busy: boolean + heavy: boolean + providerId: string + providerModel: string + tokensPerSec: number + totalTokens: number | null + bestTokensPerSec: number + shift: boolean +} + interface PermissionRequest { requestId: string toolName: string @@ -99,6 +115,7 @@ export default function AgentChat({ agent, sessionId, accentColor = '#00FFA7', e const [editingUuid, setEditingUuid] = useState(null) const [editingText, setEditingText] = useState('') const [copiedIndex, setCopiedIndex] = useState(null) + const [hud, setHud] = useState(null) const wsRef = useRef(null) const scrollRef = useRef(null) const inputRef = useRef(null) @@ -107,6 +124,74 @@ export default function AgentChat({ agent, sessionId, accentColor = '#00FFA7', e const dragCounterRef = useRef(0) const subagentToolRef = useRef<{ toolName: string; toolUseId: string; input: string; parentToolUseId: string } | null>(null) + // Mic (terminal-ux-upgrade Sprint 6, moved here from the raw Terminal — + // this chat UI already has a working input row + attach button, so the + // mic reuses the same POST /api/transcribe proxy (Sprint 5) and drops the + // transcribed text into the same `input` state as typing would. + type MicState = 'idle' | 'recording' | 'transcribing' + const [micState, setMicState] = useState('idle') + const [micError, setMicError] = useState(null) + const mediaRecorderRef = useRef(null) + const audioChunksRef = useRef([]) + + useEffect(() => { + return () => { + try { + mediaRecorderRef.current?.stream.getTracks().forEach((t) => t.stop()) + } catch { + // stream may already be stopped/released — nothing to clean up + } + } + }, []) + + async function toggleRecording() { + if (micState === 'recording') { + mediaRecorderRef.current?.stop() + return + } + if (micState === 'transcribing') return + setMicError(null) + let stream: MediaStream + try { + stream = await navigator.mediaDevices.getUserMedia({ audio: true }) + } catch (e) { + const name = e instanceof DOMException ? e.name : '' + setMicError(name === 'NotAllowedError' ? 'Permissão de microfone negada' : 'Não foi possível acessar o microfone') + return + } + const mr = new MediaRecorder(stream) + audioChunksRef.current = [] + mr.ondataavailable = (e) => { + if (e.data.size > 0) audioChunksRef.current.push(e.data) + } + mr.onstop = async () => { + stream.getTracks().forEach((t) => t.stop()) + setMicState('transcribing') + const blob = new Blob(audioChunksRef.current, { type: mr.mimeType || 'audio/webm' }) + try { + const res = await fetch(`${TS_HTTP}/api/transcribe`, { + method: 'POST', + headers: { 'Content-Type': blob.type || 'audio/webm' }, + body: blob, + }) + const data = await res.json().catch(() => null) + if (!res.ok) throw new Error((data && data.error) || `HTTP ${res.status}`) + const text = ((data && data.text) || '').trim() + if (text) { + setInput((prev) => (prev ? `${prev} ${text}` : text)) + inputRef.current?.focus() + } + } catch (e) { + setMicError(e instanceof Error ? e.message : 'Falha ao transcrever áudio') + } finally { + setMicState('idle') + } + } + mediaRecorderRef.current = mr + mr.start() + setMicState('recording') + } + // Auto-dismiss global notifications when the user opens this session useEffect(() => { if (sessionId) { @@ -136,29 +221,36 @@ export default function AgentChat({ agent, sessionId, accentColor = '#00FFA7', e setStatus('connecting') setErrorMsg(null) + setHud(null) let cancelled = false - let ws: WebSocket | null = null + let reconnectTimer: ReturnType | null = null + let reconnectAttempts = 0 + + // The server keeps the session alive when the socket drops — rejoining + // restores chat history, so a dead WS only needs a reconnect with + // capped exponential backoff instead of staying dead until remount. + function scheduleReconnect() { + if (cancelled || reconnectTimer) return + const delay = Math.min(1000 * 2 ** reconnectAttempts, 15000) + reconnectAttempts++ + setStatus('connecting') + reconnectTimer = setTimeout(() => { + reconnectTimer = null + connect() + }, delay) + } - ;(async () => { - // 1) HTTP preflight — fails fast on ECONNREFUSED so we can show a real error - // instead of hanging in 'connecting' forever (same pattern as AgentTerminal). - try { - const res = await fetch(`${TS_HTTP}/api/health`) - if (!res.ok) throw new Error(`HTTP ${res.status}`) - } catch { - if (cancelled) return - setStatus('error') - setErrorMsg(`Could not reach terminal-server at ${TS_HTTP}. Is it running?`) - return - } + function connect() { if (cancelled) return - // 2) Open WS - ws = new WebSocket(`${TS_WS}/ws`) + // Open WS + const ws = new WebSocket(`${TS_WS}/ws`) wsRef.current = ws ws.onopen = () => { - ws!.send(JSON.stringify({ type: 'join_session', sessionId })) + reconnectAttempts = 0 + setErrorMsg(null) + ws.send(JSON.stringify({ type: 'join_session', sessionId })) setStatus('idle') } @@ -273,26 +365,60 @@ export default function AgentChat({ agent, sessionId, accentColor = '#00FFA7', e } ws.onerror = () => { - if (cancelled) return - setStatus('error') - setErrorMsg('WebSocket error') + // onclose always follows onerror — reconnect is handled there. } ws.onclose = () => { if (pingRef.current) { clearInterval(pingRef.current); pingRef.current = null } + if (cancelled) return + scheduleReconnect() } pingRef.current = setInterval(() => { - if (ws!.readyState === WebSocket.OPEN) { - ws!.send(JSON.stringify({ type: 'ping' })) + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'ping' })) } }, 25000) + } + + ;(async () => { + // HTTP preflight — fails fast on ECONNREFUSED so we can show a real error + // instead of hanging in 'connecting' forever (same pattern as AgentTerminal). + try { + const res = await fetch(`${TS_HTTP}/api/health`) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + } catch { + if (cancelled) return + setStatus('error') + setErrorMsg(`Could not reach terminal-server at ${TS_HTTP}. Is it running?`) + return + } + if (cancelled) return + connect() })() + // 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 ws = wsRef.current + if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return + if (reconnectTimer) { + clearTimeout(reconnectTimer) + reconnectTimer = null + } + reconnectAttempts = 0 + connect() + } + document.addEventListener('visibilitychange', onVisible) + return () => { cancelled = true + document.removeEventListener('visibilitychange', onVisible) + if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null } if (pingRef.current) { clearInterval(pingRef.current); pingRef.current = null } - try { ws?.close() } catch {} + try { wsRef.current?.close() } catch {} wsRef.current = null } }, [sessionId]) @@ -384,6 +510,22 @@ export default function AgentChat({ agent, sessionId, accentColor = '#00FFA7', e setIsThinking(false) } + // HUD updates aren't chat messages — handled here (before the messages + // reducer below) instead of adding a case to that switch. + if (msg.type === 'hud_update') { + 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, + }) + return + } + setMessages(prev => { const copy = [...prev] @@ -1034,6 +1176,15 @@ export default function AgentChat({ agent, sessionId, accentColor = '#00FFA7', e )} + {/* Dashboard row (car-panel HUD, same component/shape as the Terminal + — see TerminalHudPanel.tsx) — only appears once the backend has + sent at least one hud_update, same as the Terminal. */} + {hud && ( +
+ +
+ )} + {/* Messages area */}
{messages.length === 0 && ( @@ -1334,6 +1485,28 @@ export default function AgentChat({ agent, sessionId, accentColor = '#00FFA7', e }} /> + {/* Mic button (Sprint 6) */} + + {/* Textarea */}