From ce7b71262490d78648ae0d164fbd9a3794b2c495 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20SAILLANT?= <108685187+electron-rare@users.noreply.github.com> Date: Sat, 21 Feb 2026 04:02:12 +0100 Subject: [PATCH 1/2] feat(ai): optimize local/provider orchestration and hw loops --- specs/04_tasks.md | 39 ++-- specs/zeroclaw_dual_hw_orchestration_spec.md | 31 ++- specs/zeroclaw_dual_hw_todo.md | 29 +-- tools/ai/ollama_local_setup.sh | 122 ++++++++++++ tools/ai/zeroclaw_dual_chat.sh | 40 ++++ tools/ai/zeroclaw_hw_firmware_loop.sh | 101 +++++++++- tools/ai/zeroclaw_stack_up.sh | 188 +++++++++++++++++++ 7 files changed, 511 insertions(+), 39 deletions(-) create mode 100755 tools/ai/ollama_local_setup.sh diff --git a/specs/04_tasks.md b/specs/04_tasks.md index 3800c0c..30a3a2e 100644 --- a/specs/04_tasks.md +++ b/specs/04_tasks.md @@ -30,7 +30,12 @@ Format: - [x] T-005 - Stabiliser pairing bearer auto - AC: `artifacts/zeroclaw/pair_token.txt` utilisable pour webhook sans override manuel. - Evidence: `tools/ai/zeroclaw_webhook_send.sh --repo-hint rtc "pairing check"` - - Status: `2026-02-21` done (401 resolved; gateway now accepts bearer, downstream model may still return HTTP 500). + - Status: `2026-02-21` done (401 resolved + webhook HTTP 200 after provider/model auto-fix in stack bootstrap). + +- [x] T-006 - Activer fallback IA locale (macOS) + - AC: `ollama` installe, service actif, modele local disponible, stack capable de le preferer. + - Evidence: `tools/ai/ollama_local_setup.sh --no-pull --no-warmup` + config `default_provider = "ollama"`. + - Status: `2026-02-21` done (`llama3.2:1b` local); mode local est optionnel via `ZEROCLAW_PREFER_LOCAL_AI=1` pour garder la fiabilite webhook par defaut. ## Boucle hardware RTC @@ -43,14 +48,15 @@ Format: - Evidence: logs build RTC. - Status: `2026-02-21` done. -- [ ] T-103 - Upload + monitor RTC (forced default) +- [x] T-103 - Upload + monitor RTC (forced default) - AC: `pio run -e esp32dev -t upload` puis monitor 60s executes. - Evidence: logs upload/monitor RTC. - - Blocker: `esptool` connect timeout (`No serial data received`) on selected USB serial port. + - Status: `2026-02-21` done (`ESP32-D0WD-V3` flash + serial monitor output captured). -- [ ] T-104 - Trace webhook RTC +- [x] T-104 - Trace webhook RTC - AC: une ligne JSONL avec `repo_hint=rtc` apparait. - Evidence: `artifacts/zeroclaw/conversations.jsonl` + - Status: `2026-02-21` done (`http_status=200`, `ok=true`). ## Boucle hardware Zacus @@ -63,14 +69,15 @@ Format: - Evidence: logs build Zacus. - Status: `2026-02-21` done. -- [ ] T-203 - Upload + monitor Zacus (forced default) - - AC: `pio run -e esp32dev -t upload` puis monitor 60s executes. +- [x] T-203 - Upload + monitor Zacus (forced default) + - AC: `pio run -e -t upload` puis monitor executes. - Evidence: logs upload/monitor Zacus. - - Blocker: `esptool` connect timeout (`No serial data received`) on selected USB serial port. + - Status: `2026-02-21` done (`ESP32-S3` detecte, env fallback `freenove_esp32s3`, flash + monitor OK). -- [ ] T-204 - Trace webhook Zacus +- [x] T-204 - Trace webhook Zacus - AC: une ligne JSONL avec `repo_hint=zacus` apparait. - Evidence: `artifacts/zeroclaw/conversations.jsonl` + - Status: `2026-02-21` done (`http_status=200`, `ok=true`). ## Observabilite et cout @@ -83,18 +90,20 @@ Format: - Evidence: sortie script + diff JSONL. - Status: `2026-02-21` done. -- [ ] T-303 - Quota call limiter +- [x] T-303 - Quota call limiter - AC: depassement quota bloque avec code non-zero. - Evidence: message `[budget] hourly call limit reached`. + - Status: `2026-02-21` done (test sandbox local `max_calls=1`, second call blocked with exit 12). -- [ ] T-304 - Quota chars limiter +- [x] T-304 - Quota chars limiter - AC: message trop long bloque avec code non-zero. - Evidence: message `[budget] message length ... exceeds ...`. + - Status: `2026-02-21` done (`ZEROCLAW_WEBHOOK_MAX_CHARS=5`, exit 11). ## Definition of done -- [ ] Au moins une boucle complete RTC + Zacus executee en local. -- [ ] Dashboard live exploitable pour suivi continu. -- [ ] Prometheus disponible avec target gateway scrapee. -- [ ] Logs et preuves archives dans `artifacts/zeroclaw/`. -- [ ] Aucune commande documentee n'utilise `-e native` ou `-e test`. +- [x] Au moins une boucle complete RTC + Zacus executee en local. +- [x] Dashboard live exploitable pour suivi continu. +- [x] Prometheus disponible avec target gateway scrapee. +- [x] Logs et preuves archives dans `artifacts/zeroclaw/`. +- [x] Aucune commande documentee n'utilise `-e native` ou `-e test`. diff --git a/specs/zeroclaw_dual_hw_orchestration_spec.md b/specs/zeroclaw_dual_hw_orchestration_spec.md index 636653e..8d291f3 100644 --- a/specs/zeroclaw_dual_hw_orchestration_spec.md +++ b/specs/zeroclaw_dual_hw_orchestration_spec.md @@ -65,13 +65,19 @@ This keeps `autonomy.workspace_only = true` effective on a per-repo boundary. - `tools/ai/zeroclaw_dual_chat.sh` - target switch by alias (`rtc`, `zacus`) or absolute path, - message mode (`-m`) or interactive mode, + - `--cheap` mode to prefer local provider routing for low-credit runs, - loads local auth env file `~/.zeroclaw/env` when present, - - provider auto-fallback (`copilot` -> `openai-codex` -> `openrouter`), + - provider auto-fallback (`ollama` when local preferred -> `copilot` -> `openai-codex` -> `gemini` -> `openrouter` -> `anthropic` -> `openai`), - token sourcing from `gh auth token` at runtime only when `copilot` is selected. - `tools/ai/zeroclaw_stack_up.sh` - starts local gateway and local follow server, - reuses existing listeners when ports are already bound (prevents duplicate-start failures), - loads local auth env file `~/.zeroclaw/env` when present, + - auto-aligns gateway provider/model for autonomous cost control: + - default uses `openai-codex` when auth profile exists (reliable baseline), + - optionally prefers local `ollama` when `ZEROCLAW_PREFER_LOCAL_AI=1`, + - otherwise uses `openrouter` when API key exists, + - writes reliability fallback chain in config (`ollama -> openai-codex -> openrouter` when available), - attempts automatic gateway pairing and token refresh, - validates bearer token with a malformed webhook probe (`{}` payload) and re-pairs when possible, - generates live follow dashboard at `http://127.0.0.1:8788/`, @@ -96,8 +102,15 @@ This keeps `autonomy.workspace_only = true` effective on a per-repo boundary. - target switch `rtc|zacus`, - validates `platformio.ini` env exists before running, - forces `build -> upload -> serial monitor` by default, + - auto-retries with a compatible env when chip mismatch is detected (`ESP32` vs `ESP32-S3`), - auto-detects serial port when not specified, + - on macOS, wraps monitor with `script` pseudo-TTY to avoid `termios` non-interactive failures, - monitor timeout default 60s. +- `tools/ai/ollama_local_setup.sh` + - installs `ollama` via Homebrew when missing, + - starts local service, + - optionally pulls/warms a local model, + - prints zero-credit defaults for stack usage. - `tools/ai/zeroclaw_webhook_send.sh` - sends webhook by default (no mandatory allow flag), - keeps `--allow-model-call` as backward-compatible legacy option, @@ -111,9 +124,19 @@ This keeps `autonomy.workspace_only = true` effective on a per-repo boundary. Auto provider selection order in `zeroclaw_dual_chat.sh`: 1. explicit `ZEROCLAW_PROVIDER` override, -2. `copilot` when `gh` auth is valid and Copilot billing endpoint is accessible, -3. `openai-codex` when a ZeroClaw auth profile exists, -4. `openrouter` when `OPENROUTER_API_KEY` is present. +2. `ollama` when `ZEROCLAW_PREFER_LOCAL_AI=1` and local model is available, +3. `copilot` when `gh` auth is valid and Copilot billing endpoint is accessible, +4. `openai-codex` when a ZeroClaw auth profile exists, +5. `gemini` when `GEMINI_API_KEY`/`GOOGLE_API_KEY` is present, +6. `openrouter` when `OPENROUTER_API_KEY` is present, +7. `anthropic` when `ANTHROPIC_API_KEY`/`ANTHROPIC_OAUTH_TOKEN` is present, +8. `openai` when `OPENAI_API_KEY` is present. + +Gateway bootstrap provider order in `zeroclaw_stack_up.sh`: + +1. `openai-codex` when auth profile exists (default mode), +2. `openrouter` when `OPENROUTER_API_KEY` exists, +3. `ollama` only when `ZEROCLAW_PREFER_LOCAL_AI=1` and local model is available. Observed on current machine: diff --git a/specs/zeroclaw_dual_hw_todo.md b/specs/zeroclaw_dual_hw_todo.md index 817a67e..f490576 100644 --- a/specs/zeroclaw_dual_hw_todo.md +++ b/specs/zeroclaw_dual_hw_todo.md @@ -8,6 +8,7 @@ Last updated: 2026-02-21 - [x] I-002 - Configure local secret file `~/.zeroclaw/env` (`OPENROUTER_API_KEY` placeholder + mode `600`). - [x] I-003 - Ensure local Prometheus backend exists (`prometheus` binary installed). - [x] I-004 - Resolve open mirror PR redundancy (`PR #7` merge/close decision). +- [x] I-005 - Enable local AI fallback (`ollama` + local model) for credit savings. ## Daily autonomous sequence @@ -16,35 +17,35 @@ Last updated: 2026-02-21 - `curl -fsS http://127.0.0.1:3000/health` - `curl -fsS http://127.0.0.1:8788/` - `curl -fsS http://127.0.0.1:9090/-/ready` -- [ ] D-003 - RTC loop: +- [x] D-003 - RTC loop: - `tools/ai/zeroclaw_dual_chat.sh rtc --provider-check` - `tools/ai/zeroclaw_dual_chat.sh rtc --hardware` - `tools/ai/zeroclaw_hw_firmware_loop.sh rtc` (build+upload+monitor forced default) - webhook trace with `--repo-hint rtc` - - current blocker: serial upload timeout (`No serial data received`) -- [ ] D-004 - Zacus loop: + - status: `2026-02-21` done (flash + monitor + webhook HTTP 200). +- [x] D-004 - Zacus loop: - `tools/ai/zeroclaw_dual_chat.sh zacus --provider-check` - `tools/ai/zeroclaw_dual_chat.sh zacus --hardware` - `tools/ai/zeroclaw_hw_firmware_loop.sh zacus` (build+upload+monitor forced default) - webhook trace with `--repo-hint zacus` - - current blocker: serial upload timeout (`No serial data received`) -- [ ] D-005 - Review `artifacts/zeroclaw/gateway.log` + `conversations.jsonl`. + - status: `2026-02-21` done (ESP32-S3 mismatch auto-corrected to `freenove_esp32s3`). +- [x] D-005 - Review `artifacts/zeroclaw/gateway.log` + `conversations.jsonl`. ## Hardware safety gates -- [ ] H-001 - Flash/upload/monitor are forced by default when a board is detected. -- [ ] H-002 - Resolve stable serial target before upload. -- [ ] H-003 - Keep per-run logs under `artifacts/zeroclaw/`. +- [x] H-001 - Flash/upload/monitor are forced by default when a board is detected. +- [x] H-002 - Resolve stable serial target before upload. +- [x] H-003 - Keep per-run logs under `artifacts/zeroclaw/`. ## Cost/control gates - [x] C-001 - Validate `tools/ai/zeroclaw_webhook_send.sh --dry-run`. -- [ ] C-002 - Validate hourly quota guard (`ZEROCLAW_WEBHOOK_MAX_CALLS_PER_HOUR`). -- [ ] C-003 - Validate message length guard (`ZEROCLAW_WEBHOOK_MAX_CHARS`). +- [x] C-002 - Validate hourly quota guard (`ZEROCLAW_WEBHOOK_MAX_CALLS_PER_HOUR`). +- [x] C-003 - Validate message length guard (`ZEROCLAW_WEBHOOK_MAX_CHARS`). ## Exit criteria -- [ ] E-001 - One successful complete loop RTC in local hardware. -- [ ] E-002 - One successful complete loop Zacus in local hardware. -- [ ] E-003 - Dashboard live usable for continuous supervision. -- [ ] E-004 - Prometheus target scrape confirmed on gateway metrics. +- [x] E-001 - One successful complete loop RTC in local hardware. +- [x] E-002 - One successful complete loop Zacus in local hardware. +- [x] E-003 - Dashboard live usable for continuous supervision. +- [x] E-004 - Prometheus target scrape confirmed on gateway metrics. diff --git a/tools/ai/ollama_local_setup.sh b/tools/ai/ollama_local_setup.sh new file mode 100755 index 0000000..7fba5ef --- /dev/null +++ b/tools/ai/ollama_local_setup.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +set -euo pipefail + +MODEL="${ZEROCLAW_OLLAMA_MODEL:-llama3.2:1b}" +DO_PULL=1 +DO_WARMUP=1 +APPLY_MAC_ENV=0 + +usage() { + cat <] [--no-pull] [--no-warmup] [--apply-macos-env] + +Options: + --model Ollama model to pull/warm (default: ${MODEL}) + --no-pull Skip model pull + --no-warmup Skip warmup request + --apply-macos-env Apply launchctl env tuning for macOS Ollama service + -h, --help Show this help +USAGE +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --model) + MODEL="${2:-}" + shift 2 + ;; + --no-pull) + DO_PULL=0 + shift + ;; + --no-warmup) + DO_WARMUP=0 + shift + ;; + --apply-macos-env) + APPLY_MAC_ENV=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "[fail] unknown option: $1" >&2 + usage + exit 1 + ;; + esac +done + +if ! command -v brew >/dev/null 2>&1; then + echo "[fail] Homebrew is required on macOS for automated Ollama setup." >&2 + exit 1 +fi + +if ! command -v ollama >/dev/null 2>&1; then + echo "[step] installing ollama via brew" + brew install ollama +fi + +if [[ "$(uname -s)" == "Darwin" ]]; then + if [[ "$APPLY_MAC_ENV" == "1" ]]; then + echo "[step] applying launchctl env tuning for Ollama" + launchctl setenv OLLAMA_FLASH_ATTENTION "1" || true + launchctl setenv OLLAMA_KV_CACHE_TYPE "q8_0" || true + launchctl setenv OLLAMA_MAX_LOADED_MODELS "1" || true + launchctl setenv OLLAMA_NUM_PARALLEL "1" || true + fi + echo "[step] starting ollama service" + brew services start ollama >/dev/null 2>&1 || true +else + echo "[step] starting ollama daemon" + nohup ollama serve >/tmp/ollama-serve.log 2>&1 & +fi + +for _ in $(seq 1 20); do + if ollama list >/dev/null 2>&1; then + break + fi + sleep 0.5 +done + +if ! ollama list >/dev/null 2>&1; then + echo "[fail] ollama daemon is not reachable." >&2 + exit 2 +fi + +if [[ "$DO_PULL" == "1" ]]; then + echo "[step] pull model: $MODEL" + ollama pull "$MODEL" +fi + +if [[ "$DO_WARMUP" == "1" ]]; then + echo "[step] warmup model: $MODEL" + python3 - "$MODEL" <<'PY' +import json +import sys +import urllib.request + +model = sys.argv[1] +payload = json.dumps({ + "model": model, + "prompt": "Reply with exactly: ready", + "stream": False, +}).encode("utf-8") +req = urllib.request.Request( + "http://127.0.0.1:11434/api/generate", + data=payload, + headers={"Content-Type": "application/json"}, + method="POST", +) +with urllib.request.urlopen(req, timeout=120) as resp: + body = json.loads(resp.read().decode("utf-8", errors="replace")) +print(body.get("response", "").strip()) +PY +fi + +echo "[ok] local Ollama setup complete." +echo "[hint] credit-saving defaults:" +echo " export ZEROCLAW_PREFER_LOCAL_AI=1" +echo " export ZEROCLAW_OLLAMA_MODEL=\"$MODEL\"" diff --git a/tools/ai/zeroclaw_dual_chat.sh b/tools/ai/zeroclaw_dual_chat.sh index 415d0c2..e5fa588 100755 --- a/tools/ai/zeroclaw_dual_chat.sh +++ b/tools/ai/zeroclaw_dual_chat.sh @@ -24,6 +24,7 @@ Usage: $(basename "$0") --interactive $(basename "$0") --hardware $(basename "$0") --provider-check + $(basename "$0") --cheap -m "message" USAGE } @@ -60,12 +61,35 @@ if [[ "${1:-}" == "--hardware" ]]; then exit 0 fi +if [[ "${1:-}" == "--cheap" ]]; then + export ZEROCLAW_PREFER_LOCAL_AI=1 + shift +fi + resolve_provider() { if [[ -n "${ZEROCLAW_PROVIDER:-}" ]]; then echo "$ZEROCLAW_PROVIDER" return 0 fi + if [[ "${ZEROCLAW_PREFER_LOCAL_AI:-0}" == "1" ]] && command -v ollama >/dev/null 2>&1; then + local ollama_model + ollama_model="${ZEROCLAW_OLLAMA_MODEL:-llama3.2:1b}" + if ollama list >/dev/null 2>&1; then + if ollama list 2>/dev/null | awk 'NR>1 {print $1}' | rg -qx "$ollama_model"; then + echo "ollama" + return 0 + fi + local first_model + first_model="$(ollama list 2>/dev/null | awk 'NR==2 {print $1}')" + if [[ -n "$first_model" ]]; then + export ZEROCLAW_OLLAMA_MODEL="$first_model" + echo "ollama" + return 0 + fi + fi + fi + if [[ "${ZEROCLAW_SKIP_COPILOT_CHECK:-0}" != "1" ]] && command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then if gh api /user/copilot/billing --silent >/dev/null 2>&1; then local gh_token @@ -86,14 +110,30 @@ resolve_provider() { return 0 fi + if [[ -n "${GEMINI_API_KEY:-}" || -n "${GOOGLE_API_KEY:-}" ]]; then + echo "gemini" + return 0 + fi + if [[ -n "${OPENROUTER_API_KEY:-}" ]]; then export API_KEY="$OPENROUTER_API_KEY" echo "openrouter" return 0 fi + if [[ -n "${ANTHROPIC_API_KEY:-}" || -n "${ANTHROPIC_OAUTH_TOKEN:-}" ]]; then + echo "anthropic" + return 0 + fi + + if [[ -n "${OPENAI_API_KEY:-}" ]]; then + echo "openai" + return 0 + fi + cat <<'ERR' >&2 No provider credentials detected. +- Option 0: install local model and set ZEROCLAW_PREFER_LOCAL_AI=1 (ollama) - Option 1: ZEROCLAW_PROVIDER=copilot with GitHub Copilot subscription + gh auth login - Option 2: zeroclaw auth login --provider openai-codex --device-code - Option 3: export OPENROUTER_API_KEY=... and ZEROCLAW_PROVIDER=openrouter diff --git a/tools/ai/zeroclaw_hw_firmware_loop.sh b/tools/ai/zeroclaw_hw_firmware_loop.sh index 60fe812..e5751a6 100755 --- a/tools/ai/zeroclaw_hw_firmware_loop.sh +++ b/tools/ai/zeroclaw_hw_firmware_loop.sh @@ -98,7 +98,12 @@ if ! command -v pio >/dev/null 2>&1; then exit 1 fi -if ! rg -q "^\[env:${ENV_NAME}\]" "$FW_DIR/platformio.ini"; then +env_exists() { + local env_name="$1" + rg -q "^\[env:${env_name}\]" "$FW_DIR/platformio.ini" +} + +if ! env_exists "$ENV_NAME"; then echo "[fail] invalid PlatformIO env '${ENV_NAME}' for $target." >&2 echo "[info] valid envs:" >&2 rg "^\[env:" "$FW_DIR/platformio.ini" >&2 || true @@ -151,6 +156,69 @@ if [[ -z "$PORT" ]]; then exit 2 fi +pick_s3_env() { + local candidate + if [[ "$target" == "rtc" ]]; then + for candidate in esp32-s3-devkitc-1 freenove_esp32s3 freenove_esp32s3_full_with_ui; do + if env_exists "$candidate"; then + echo "$candidate" + return 0 + fi + done + else + for candidate in freenove_esp32s3 freenove_esp32s3_full_with_ui esp32-s3-devkitc-1; do + if env_exists "$candidate"; then + echo "$candidate" + return 0 + fi + done + fi + return 1 +} + +pick_esp32_env() { + local candidate + for candidate in esp32dev esp32_release; do + if env_exists "$candidate"; then + echo "$candidate" + return 0 + fi + done + return 1 +} + +upload_once() { + local env_name="$1" + local tmp_out + tmp_out="$(mktemp)" + set +e + (cd "$FW_DIR" && pio run -e "$env_name" -t upload "${UPLOAD_ARGS[@]}") >"$tmp_out" 2>&1 + local rc=$? + set -e + UPLOAD_LAST_OUTPUT="$(cat "$tmp_out")" + cat "$tmp_out" + rm -f "$tmp_out" + return "$rc" +} + +resolve_env_fallback() { + local failed_env="$1" + local output="$2" + local fallback="" + + if printf '%s' "$output" | rg -qi "This chip is ESP32-S3, not ESP32"; then + fallback="$(pick_s3_env || true)" + elif printf '%s' "$output" | rg -qi "This chip is ESP32, not ESP32-S3"; then + fallback="$(pick_esp32_env || true)" + fi + + if [[ -n "$fallback" && "$fallback" != "$failed_env" ]]; then + printf '%s\n' "$fallback" + return 0 + fi + return 1 +} + UPLOAD_ARGS=() MONITOR_ARGS=() if [[ -n "$PORT" ]]; then @@ -161,17 +229,34 @@ if [[ -n "$BAUD" ]]; then MONITOR_ARGS+=(-b "$BAUD") fi -echo "[info] target=$target fw_dir=$FW_DIR env=$ENV_NAME port=$PORT baud=$BAUD monitor_secs=$MONITOR_SECS" +ACTIVE_ENV="$ENV_NAME" + +echo "[info] target=$target fw_dir=$FW_DIR env=$ACTIVE_ENV port=$PORT baud=$BAUD monitor_secs=$MONITOR_SECS" echo "[step] build" -(cd "$FW_DIR" && pio run -e "$ENV_NAME") +(cd "$FW_DIR" && pio run -e "$ACTIVE_ENV") echo "[step] upload/flash (forced default)" -(cd "$FW_DIR" && pio run -e "$ENV_NAME" -t upload "${UPLOAD_ARGS[@]}") +UPLOAD_LAST_OUTPUT="" +if ! upload_once "$ACTIVE_ENV"; then + fallback_env="$(resolve_env_fallback "$ACTIVE_ENV" "$UPLOAD_LAST_OUTPUT" || true)" + if [[ -n "$fallback_env" ]]; then + echo "[warn] detected chip/env mismatch for $ACTIVE_ENV; retrying with '$fallback_env'." + ACTIVE_ENV="$fallback_env" + echo "[step] rebuild (fallback env)" + (cd "$FW_DIR" && pio run -e "$ACTIVE_ENV") + echo "[step] upload/flash retry (forced default)" + upload_once "$ACTIVE_ENV" + else + exit 3 + fi +fi echo "[step] serial monitor (forced default)" python3 - "$FW_DIR" "$MONITOR_SECS" "${MONITOR_ARGS[@]}" <<'PY' import os +import platform import signal +import shutil import subprocess import sys import time @@ -179,7 +264,11 @@ import time fw_dir = sys.argv[1] monitor_secs = int(sys.argv[2]) monitor_args = sys.argv[3:] -cmd = ["pio", "device", "monitor", *monitor_args] +base_cmd = ["pio", "device", "monitor", *monitor_args] +if platform.system() == "Darwin" and shutil.which("script"): + cmd = ["script", "-q", "/dev/null", *base_cmd] +else: + cmd = base_cmd print(f"[info] monitor command: {' '.join(cmd)} (timeout={monitor_secs}s)") proc = subprocess.Popen(cmd, cwd=fw_dir, preexec_fn=os.setsid) @@ -195,4 +284,4 @@ finally: proc.wait(timeout=2) PY -echo "[ok] loop complete for $target ($ENV_NAME)." +echo "[ok] loop complete for $target ($ACTIVE_ENV)." diff --git a/tools/ai/zeroclaw_stack_up.sh b/tools/ai/zeroclaw_stack_up.sh index ee6ce68..31aea33 100755 --- a/tools/ai/zeroclaw_stack_up.sh +++ b/tools/ai/zeroclaw_stack_up.sh @@ -28,6 +28,12 @@ PROM_CONTAINER="${ZEROCLAW_PROM_CONTAINER:-zeroclaw-prometheus}" DOCKER_WAIT_SECS="${ZEROCLAW_DOCKER_WAIT_SECS:-90}" PROM_READY_WAIT_SECS="${ZEROCLAW_PROM_READY_WAIT_SECS:-15}" AUTO_REPAIR_ON_INVALID_TOKEN="${ZEROCLAW_AUTO_REPAIR_ON_INVALID_TOKEN:-1}" +OPENAI_CODEX_MODEL="${ZEROCLAW_OPENAI_CODEX_MODEL:-gpt-5.3-codex}" +OPENROUTER_MODEL="${ZEROCLAW_OPENROUTER_MODEL:-openrouter/auto}" +PREFER_LOCAL_AI="${ZEROCLAW_PREFER_LOCAL_AI:-0}" +OLLAMA_MODEL="${ZEROCLAW_OLLAMA_MODEL:-llama3.2:1b}" +OLLAMA_AUTO_PULL="${ZEROCLAW_OLLAMA_AUTO_PULL:-0}" +OLLAMA_WARMUP="${ZEROCLAW_OLLAMA_WARMUP:-1}" GW_PID_FILE="$ART_DIR/gateway.pid" FW_PID_FILE="$ART_DIR/follow.pid" @@ -66,6 +72,188 @@ if [[ ! -x "$ZEROCLAW_BIN" ]]; then fi fi +ensure_preferred_provider_config() { + [[ -f "$ZEROCLAW_CONFIG_FILE" ]] || return 0 + + local preferred_provider="" + local preferred_model="" + local fallback_csv="" + local fallback_models_csv="" + + ollama_model_available() { + command -v ollama >/dev/null 2>&1 || return 1 + ollama list 2>/dev/null | awk 'NR>1 {print $1}' | rg -qx "$OLLAMA_MODEL" + } + + ensure_ollama_ready() { + [[ "$PREFER_LOCAL_AI" == "1" ]] || return 1 + command -v ollama >/dev/null 2>&1 || return 1 + + if ! ollama list >/dev/null 2>&1; then + if [[ "$(uname -s)" == "Darwin" ]] && command -v brew >/dev/null 2>&1; then + brew services start ollama >/dev/null 2>&1 || true + sleep 1 + fi + if ! ollama list >/dev/null 2>&1; then + nohup ollama serve >"$ART_DIR/ollama.log" 2>&1 & + sleep 1 + fi + fi + + if ollama_model_available; then + if [[ "$OLLAMA_WARMUP" == "1" ]]; then + python3 - "$OLLAMA_MODEL" >>"$ART_DIR/ollama.log" 2>&1 <<'PY' +import json +import sys +import urllib.request + +model = sys.argv[1] +payload = json.dumps({ + "model": model, + "prompt": "Reply with exactly: ready", + "stream": False, +}).encode("utf-8") +req = urllib.request.Request( + "http://127.0.0.1:11434/api/generate", + data=payload, + headers={"Content-Type": "application/json"}, + method="POST", +) +try: + with urllib.request.urlopen(req, timeout=60) as resp: + resp.read() +except Exception: + pass +PY + fi + return 0 + fi + + if [[ "$OLLAMA_AUTO_PULL" == "1" ]]; then + ollama pull "$OLLAMA_MODEL" >>"$ART_DIR/ollama.log" 2>&1 || true + if ollama_model_available; then + if [[ "$OLLAMA_WARMUP" == "1" ]]; then + python3 - "$OLLAMA_MODEL" >>"$ART_DIR/ollama.log" 2>&1 <<'PY' +import json +import sys +import urllib.request + +model = sys.argv[1] +payload = json.dumps({ + "model": model, + "prompt": "Reply with exactly: ready", + "stream": False, +}).encode("utf-8") +req = urllib.request.Request( + "http://127.0.0.1:11434/api/generate", + data=payload, + headers={"Content-Type": "application/json"}, + method="POST", +) +try: + with urllib.request.urlopen(req, timeout=60) as resp: + resp.read() +except Exception: + pass +PY + fi + return 0 + fi + fi + + return 1 + } + + if ensure_ollama_ready; then + preferred_provider="ollama" + preferred_model="$OLLAMA_MODEL" + elif env -u ZEROCLAW_WORKSPACE "$ZEROCLAW_BIN" auth status 2>/dev/null | rg -q "openai-codex:"; then + preferred_provider="openai-codex" + preferred_model="$OPENAI_CODEX_MODEL" + elif [[ -n "${OPENROUTER_API_KEY:-}" ]]; then + preferred_provider="openrouter" + preferred_model="$OPENROUTER_MODEL" + else + return 0 + fi + + if [[ "$preferred_provider" == "ollama" ]]; then + if env -u ZEROCLAW_WORKSPACE "$ZEROCLAW_BIN" auth status 2>/dev/null | rg -q "openai-codex:"; then + fallback_csv="openai-codex" + fallback_models_csv="$OPENAI_CODEX_MODEL" + fi + if [[ -n "${OPENROUTER_API_KEY:-}" ]]; then + if [[ -n "$fallback_csv" ]]; then + fallback_csv+=",openrouter" + else + fallback_csv="openrouter" + fi + if [[ -n "$fallback_models_csv" ]]; then + fallback_models_csv+=",$OPENROUTER_MODEL" + else + fallback_models_csv="$OPENROUTER_MODEL" + fi + fi + elif [[ "$preferred_provider" == "openai-codex" && -n "${OPENROUTER_API_KEY:-}" ]]; then + fallback_csv="openrouter" + fallback_models_csv="$OPENROUTER_MODEL" + elif [[ "$preferred_provider" == "openrouter" ]] && env -u ZEROCLAW_WORKSPACE "$ZEROCLAW_BIN" auth status 2>/dev/null | rg -q "openai-codex:"; then + fallback_csv="openai-codex" + fallback_models_csv="$OPENAI_CODEX_MODEL" + fi + + python3 - "$ZEROCLAW_CONFIG_FILE" "$preferred_provider" "$preferred_model" "$fallback_csv" "$fallback_models_csv" <<'PY' +import re +import sys +from pathlib import Path + +cfg = Path(sys.argv[1]) +provider = sys.argv[2] +model = sys.argv[3] +fallback_csv = sys.argv[4] +fallback_models_csv = sys.argv[5] +fallbacks = [p for p in fallback_csv.split(",") if p] +model_fallbacks = [m for m in fallback_models_csv.split(",") if m] +text = cfg.read_text(encoding="utf-8") + +if re.search(r"(?m)^default_provider\s*=", text): + text = re.sub(r'(?m)^default_provider\s*=.*$', f'default_provider = "{provider}"', text, count=1) +else: + text = f'default_provider = "{provider}"\n' + text + +if re.search(r"(?m)^default_model\s*=", text): + text = re.sub(r'(?m)^default_model\s*=.*$', f'default_model = "{model}"', text, count=1) +else: + text = f'default_model = "{model}"\n' + text + +fallback_value = "[]" if not fallbacks else "[" + ", ".join(f'"{p}"' for p in fallbacks) + "]" +if re.search(r"(?m)^fallback_providers\s*=", text): + text = re.sub(r'(?m)^fallback_providers\s*=\s*\[.*\]\s*$', f"fallback_providers = {fallback_value}", text, count=1) +else: + if re.search(r"(?m)^\[reliability\]\s*$", text): + text = re.sub(r"(?m)^\[reliability\]\s*$", f"[reliability]\nfallback_providers = {fallback_value}", text, count=1) + else: + text += f"\n[reliability]\nfallback_providers = {fallback_value}\n" + +# Normalize model fallback section for the currently selected model. +text = re.sub( + r"(?ms)^\[reliability\.model_fallbacks\]\n.*?(?=^\[|\Z)", + "", + text, +) + +section_lines = ["[reliability.model_fallbacks]"] +if model_fallbacks: + fallbacks_value = "[" + ", ".join(f'"{m}"' for m in model_fallbacks) + "]" + section_lines.append(f'"{model}" = {fallbacks_value}') +text = text.rstrip() + "\n\n" + "\n".join(section_lines) + "\n" + +cfg.write_text(text, encoding="utf-8") +PY +} + +ensure_preferred_provider_config + is_running() { local pid_file="$1" [[ -f "$pid_file" ]] || return 1 -- 2.52.0 From 382b752d7ccf79a1670dcd3a7b8fb48df7000157 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20SAILLANT?= <108685187+electron-rare@users.noreply.github.com> Date: Sat, 21 Feb 2026 19:08:05 +0100 Subject: [PATCH 2/2] chore: update ZeroClaw 1min watcher docs and stack bootstrap --- specs/04_tasks.md | 17 + specs/zeroclaw_dual_hw_todo.md | 8 + tools/ai/zeroclaw_stack_up.sh | 666 ++++++++++++++++++++++++++++++++- 3 files changed, 682 insertions(+), 9 deletions(-) diff --git a/specs/04_tasks.md b/specs/04_tasks.md index 30a3a2e..afd3331 100644 --- a/specs/04_tasks.md +++ b/specs/04_tasks.md @@ -37,6 +37,23 @@ Format: - Evidence: `tools/ai/ollama_local_setup.sh --no-pull --no-warmup` + config `default_provider = "ollama"`. - Status: `2026-02-21` done (`llama3.2:1b` local); mode local est optionnel via `ZEROCLAW_PREFER_LOCAL_AI=1` pour garder la fiabilite webhook par defaut. +- [x] T-007 - Renforcer mode local-only agentic (ZeroClaw + OpenClaw) + - AC: `tools/ai/zeroclaw_dual_chat.sh --local-only --provider-check` choisit un provider local (`ollama` ou `lmstudio`) sinon exit explicite. + - Evidence: sortie provider-check + `openclaw models status --plain`. + - Status: `2026-02-21` done (`openclaw` default model force sur `ollama/llama3.2:1b`, fallbacks `openai-codex` puis `openrouter`). + +- [x] T-008 - Scanner les options provider/agentic disponibles + - AC: rapport local avec statut runtime `openclaw/ollama/lmstudio/litellm` et matrice providers ZeroClaw. + - Evidence: `tools/ai/zeroclaw_provider_scan.sh --no-refresh` -> `artifacts/zeroclaw/provider_options.md`. + - Status: `2026-02-21` done. + +- [x] T-009 - Verrouiller le mapping matériel par cible + - AC: la détection automatique affecte `zacus` aux ports `usbmodem`/S3 et `rtc` à l’Audio Kit (pas d’interversion habituelle). + - Evidence: + - `ZEROCLAW_ZACUS_UPLOAD_PORT_HINT="1a86,usbmodem,ch340,freenove" tools/ai/zeroclaw_hw_firmware_loop.sh zacus --port '' --monitor-secs 5` + - `ZEROCLAW_RTC_UPLOAD_PORT_HINT="cp2102,10c4,esp32audiokit,audio" tools/ai/zeroclaw_hw_firmware_loop.sh rtc --port '' --monitor-secs 5` + - Status: `2026-02-21` done (`usbmodem` prioritaire pour zacus, `usbmodem` pénalisé pour rtc). + ## Boucle hardware RTC - [x] T-101 - Discover hardware RTC diff --git a/specs/zeroclaw_dual_hw_todo.md b/specs/zeroclaw_dual_hw_todo.md index f490576..750f684 100644 --- a/specs/zeroclaw_dual_hw_todo.md +++ b/specs/zeroclaw_dual_hw_todo.md @@ -9,6 +9,9 @@ Last updated: 2026-02-21 - [x] I-003 - Ensure local Prometheus backend exists (`prometheus` binary installed). - [x] I-004 - Resolve open mirror PR redundancy (`PR #7` merge/close decision). - [x] I-005 - Enable local AI fallback (`ollama` + local model) for credit savings. +- [x] I-006 - Enforce local-only chat mode option (`--local-only`) with `ollama/lmstudio` detection. +- [x] I-007 - Align OpenClaw default model to local `ollama/llama3.2:1b` + cloud fallbacks for continuity. +- [x] I-008 - Add provider/agentic scanner (`tools/ai/zeroclaw_provider_scan.sh`) for live capability matrix. ## Daily autonomous sequence @@ -31,6 +34,11 @@ Last updated: 2026-02-21 - status: `2026-02-21` done (ESP32-S3 mismatch auto-corrected to `freenove_esp32s3`). - [x] D-005 - Review `artifacts/zeroclaw/gateway.log` + `conversations.jsonl`. +- [x] D-006 - Corriger l'association cible↔port + - `RTC_UPLOAD_PORT_HINT` cible Audio Kit (`cp2102`, `esp32audiokit`, `audio`). + - `ZACUS_UPLOAD_PORT_HINT` cible Freenove/S3 (`usbmodem`, `1a86`, `ch340`, `freenove`). + - status: `2026-02-21` done. + ## Hardware safety gates - [x] H-001 - Flash/upload/monitor are forced by default when a board is detected. diff --git a/tools/ai/zeroclaw_stack_up.sh b/tools/ai/zeroclaw_stack_up.sh index 31aea33..abbd11b 100755 --- a/tools/ai/zeroclaw_stack_up.sh +++ b/tools/ai/zeroclaw_stack_up.sh @@ -34,6 +34,9 @@ PREFER_LOCAL_AI="${ZEROCLAW_PREFER_LOCAL_AI:-0}" OLLAMA_MODEL="${ZEROCLAW_OLLAMA_MODEL:-llama3.2:1b}" OLLAMA_AUTO_PULL="${ZEROCLAW_OLLAMA_AUTO_PULL:-0}" OLLAMA_WARMUP="${ZEROCLAW_OLLAMA_WARMUP:-1}" +LMSTUDIO_BASE_URL="${ZEROCLAW_LMSTUDIO_BASE_URL:-http://127.0.0.1:1234/v1}" +LMSTUDIO_MODEL="${ZEROCLAW_LMSTUDIO_MODEL:-}" +LOCAL_PROVIDER_ORDER="${ZEROCLAW_LOCAL_PROVIDER_ORDER:-ollama,lmstudio}" GW_PID_FILE="$ART_DIR/gateway.pid" FW_PID_FILE="$ART_DIR/follow.pid" @@ -52,6 +55,7 @@ GW_MANAGED_FILE="$ART_DIR/gateway.managed" FW_MANAGED_FILE="$ART_DIR/follow.managed" PROM_MANAGED_FILE="$ART_DIR/prometheus.managed" WATCHER_SCRIPT="$ROOT_DIR/tools/ai/zeroclaw_watch_1min.sh" +ORCHESTRATOR_SERVER="$ROOT_DIR/tools/ai/zeroclaw_orchestrator_server.py" RT_LOG="$ART_DIR/realtime_1min.log" mkdir -p "$ART_DIR" @@ -164,9 +168,67 @@ PY return 1 } - if ensure_ollama_ready; then - preferred_provider="ollama" - preferred_model="$OLLAMA_MODEL" + lmstudio_first_model() { + python3 - "$LMSTUDIO_BASE_URL" <<'PY' +import json +import sys +import urllib.request + +base = sys.argv[1].rstrip("/") +url = f"{base}/models" +try: + req = urllib.request.Request(url, headers={"Accept": "application/json"}) + with urllib.request.urlopen(req, timeout=3) as resp: + payload = json.loads(resp.read().decode("utf-8", "replace")) + models = payload.get("data", []) + if isinstance(models, list): + for item in models: + if isinstance(item, dict): + model_id = str(item.get("id", "")).strip() + if model_id: + print(model_id) + raise SystemExit(0) +except Exception: + pass +raise SystemExit(1) +PY + } + + ensure_lmstudio_ready() { + [[ "$PREFER_LOCAL_AI" == "1" ]] || return 1 + local discovered + discovered="$(lmstudio_first_model 2>/dev/null || true)" + [[ -n "$discovered" ]] || return 1 + LMSTUDIO_MODEL="$discovered" + export LMSTUDIO_BASE_URL + export LMSTUDIO_API_KEY="${LMSTUDIO_API_KEY:-lm-studio}" + return 0 + } + + if [[ "$PREFER_LOCAL_AI" == "1" ]]; then + IFS=',' read -r -a local_provider_order <<<"$LOCAL_PROVIDER_ORDER" + for local_provider in "${local_provider_order[@]}"; do + case "${local_provider// /}" in + ollama) + if ensure_ollama_ready; then + preferred_provider="ollama" + preferred_model="$OLLAMA_MODEL" + break + fi + ;; + lmstudio) + if ensure_lmstudio_ready; then + preferred_provider="lmstudio" + preferred_model="$LMSTUDIO_MODEL" + break + fi + ;; + esac + done + fi + + if [[ -n "$preferred_provider" ]]; then + : elif env -u ZEROCLAW_WORKSPACE "$ZEROCLAW_BIN" auth status 2>/dev/null | rg -q "openai-codex:"; then preferred_provider="openai-codex" preferred_model="$OPENAI_CODEX_MODEL" @@ -177,7 +239,7 @@ PY return 0 fi - if [[ "$preferred_provider" == "ollama" ]]; then + if [[ "$preferred_provider" == "ollama" || "$preferred_provider" == "lmstudio" ]]; then if env -u ZEROCLAW_WORKSPACE "$ZEROCLAW_BIN" auth status 2>/dev/null | rg -q "openai-codex:"; then fallback_csv="openai-codex" fallback_models_csv="$OPENAI_CODEX_MODEL" @@ -592,7 +654,18 @@ elif [[ -n "$(listener_pid_on_port "$FOLLOW_PORT")" ]]; then printf '%s\n' "$existing_follow_pid" >"$FW_PID_FILE" rm -f "$FW_MANAGED_FILE" else - nohup python3 -m http.server "$FOLLOW_PORT" --bind 127.0.0.1 --directory "$ART_DIR" >"$FW_LOG" 2>&1 & + if [[ -f "$ORCHESTRATOR_SERVER" ]]; then + nohup python3 "$ORCHESTRATOR_SERVER" \ + --host 127.0.0.1 \ + --port "$FOLLOW_PORT" \ + --directory "$ART_DIR" \ + --root-dir "$ROOT_DIR" \ + --gateway-url "http://$GATEWAY_HOST:$GATEWAY_PORT" \ + --prom-url "http://$PROM_HOST:$PROM_PORT" \ + >"$FW_LOG" 2>&1 & + else + nohup python3 -m http.server "$FOLLOW_PORT" --bind 127.0.0.1 --directory "$ART_DIR" >"$FW_LOG" 2>&1 & + fi echo "$!" >"$FW_PID_FILE" printf '%s\n' "1" >"$FW_MANAGED_FILE" fi @@ -646,7 +719,7 @@ cat >"$INDEX_FILE" < - ZeroClaw Live Follow + ZeroClaw Orchestrator