Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .claude/skills/ai-image-creator/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down
494 changes: 450 additions & 44 deletions .claude/skills/ai-image-creator/scripts/generate-image.py

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,8 @@ bling-auth: ## 🔐 Bling OAuth2 login (one-time: capture access + refre
@python3 .claude/skills/int-bling/scripts/bling_auth.py

telegram: ## 📨 Start Telegram bot in background (screen)
@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; }
@if screen -list | grep -q '\.telegram'; then \
echo "⚠ Telegram bot is already running. Use 'make telegram-stop' first or 'make telegram-attach' to connect."; \
else \
Expand Down
19 changes: 18 additions & 1 deletion config/providers.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,22 @@
},
"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": "deepseek-ai/deepseek-v3.1",
"requires_logout": true
},
"omnirouter": {
Expand All @@ -34,7 +50,8 @@
},
"default_base_url": "",
"default_model": "",
"requires_logout": true
"requires_logout": true,
"mode": "code"
},
"openai": {
"name": "OpenAI (API Key)",
Expand Down
5 changes: 5 additions & 0 deletions dashboard/backend/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,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
);
Expand Down Expand Up @@ -516,6 +517,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()
Expand Down
10 changes: 5 additions & 5 deletions dashboard/backend/heartbeat_dispatcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
),
)
Expand All @@ -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,
),
)
Expand Down
169 changes: 111 additions & 58 deletions dashboard/backend/heartbeat_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,13 +80,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,
),
)
Expand Down Expand Up @@ -220,7 +220,63 @@ 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,
agent=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("attempt_number", 0) 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,
)
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")
Expand All @@ -240,6 +296,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
]

Expand Down Expand Up @@ -465,13 +522,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"])
Expand Down Expand Up @@ -502,57 +594,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
Expand Down
Loading