feat(ai): add 1min watcher + realtime trace and hw loop docs #11

Merged
electron-rare merged 2 commits from codex/agentic-credit-local-optimization into main 2026-02-21 18:14:01 +00:00
7 changed files with 1189 additions and 44 deletions
+41 -15
View File
@@ -30,7 +30,29 @@ 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.
- [x] T-007 - Renforcer mode local-only agentic (ZeroClaw + OpenClaw)
- AC: `tools/ai/zeroclaw_dual_chat.sh <repo> --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` à lAudio Kit (pas dinterversion 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
@@ -43,14 +65,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 +86,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 <env valide> -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 +107,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`.
+27 -4
View File
@@ -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:
+23 -14
View File
@@ -8,6 +8,10 @@ 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.
- [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
@@ -16,35 +20,40 @@ 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`.
- [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
- [ ] 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.
+122
View File
@@ -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 <<USAGE
Usage: $(basename "$0") [--model <name>] [--no-pull] [--no-warmup] [--apply-macos-env]
Options:
--model <name> 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
chatgpt-codex-connector[bot] commented 2026-02-21 18:12:54 +00:00 (Migrated from github.com)
Review

P2 Badge Skip Homebrew requirement on non-macOS hosts

The script exits when brew is missing before it checks uname, so Linux users hit a hard failure even though the script already has a non-Darwin path (nohup ollama serve) and may already have ollama installed. This makes ollama_local_setup.sh unusable outside macOS unless Homebrew is installed unnecessarily.

Useful? React with 👍 / 👎.

**<sub><sub>![P2 Badge](https://img.shields.io/badge/P2-yellow?style=flat)</sub></sub> Skip Homebrew requirement on non-macOS hosts** The script exits when `brew` is missing before it checks `uname`, so Linux users hit a hard failure even though the script already has a non-Darwin path (`nohup ollama serve`) and may already have `ollama` installed. This makes `ollama_local_setup.sh` unusable outside macOS unless Homebrew is installed unnecessarily. Useful? React with 👍 / 👎.
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\""
+40
View File
@@ -24,6 +24,7 @@ Usage:
$(basename "$0") <rtc|zacus|path> --interactive
$(basename "$0") <rtc|zacus|path> --hardware
$(basename "$0") <rtc|zacus|path> --provider-check
$(basename "$0") <rtc|zacus|path> --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
+95 -6
View File
@@ -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)."
+841 -5
View File
@@ -28,6 +28,15 @@ 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}"
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"
@@ -46,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"
@@ -66,6 +76,246 @@ 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
}
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"
elif [[ -n "${OPENROUTER_API_KEY:-}" ]]; then
preferred_provider="openrouter"
preferred_model="$OPENROUTER_MODEL"
else
return 0
fi
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"
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
@@ -404,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
@@ -458,7 +719,7 @@ cat >"$INDEX_FILE" <<EOF
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>ZeroClaw Live Follow</title>
<title>ZeroClaw Orchestrator</title>
<style>
:root {
--bg: #f7f8fa;
@@ -505,6 +766,25 @@ cat >"$INDEX_FILE" <<EOF
padding: 5px 10px;
font-size: 13px;
}
.alert-banner {
border: 1px solid #d97706;
background: #fff7ed;
color: #9a3412;
border-radius: 10px;
padding: 8px 10px;
margin-bottom: 12px;
font-size: 13px;
display: none;
}
.alert-banner.show {
display: block;
}
.ops-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 12px;
}
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
@@ -526,9 +806,16 @@ cat >"$INDEX_FILE" <<EOF
border-bottom: 1px solid var(--line);
font-size: 16px;
}
.panel .body {
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 10px;
flex: 1;
}
.panel pre {
margin: 0;
padding: 12px 14px;
padding: 10px 12px;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
@@ -536,6 +823,124 @@ cat >"$INDEX_FILE" <<EOF
font-size: 12px;
line-height: 1.4;
flex: 1;
background: #fafbfd;
border: 1px solid var(--line);
border-radius: 8px;
}
textarea {
width: 100%;
min-height: 64px;
resize: vertical;
padding: 8px;
font-size: 13px;
border: 1px solid var(--line);
border-radius: 8px;
font-family: "SF Pro Text", "Segoe UI", Arial, sans-serif;
}
.btn-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
button {
border: 1px solid var(--line);
background: #fdfefe;
color: var(--ink);
border-radius: 8px;
padding: 8px 10px;
cursor: pointer;
font-size: 12px;
}
button.primary {
border-color: #245fa1;
color: #fff;
background: #245fa1;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.hint {
color: var(--muted);
font-size: 12px;
margin: 0;
}
.status-block {
color: var(--muted);
font-size: 12px;
border: 1px dashed var(--line);
border-radius: 8px;
padding: 8px 10px;
}
.questions-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 12px;
}
.question-item {
border: 1px solid var(--line);
border-radius: 8px;
padding: 8px;
background: #fcfdff;
margin-bottom: 8px;
}
.question-item.blocking {
border-color: #f59e0b;
background: #fffaf0;
}
.question-title {
font-size: 13px;
font-weight: 600;
margin-bottom: 4px;
}
.question-meta {
font-size: 11px;
color: var(--muted);
margin-bottom: 6px;
}
.question-context {
font-size: 12px;
white-space: pre-wrap;
border: 1px solid var(--line);
border-radius: 8px;
padding: 6px;
background: #fff;
margin-bottom: 8px;
}
.question-answer {
min-height: 52px;
margin-bottom: 6px;
}
.modal {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.5);
display: none;
align-items: center;
justify-content: center;
z-index: 9999;
padding: 16px;
}
.modal.open {
display: flex;
}
.modal-card {
width: min(760px, 100%);
chatgpt-codex-connector[bot] commented 2026-02-21 18:12:54 +00:00 (Migrated from github.com)
Review

P1 Badge Ship orchestrator API server or disable API dashboard mode

This branch adds a dashboard that polls /api/status, /api/run, and question endpoints, but startup only serves those routes when tools/ai/zeroclaw_orchestrator_server.py exists. In this commit tree the file is not present (the new tools/ai entries do not include it), so this condition is always false and the script always falls back to python -m http.server, which returns 404 for every new orchestration control/API panel.

Useful? React with 👍 / 👎.

**<sub><sub>![P1 Badge](https://img.shields.io/badge/P1-orange?style=flat)</sub></sub> Ship orchestrator API server or disable API dashboard mode** This branch adds a dashboard that polls `/api/status`, `/api/run`, and question endpoints, but startup only serves those routes when `tools/ai/zeroclaw_orchestrator_server.py` exists. In this commit tree the file is not present (the new `tools/ai` entries do not include it), so this condition is always false and the script always falls back to `python -m http.server`, which returns 404 for every new orchestration control/API panel. Useful? React with 👍 / 👎.
background: #fff;
border: 1px solid var(--line);
border-radius: 12px;
box-shadow: 0 12px 40px rgba(17, 26, 41, 0.25);
padding: 14px;
}
.modal-card h3 {
margin: 0 0 8px;
font-size: 18px;
}
.modal-card .small {
margin: 0 0 8px;
font-size: 12px;
color: var(--muted);
}
.status {
margin-top: 10px;
@@ -543,6 +948,8 @@ cat >"$INDEX_FILE" <<EOF
font-size: 12px;
}
@media (max-width: 980px) {
.ops-grid { grid-template-columns: 1fr; }
.questions-grid { grid-template-columns: 1fr; }
.grid { grid-template-columns: 1fr; }
.panel { min-height: 320px; }
}
@@ -550,23 +957,96 @@ cat >"$INDEX_FILE" <<EOF
</head>
<body>
<main>
<h1>ZeroClaw Live Follow</h1>
<h1>ZeroClaw Orchestrator</h1>
<div class="meta">
Follow URL: <code>http://127.0.0.1:$FOLLOW_PORT/</code> |
Polling interval: <code>1s</code> |
Display cap: <code>500 lines/panel</code> |
Prometheus: <code>$PROM_STATUS</code>
Prometheus: <code>$PROM_STATUS</code> |
API: <code>/api/status</code> <code>/api/run</code>
</div>
<div id="questionAlert" class="alert-banner"></div>
<div class="links">
<a href="/conversations.jsonl">/conversations.jsonl</a>
<a href="/gateway.log">/gateway.log</a>
<a href="/realtime_1min.log">/realtime_1min.log</a>
<a href="/orchestrator.log">/orchestrator.log</a>
<a href="/prometheus.yml">/prometheus.yml</a>
<a href="http://$GATEWAY_HOST:$GATEWAY_PORT/health">/health</a>
<a href="http://$GATEWAY_HOST:$GATEWAY_PORT/metrics">/metrics</a>
<a href="http://$PROM_HOST:$PROM_PORT/targets">prometheus /targets</a>
<a href="http://$PROM_HOST:$PROM_PORT/graph">prometheus /graph</a>
</div>
<div class="ops-grid">
<section class="panel">
<h2>Orchestration Controls</h2>
<div class="body">
<p class="hint">Prompt general (source)</p>
<textarea id="generalPromptInput">Autonomy heartbeat: return concise status and blockers</textarea>
<div class="btn-row">
<button onclick="setGeneralPrompt('RTC post-flash check: verify audio wifi status and recent errors')">General RTC Quick</button>
<button onclick="setGeneralPrompt('Zacus post-flash check: verify network ui audio story state and recent errors')">General Zacus Quick</button>
<button onclick="setGeneralPrompt('Autonomy heartbeat: return concise status and blockers')">General Heartbeat</button>
<button onclick="deriveRepoPrompts()">Generate RTC+Zacus Prompts</button>
<button class="primary" onclick="runAction('general_prompt_fanout', true)">Send General -> RTC+Zacus</button>
</div>
<p class="hint">Prompt RTC</p>
<textarea id="rtcPromptInput">RTC post-flash check: verify audio wifi status and recent errors</textarea>
<p class="hint">Prompt Zacus</p>
<textarea id="zacusPromptInput">Zacus post-flash check: verify network ui audio story state and recent errors</textarea>
<div class="btn-row">
<button onclick="runAction('rtc_webhook', true)">Send RTC Prompt</button>
<button onclick="runAction('zacus_webhook', true)">Send Zacus Prompt</button>
</div>
<p class="hint">Operator presets (one-click chains with progress in Job panel)</p>
<div class="btn-row">
<button class="primary" onclick="runPreset('rtc_operator_chain', 'RTC post-flash check: verify audio wifi status and recent errors', true)">Run RTC Operator Chain</button>
<button class="primary" onclick="runPreset('zacus_operator_chain', 'Zacus post-flash check: verify network ui audio story state and recent errors', true)">Run Zacus Operator Chain</button>
<button class="primary" onclick="runPreset('daily_chain', '', false)">Run Daily Chain</button>
</div>
<div class="btn-row">
<button onclick="runAction('provider_scan', false)">Provider Scan</button>
<button onclick="runAction('rtc_provider_check', false)">RTC Provider Check</button>
<button onclick="runAction('zacus_provider_check', false)">Zacus Provider Check</button>
<button onclick="runAction('rtc_firmware_loop', false)">RTC Build/Flash/Monitor</button>
<button onclick="runAction('zacus_firmware_loop', false)">Zacus Build/Flash/Monitor</button>
<button onclick="stopJob()">Stop Current Job</button>
</div>
<div class="status-block" id="opsStatus">Waiting for API status...</div>
</div>
</section>
<section class="panel">
<h2>Job Progress</h2>
<div class="body">
<pre id="jobInfo">No job yet.</pre>
<pre id="jobTail">(job output)</pre>
</div>
</section>
</div>
<div class="questions-grid">
<section class="panel">
<h2>Questions / Decisions</h2>
<div class="body">
<p class="hint">Create a blocking question when a decision is needed.</p>
<textarea id="questionTitleInput">Decision needed</textarea>
<textarea id="questionContextInput">Context and current blocker...</textarea>
<textarea id="questionOptionsInput">A | B | C</textarea>
<textarea id="questionRecommendationInput">Recommended: B</textarea>
<div class="btn-row">
<label class="hint"><input type="checkbox" id="questionBlockingInput" checked> blocking</label>
<button class="primary" onclick="raiseQuestion()">Raise Question</button>
<button onclick="refreshQuestions()">Refresh Questions</button>
</div>
<div class="status-block" id="questionsSummary">No questions loaded.</div>
</div>
</section>
<section class="panel">
<h2>Open Questions</h2>
<div class="body">
<div id="openQuestions">(no open questions)</div>
</div>
</section>
</div>
<div class="grid">
<section class="panel">
<h2>Conversations (JSONL live)</h2>
@@ -579,15 +1059,28 @@ cat >"$INDEX_FILE" <<EOF
</div>
<div class="status" id="status">Polling started...</div>
</main>
<div id="questionPopup" class="modal">
<div class="modal-card">
<h3>Blocking Question</h3>
<p class="small" id="popupMeta"></p>
<pre id="popupBody"></pre>
<div class="btn-row">
<button onclick="closeQuestionPopup()">Close</button>
</div>
</div>
</div>
<script>
const MAX_LINES = 500;
const POLL_MS = 1000;
const API_POLL_MS = 1500;
let convoCount = 0;
let gatewayCount = 0;
let convoBuf = [];
let gatewayBuf = [];
let lastOk = null;
let runInFlight = false;
let lastPopupQuestionId = Number(localStorage.getItem("zc_last_popup_question_id") || "0");
function trimTail(lines) {
if (lines.length <= MAX_LINES) return lines;
@@ -650,6 +1143,345 @@ cat >"$INDEX_FILE" <<EOF
}
}
async function fetchJson(url) {
const response = await fetch(url + "?t=" + Date.now(), { cache: "no-store" });
const body = await response.text();
let parsed = {};
try {
parsed = body ? JSON.parse(body) : {};
} catch (error) {
parsed = { raw: body };
}
if (!response.ok) {
const msg = parsed.error || ("HTTP " + response.status);
throw new Error(msg);
}
return parsed;
}
function boolText(value) {
if (value === true) return "yes";
if (value === false) return "no";
return "unknown";
}
function closeQuestionPopup() {
document.getElementById("questionPopup").classList.remove("open");
}
function showQuestionPopup(question) {
if (!question) return;
const meta =
"id=" + safeText(question.id) +
" blocking=" + boolText(question.blocking) +
" created=" + safeText(question.created_at);
let body = safeText(question.title);
if (question.context) body += "\n\n" + safeText(question.context);
const options = Array.isArray(question.options) ? question.options : [];
if (options.length) body += "\n\nOptions: " + options.join(" | ");
if (question.recommendation) body += "\nRecommendation: " + safeText(question.recommendation);
document.getElementById("popupMeta").textContent = meta;
document.getElementById("popupBody").textContent = body;
document.getElementById("questionPopup").classList.add("open");
}
function renderOpenQuestions(openItems) {
const root = document.getElementById("openQuestions");
root.innerHTML = "";
if (!openItems.length) {
root.textContent = "(no open questions)";
return;
}
for (const q of openItems) {
const box = document.createElement("div");
box.className = "question-item" + (q.blocking ? " blocking" : "");
const title = document.createElement("div");
title.className = "question-title";
title.textContent = "#" + safeText(q.id) + " " + safeText(q.title);
box.appendChild(title);
const meta = document.createElement("div");
meta.className = "question-meta";
meta.textContent = "blocking=" + boolText(q.blocking) + " created=" + safeText(q.created_at);
box.appendChild(meta);
const ctx = document.createElement("div");
ctx.className = "question-context";
let ctxText = safeText(q.context || "");
const options = Array.isArray(q.options) ? q.options : [];
if (options.length) ctxText += (ctxText ? "\n" : "") + "Options: " + options.join(" | ");
if (q.recommendation) ctxText += (ctxText ? "\n" : "") + "Recommendation: " + safeText(q.recommendation);
ctx.textContent = ctxText || "(no context)";
box.appendChild(ctx);
const answer = document.createElement("textarea");
answer.className = "question-answer";
answer.id = "q-answer-" + safeText(q.id);
answer.placeholder = "Decision / answer...";
box.appendChild(answer);
const row = document.createElement("div");
row.className = "btn-row";
const resolveBtn = document.createElement("button");
resolveBtn.textContent = "Resolve";
resolveBtn.onclick = function () {
resolveQuestion(q.id);
};
const popupBtn = document.createElement("button");
popupBtn.textContent = "Popup";
popupBtn.onclick = function () {
showQuestionPopup(q);
};
row.appendChild(resolveBtn);
row.appendChild(popupBtn);
box.appendChild(row);
root.appendChild(box);
}
}
async function refreshQuestions() {
try {
const data = await fetchJson("/api/questions");
const openItems = Array.isArray(data.open_items) ? data.open_items : [];
const summary =
"open=" + safeText(data.open_count) +
" blocking_open=" + safeText(data.blocking_open_count) +
" total=" + safeText(Array.isArray(data.items) ? data.items.length : 0);
document.getElementById("questionsSummary").textContent = summary;
renderOpenQuestions(openItems);
const alertBox = document.getElementById("questionAlert");
if ((data.blocking_open_count || 0) > 0) {
alertBox.classList.add("show");
alertBox.textContent = "Blocking question open: " + safeText(data.blocking_open_count) + " (see Questions / Decisions)";
} else {
alertBox.classList.remove("show");
alertBox.textContent = "";
}
const latest = data.latest_open;
if (latest && latest.blocking && Number(latest.id) > lastPopupQuestionId) {
lastPopupQuestionId = Number(latest.id);
localStorage.setItem("zc_last_popup_question_id", String(lastPopupQuestionId));
showQuestionPopup(latest);
window.alert("New blocking question #" + safeText(latest.id) + ": " + safeText(latest.title));
}
} catch (error) {
document.getElementById("questionsSummary").textContent = "questions api error: " + error.message;
}
}
async function raiseQuestion() {
const payload = {
title: document.getElementById("questionTitleInput").value.trim(),
context: document.getElementById("questionContextInput").value.trim(),
options: document.getElementById("questionOptionsInput").value.trim(),
recommendation: document.getElementById("questionRecommendationInput").value.trim(),
blocking: document.getElementById("questionBlockingInput").checked,
};
try {
const response = await fetch("/api/questions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const body = await response.text();
let parsed = {};
try {
parsed = body ? JSON.parse(body) : {};
} catch (error) {
parsed = { raw: body };
}
if (!response.ok) {
throw new Error(parsed.error || ("HTTP " + response.status));
}
setStatus("question raised", false);
await refreshQuestions();
} catch (error) {
setStatus("raise question failed: " + error.message, true);
}
}
async function resolveQuestion(id) {
const input = document.getElementById("q-answer-" + safeText(id));
const responseText = input ? input.value.trim() : "";
try {
const response = await fetch("/api/questions/resolve", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: id, response: responseText }),
});
const body = await response.text();
let parsed = {};
try {
parsed = body ? JSON.parse(body) : {};
} catch (error) {
parsed = { raw: body };
}
if (!response.ok) {
throw new Error(parsed.error || ("HTTP " + response.status));
}
setStatus("question resolved #" + safeText(id), false);
await refreshQuestions();
} catch (error) {
setStatus("resolve question failed: " + error.message, true);
}
}
async function refreshOps() {
try {
const status = await fetchJson("/api/status");
const health = status.health || {};
const last = status.last || {};
const job = status.job || {};
const ops =
"gateway_ok=" + boolText(health.gateway_ok) +
" status=" + safeText(health.gateway_status) +
" paired=" + boolText(health.paired) +
" prom_ok=" + boolText(health.prometheus_ok) +
" prom_status=" + safeText(health.prometheus_status) +
"\nlast_convo: " + safeText(last.conversation || "(none)") +
"\nlast_rt1m: " + safeText(last.realtime_1min || "(none)");
document.getElementById("opsStatus").textContent = ops;
const jobInfo =
"job_id=" + safeText(job.job_id) +
" running=" + boolText(job.running) +
" action=" + safeText(job.action) +
"\nstarted_at=" + safeText(job.started_at) +
" ended_at=" + safeText(job.ended_at) +
" returncode=" + safeText(job.returncode);
document.getElementById("jobInfo").textContent = jobInfo;
const tail = Array.isArray(job.tail) ? job.tail : [];
document.getElementById("jobTail").textContent = tail.length ? tail.join("\n") : "(job output)";
} catch (error) {
document.getElementById("opsStatus").textContent = "api status error: " + error.message;
}
}
function setButtonsDisabled(disabled) {
const buttons = document.querySelectorAll("button");
for (const btn of buttons) {
btn.disabled = disabled;
}
}
function setGeneralPrompt(text) {
document.getElementById("generalPromptInput").value = text;
setStatus("general prompt template loaded", false);
}
function deriveRepoPrompts() {
const general = document.getElementById("generalPromptInput").value.trim();
if (!general) {
setStatus("general prompt is required", true);
return;
}
const rtcPrompt =
general +
"\\nContexte cible: RTC_BL_PHONE sur ESP32 Audio Kit." +
"\\nAttendu: diagnostic audio/Bluetooth/WiFi/WebServer + erreurs recentes + prochaine action concise.";
const zacusPrompt =
general +
"\\nContexte cible: le-mystere-professeur-zacus sur Freenove ESP32-S3 (usbmodem)." +
"\\nAttendu: diagnostic UI/story/audio/network + erreurs recentes + prochaine action concise.";
document.getElementById("rtcPromptInput").value = rtcPrompt;
document.getElementById("zacusPromptInput").value = zacusPrompt;
setStatus("rtc/zacus prompts generated from general prompt", false);
}
function getPromptForAction(action) {
if (action === "rtc_webhook" || action === "rtc_operator_chain") {
return document.getElementById("rtcPromptInput").value.trim();
}
if (action === "zacus_webhook" || action === "zacus_operator_chain") {
return document.getElementById("zacusPromptInput").value.trim();
}
if (action === "general_prompt_fanout") {
return document.getElementById("generalPromptInput").value.trim();
}
return document.getElementById("generalPromptInput").value.trim();
}
async function runPreset(action, defaultPrompt, needsMessage) {
if (needsMessage) {
let input = null;
if (action === "rtc_operator_chain") input = document.getElementById("rtcPromptInput");
if (action === "zacus_operator_chain") input = document.getElementById("zacusPromptInput");
if (!input) input = document.getElementById("generalPromptInput");
if (!input.value.trim() && defaultPrompt) {
input.value = defaultPrompt;
}
}
await runAction(action, needsMessage);
}
async function runAction(action, needsMessage) {
if (runInFlight) return;
const payload = { action: action };
if (needsMessage) {
const message = getPromptForAction(action);
if (!message) {
setStatus("prompt is required for " + action, true);
return;
}
payload.message = message;
}
runInFlight = true;
setButtonsDisabled(true);
try {
const response = await fetch("/api/run", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const body = await response.text();
let parsed = {};
try {
parsed = body ? JSON.parse(body) : {};
} catch (error) {
parsed = { raw: body };
}
if (!response.ok) {
throw new Error(parsed.error || ("HTTP " + response.status));
}
setStatus("action started: " + action, false);
} catch (error) {
setStatus("action failed: " + action + " -> " + error.message, true);
} finally {
runInFlight = false;
setButtonsDisabled(false);
await refreshOps();
}
}
async function stopJob() {
try {
const response = await fetch("/api/stop", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{}",
});
const body = await response.text();
let parsed = {};
try {
parsed = body ? JSON.parse(body) : {};
} catch (error) {
parsed = { raw: body };
}
if (!response.ok) {
throw new Error(parsed.error || ("HTTP " + response.status));
}
setStatus("job stop requested", false);
} catch (error) {
setStatus("stop failed: " + error.message, true);
} finally {
await refreshOps();
}
}
function setStatus(message, isError) {
const now = new Date().toISOString();
lastOk = !isError;
@@ -671,6 +1503,10 @@ cat >"$INDEX_FILE" <<EOF
tick();
setInterval(tick, POLL_MS);
refreshOps();
setInterval(refreshOps, API_POLL_MS);
refreshQuestions();
setInterval(refreshQuestions, API_POLL_MS);
</script>
</body>
</html>