diff --git a/community/codex-task-runner/README.md b/community/codex-task-runner/README.md new file mode 100644 index 0000000..0fd5579 --- /dev/null +++ b/community/codex-task-runner/README.md @@ -0,0 +1,182 @@ +# Codex Task Runner + +![Community](https://img.shields.io/badge/OpenHome-Community-orange?style=flat-square) +![Author](https://img.shields.io/badge/Author-@juyounglee-lightgrey?style=flat-square) + +## What It Does +Runs a coding task through a remote webhook that executes `codex exec` headlessly, then reads back a short spoken result. + +## Client / Server Example +- **Client:** OpenHome WebUI (or OpenHome runtime) running this ability. +- **Server:** any webhook server that exposes `POST /run` and returns the expected JSON. +- If OpenHome is remote, expose local server with a tunnel and set `WEBHOOK_URL` to that public `/run` URL. + +## Configuration style (WebUI-friendly) +This ability is configured directly in `main.py` constants: + +```python +WEBHOOK_URL = "https:///run" +WEBHOOK_TOKEN = "" +REQUEST_TIMEOUT_SECONDS = 180 +``` + +Use the exact same `WEBHOOK_TOKEN` value on both sides. + +## Suggested Trigger Words +- "run codex task" +- "ask codex to code" +- "execute coding task" + +## Setup +1. Run any webhook server that accepts `POST /run` with bearer auth. +2. Run `ngrok http 8080` if OpenHome must call your local machine. +3. In this ability's `main.py`, replace `WEBHOOK_URL` and `WEBHOOK_TOKEN` placeholders. +4. Upload this Ability zip to OpenHome and set trigger words in the dashboard. + +## Minimal Codex Webhook Example (crude) + +```python +# NOTE: This example runs on a separate webhook server, not inside an OpenHome +# ability. It is not subject to OpenHome ability SDK restrictions. +import os +import subprocess +import uuid +from flask import Flask, jsonify, request + +app = Flask(__name__) +WEBHOOK_TOKEN = os.environ.get("WEBHOOK_TOKEN", "YOUR_WEBHOOK_TOKEN_HERE") +RUNS_DIR = os.environ.get("RUNS_DIR", "./runs") +DEFAULT_WORKDIR = os.path.realpath( + os.path.abspath(os.environ.get("CODEX_WORKDIR", ".")) +) +CODEX_SANDBOX = os.environ.get("CODEX_SANDBOX", "workspace-write") +CODEX_TIMEOUT_SECONDS = int(os.environ.get("CODEX_TIMEOUT_SECONDS", "600")) + + +def _is_allowed_workdir(path: str) -> bool: + target = os.path.realpath(os.path.abspath(path)) + try: + common = os.path.commonpath([DEFAULT_WORKDIR, target]) + except ValueError: + return False + return common == DEFAULT_WORKDIR + + +@app.post("/run") +def run(): + auth = request.headers.get("Authorization", "") + if auth != f"Bearer {WEBHOOK_TOKEN}": + return jsonify({"ok": False, "error": "unauthorized"}), 401 + + req = request.get_json(silent=True) or {} + prompt = (req.get("prompt") or "").strip() + if not prompt: + return jsonify({"ok": False, "error": "prompt is required"}), 400 + + target_workdir = req.get("workdir") or DEFAULT_WORKDIR + if not _is_allowed_workdir(target_workdir): + return jsonify({"ok": False, "error": "workdir not allowed"}), 403 + if not os.path.isdir(target_workdir): + return jsonify({"ok": False, "error": "workdir not found"}), 400 + + request_id = uuid.uuid4().hex[:12] + run_dir = os.path.join(RUNS_DIR, request_id) + os.makedirs(run_dir, exist_ok=True) + + artifact_path = os.path.join(run_dir, "final-message.txt") + events_path = os.path.join(run_dir, "events.jsonl") + stderr_path = os.path.join(run_dir, "stderr.log") + + cmd = [ + "codex", + "exec", + "-C", + target_workdir, + "--json", + "-o", + artifact_path, + "--full-auto", + "--sandbox", + CODEX_SANDBOX, + prompt, + ] + + with open(events_path, "w", encoding="utf-8") as out, open( + stderr_path, "w", encoding="utf-8" + ) as err: + result = subprocess.run( + cmd, + stdout=out, + stderr=err, + text=True, + timeout=CODEX_TIMEOUT_SECONDS, + check=False, + ) + + if result.returncode != 0: + return jsonify({ + "ok": False, + "error": f"codex failed with exit code {result.returncode}", + "events_path": events_path, + "request_id": request_id, + }), 500 + + summary = "Codex completed the task." + if os.path.exists(artifact_path): + with open(artifact_path, "r", encoding="utf-8") as f: + summary = (f.read().strip() or summary)[:800] + + return jsonify({ + "ok": True, + "summary": summary, + "artifact_path": artifact_path, + "events_path": events_path, + "request_id": request_id, + }) +``` + +## Expected Webhook Response +The ability expects JSON with this shape: + +```json +{ + "ok": true, + "summary": "Codex completed the task and updated two files.", + "artifact_path": "/absolute/path/to/final-message.txt", + "events_path": "/absolute/path/to/events.jsonl", + "request_id": "7fd8c0bf44c1" +} +``` + +For production deployments, prefer returning relative paths (or opaque IDs/URLs) +instead of absolute filesystem paths. + +## How It Works +1. Ask user for a coding task. +2. Check required constants (`WEBHOOK_URL`, `WEBHOOK_TOKEN`). +3. Confirm before executing. +4. Send task to webhook as JSON (`{"prompt": "..."}`) with bearer auth. +5. Speak returned summary and optional artifact path. +6. Return to normal Personality flow. + +## Quick test flow +1. Trigger with a phrase like **"run codex task"**. +2. Give a short task prompt. +3. Say **yes** on confirmation. +4. Verify spoken summary and webhook artifact path. + +## Logs +- Ability logs are emitted with `editor_logging_handler` in OpenHome Live Editor logs. +- Look for `[CodexTaskRunner]` entries. +- On successful webhook calls, logs include `request_id` so you can match server-side logs. + +## Token hygiene +For demo use, static token constants are fine. After testing, rotate the token on both webhook and ability. + +## Example Conversation +> **User:** "run codex task" +> **AI:** "Tell me the coding task you want Codex to run." +> **User:** "Add basic tests for the validator script and run them." +> **AI:** "Got it. Want me to run Codex on that now?" +> **User:** "Yes" +> **AI:** "Codex added tests and confirmed they pass." diff --git a/community/codex-task-runner/__init__.py b/community/codex-task-runner/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/community/codex-task-runner/__init__.py @@ -0,0 +1 @@ + diff --git a/community/codex-task-runner/main.py b/community/codex-task-runner/main.py new file mode 100644 index 0000000..dec6e34 --- /dev/null +++ b/community/codex-task-runner/main.py @@ -0,0 +1,250 @@ +"""OpenHome ability that proxies coding tasks to an external Codex webhook. + +Conversation flow: +1) Ask for task. +2) Confirm intent. +3) Call webhook. +4) Speak result. + +Client/server example: +- Client: OpenHome WebUI/voice runtime executing this ability. +- Server: any webhook server implementation exposing POST /run. +""" + +import asyncio +import requests +from src.agent.capability import MatchingCapability +from src.main import AgentWorker +from src.agent.capability_worker import CapabilityWorker + +# Configure these directly before uploading to OpenHome WebUI. +WEBHOOK_URL = "YOUR_WEBHOOK_URL_HERE" +WEBHOOK_TOKEN = "YOUR_WEBHOOK_TOKEN_HERE" +REQUEST_TIMEOUT_SECONDS = 180 +EXIT_WORDS = {"stop", "cancel", "exit", "quit", "never mind"} +MAX_LOG_PREVIEW_CHARS = 120 +VOICE_SUMMARY_MAX_INPUT_CHARS = 3000 +MAX_SPOKEN_SUMMARY_CHARS = 420 + + +class CodexTaskRunnerCapability(MatchingCapability): + """Ability entrypoint that coordinates speech UX and webhook execution.""" + + worker: AgentWorker = None + capability_worker: CapabilityWorker = None + + # register capability tag: #{{register capability}} + + def call(self, worker: AgentWorker): + """OpenHome SDK hook; starts async ability flow.""" + self.worker = worker + self.capability_worker = CapabilityWorker(self.worker) + self.worker.editor_logging_handler.info( + "[CodexTaskRunner] capability called; starting async run task" + ) + self.worker.session_tasks.create(self.run()) + + def _preview(self, text: str) -> str: + """Return compact preview string for logs.""" + compact = " ".join(text.split()) + if len(compact) <= MAX_LOG_PREVIEW_CHARS: + return compact + return f"{compact[:MAX_LOG_PREVIEW_CHARS]}..." + + def _is_configured(self) -> bool: + """Return True when placeholders were replaced with real values.""" + return WEBHOOK_URL not in {"", "YOUR_WEBHOOK_URL_HERE"} and WEBHOOK_TOKEN not in { + "", + "YOUR_WEBHOOK_TOKEN_HERE", + } + + def _to_conversational_summary(self, raw_summary: str) -> str: + """Rewrite structured webhook summary into short natural speech.""" + rewrite_prompt = ( + "Rewrite this coding result for spoken voice. " + "Use 1-2 short conversational sentences. " + "Do not read list numbers, markdown, file paths, or command snippets. " + "Keep only the key outcome and one optional follow-up.\n\n" + f"Result:\n{raw_summary[:VOICE_SUMMARY_MAX_INPUT_CHARS]}" + ) + + try: + rewritten = self.capability_worker.text_to_text_response( + rewrite_prompt, + self.worker.agent_memory.full_message_history, + ) + cleaned = (rewritten or "").replace("```", "").strip() + if not cleaned: + return raw_summary + if len(cleaned) > MAX_SPOKEN_SUMMARY_CHARS: + return f"{cleaned[:MAX_SPOKEN_SUMMARY_CHARS - 3].rstrip()}..." + return cleaned + except Exception as err: + self.worker.editor_logging_handler.warning( + f"[CodexTaskRunner] voice summary rewrite failed: {err}" + ) + return raw_summary + + async def _call_webhook(self, user_request: str) -> dict | None: + """Call webhook and return parsed JSON payload on success.""" + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {WEBHOOK_TOKEN}", + } + + self.worker.editor_logging_handler.info( + "[CodexTaskRunner] sending webhook request " + f"url={WEBHOOK_URL} prompt_len={len(user_request)}" + ) + + try: + webhook_response = await asyncio.to_thread( + requests.post, + WEBHOOK_URL, + headers=headers, + json={"prompt": user_request}, + timeout=REQUEST_TIMEOUT_SECONDS, + ) + except Exception as err: + self.worker.editor_logging_handler.error( + f"[CodexTaskRunner] webhook request failed: {err}" + ) + return None + + self.worker.editor_logging_handler.info( + "[CodexTaskRunner] webhook response received " + f"status={webhook_response.status_code}" + ) + if webhook_response.status_code != 200: + self.worker.editor_logging_handler.error( + "[CodexTaskRunner] webhook non-200 response: " + f"{webhook_response.status_code} {webhook_response.text}" + ) + return None + + try: + response_payload = webhook_response.json() + except Exception as err: + self.worker.editor_logging_handler.error( + f"[CodexTaskRunner] invalid JSON response: {err}" + ) + return None + + if isinstance(response_payload, dict): + return response_payload + + self.worker.editor_logging_handler.error( + "[CodexTaskRunner] webhook response is not an object" + ) + return None + + async def run(self): + """Main conversation flow from user prompt to spoken result.""" + try: + self.worker.editor_logging_handler.info( + "[CodexTaskRunner] session started" + ) + + if not self._is_configured(): + self.worker.editor_logging_handler.error( + "[CodexTaskRunner] configuration error: replace webhook placeholders" + ) + await self.capability_worker.speak( + "This Codex task runner is not configured yet. " + "Please set WEBHOOK_URL and WEBHOOK_TOKEN placeholders." + ) + return + + # 1) Gather user task and handle fast cancel path. + await self.capability_worker.speak( + "Tell me the coding task you want Codex to run." + ) + user_request = await self.capability_worker.user_response() + + if not user_request: + self.worker.editor_logging_handler.warning( + "[CodexTaskRunner] user_request empty" + ) + await self.capability_worker.speak( + "I didn't catch that. Please try again." + ) + return + + request_preview = self._preview(user_request) + self.worker.editor_logging_handler.info( + "[CodexTaskRunner] user_request received " + f"preview='{request_preview}'" + ) + + lowered = user_request.lower().strip() + if any(lowered == word or lowered.startswith(f"{word} ") for word in EXIT_WORDS): + self.worker.editor_logging_handler.info( + "[CodexTaskRunner] exit word detected; canceling request" + ) + await self.capability_worker.speak("Okay, canceled.") + return + + # 2) Explicit confirmation before external execution. + self.worker.editor_logging_handler.info( + "[CodexTaskRunner] confirmation requested " + f"preview='{request_preview}'" + ) + confirmed = await self.capability_worker.run_confirmation_loop( + "Got it. Want me to run Codex on that now?" + ) + self.worker.editor_logging_handler.info( + f"[CodexTaskRunner] confirmation_result={confirmed}" + ) + if not confirmed: + await self.capability_worker.speak("Okay, I won't run it.") + return + + # 3) Execute request via webhook. + await self.capability_worker.speak( + "Running Codex now. This may take up to a few minutes." + ) + webhook_result = await self._call_webhook(user_request) + + if not webhook_result or not webhook_result.get("ok"): + self.worker.editor_logging_handler.error( + "[CodexTaskRunner] webhook returned failure payload" + ) + await self.capability_worker.speak( + "I couldn't complete that Codex run right now. " + "Please check your webhook server logs." + ) + return + + raw_summary = webhook_result.get("summary") or ( + "Codex finished, but the webhook returned no summary text." + ) + self.worker.editor_logging_handler.info( + "[CodexTaskRunner] webhook success " + f"request_id={webhook_result.get('request_id', '')} " + "summary_len=" + f"{len(raw_summary)} artifact_path={webhook_result.get('artifact_path', '')}" + ) + + # 4) Speak concise result. + spoken_summary = self._to_conversational_summary(raw_summary) + + await self.capability_worker.speak(spoken_summary) + + if webhook_result.get("artifact_path"): + await self.capability_worker.speak( + "I also saved the full output in the run artifacts." + ) + + except Exception as err: + self.worker.editor_logging_handler.error( + f"[CodexTaskRunner] unexpected error: {err}" + ) + await self.capability_worker.speak( + "Something went wrong while running the coding task." + ) + finally: + # Always return control to normal personality flow. + self.worker.editor_logging_handler.info( + "[CodexTaskRunner] session finished; resuming normal flow" + ) + self.capability_worker.resume_normal_flow()