From 44e2db8539e81ce86ba5f39fbfb343ea4e6c1853 Mon Sep 17 00:00:00 2001 From: Juyoung Lee Date: Sun, 22 Feb 2026 21:09:26 -0800 Subject: [PATCH 01/14] Add Codex Task Runner community ability Introduce a WebUI-compatible ability that relays coding tasks to a Codex webhook with confirmation/cancel flow and conversational summaries. Document setup, trigger words, and a minimal Codex-focused /run webhook contract for contributors. --- community/codex-task-runner/README.md | 136 ++++++++++++ community/codex-task-runner/__init__.py | 1 + community/codex-task-runner/main.py | 263 ++++++++++++++++++++++++ 3 files changed, 400 insertions(+) create mode 100644 community/codex-task-runner/README.md create mode 100644 community/codex-task-runner/__init__.py create mode 100644 community/codex-task-runner/main.py diff --git a/community/codex-task-runner/README.md b/community/codex-task-runner/README.md new file mode 100644 index 00000000..ea694fc8 --- /dev/null +++ b/community/codex-task-runner/README.md @@ -0,0 +1,136 @@ +# 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 +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") + + +@app.post("/run") +def run(): + auth = request.headers.get("Authorization", "") + if auth != f"Bearer {WEBHOOK_TOKEN}": + return jsonify({"ok": False, "error": "unauthorized"}), 401 + + prompt = (request.json or {}).get("prompt", "").strip() + if not prompt: + return jsonify({"ok": False, "error": "prompt is required"}), 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") + cmd = [ + "codex", + "exec", + prompt, + "--dangerously-bypass-approvals-and-sandbox", + "--output-last-message", + artifact_path, + ] + + result = subprocess.run(cmd, capture_output=True, text=True) + if result.returncode != 0: + return jsonify({ + "ok": False, + "error": (result.stderr or result.stdout or "codex failed")[:500], + "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": "", + "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" +} +``` + +## 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 00000000..8b137891 --- /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 00000000..4ce68213 --- /dev/null +++ b/community/codex-task-runner/main.py @@ -0,0 +1,263 @@ +"""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 json +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 + + @classmethod + def register_capability(cls) -> "MatchingCapability": + """Load ability metadata from config.json.""" + import os + + with open( + os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json") + ) as file: + data = json.load(file) + return cls( + unique_name=data["unique_name"], + matching_hotwords=data["matching_hotwords"], + ) + + 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 + and WEBHOOK_URL != "YOUR_WEBHOOK_URL_HERE" + and WEBHOOK_TOKEN + and WEBHOOK_TOKEN != "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 = 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 not isinstance(response_payload, dict): + self.worker.editor_logging_handler.error( + "[CodexTaskRunner] webhook response is not an object" + ) + return None + + return response_payload + + 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 + + # 2) 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 + + self.worker.editor_logging_handler.info( + "[CodexTaskRunner] user_request received " + f"preview='{self._preview(user_request)}'" + ) + + lowered = user_request.lower().strip() + if any(word in lowered 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 + + # 3) Explicit confirmation before external execution. + request_preview = self._preview(user_request) + 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 + + # 4) 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", "") + if not raw_summary: + raw_summary = "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', '')}" + ) + + spoken_summary = self._to_conversational_summary(raw_summary) + + await self.capability_worker.speak(spoken_summary) + + artifact_path = webhook_result.get("artifact_path") + if 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() From c4df3364bfcd86904e2b77a9810c1f1be0dac92b Mon Sep 17 00:00:00 2001 From: Juyoung Lee Date: Sun, 22 Feb 2026 21:36:21 -0800 Subject: [PATCH 02/14] docs: align webhook example with safe scoped Codex execution --- community/codex-task-runner/README.md | 49 +++++++++++++++++++++++---- 1 file changed, 42 insertions(+), 7 deletions(-) diff --git a/community/codex-task-runner/README.md b/community/codex-task-runner/README.md index ea694fc8..890db2c0 100644 --- a/community/codex-task-runner/README.md +++ b/community/codex-task-runner/README.md @@ -44,6 +44,14 @@ 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.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.abspath(path) + return target == DEFAULT_WORKDIR or target.startswith(DEFAULT_WORKDIR + os.sep) @app.post("/run") @@ -52,29 +60,56 @@ def run(): if auth != f"Bearer {WEBHOOK_TOKEN}": return jsonify({"ok": False, "error": "unauthorized"}), 401 - prompt = (request.json or {}).get("prompt", "").strip() + 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", - prompt, - "--dangerously-bypass-approvals-and-sandbox", - "--output-last-message", + "-C", + target_workdir, + "--json", + "-o", artifact_path, + "--full-auto", + "--sandbox", + CODEX_SANDBOX, + prompt, ] - result = subprocess.run(cmd, capture_output=True, text=True) + 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": (result.stderr or result.stdout or "codex failed")[:500], + "error": f"codex failed with exit code {result.returncode}", + "events_path": events_path, "request_id": request_id, }), 500 @@ -87,7 +122,7 @@ def run(): "ok": True, "summary": summary, "artifact_path": artifact_path, - "events_path": "", + "events_path": events_path, "request_id": request_id, }) ``` From b367543c895f2fa1ace222258329dd8c2f357a2c Mon Sep 17 00:00:00 2001 From: Juyoung Lee Date: Sun, 22 Feb 2026 21:39:14 -0800 Subject: [PATCH 03/14] fix: align Codex Task Runner with new validator rules Replace explicit register_capability boilerplate with the template register tag and remove raw open() usage so the ability matches updated validation requirements. --- community/codex-task-runner/main.py | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/community/codex-task-runner/main.py b/community/codex-task-runner/main.py index 4ce68213..ddc70fa0 100644 --- a/community/codex-task-runner/main.py +++ b/community/codex-task-runner/main.py @@ -11,7 +11,6 @@ - Server: any webhook server implementation exposing POST /run. """ -import json import requests from src.agent.capability import MatchingCapability from src.main import AgentWorker @@ -33,19 +32,7 @@ class CodexTaskRunnerCapability(MatchingCapability): worker: AgentWorker = None capability_worker: CapabilityWorker = None - @classmethod - def register_capability(cls) -> "MatchingCapability": - """Load ability metadata from config.json.""" - import os - - with open( - os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json") - ) as file: - data = json.load(file) - return cls( - unique_name=data["unique_name"], - matching_hotwords=data["matching_hotwords"], - ) + #{{register capability}} def call(self, worker: AgentWorker): """OpenHome SDK hook; starts async ability flow.""" From 9f4007ac43568472ac3f1f9753f06c8188b6a2e0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 23 Feb 2026 05:40:28 +0000 Subject: [PATCH 04/14] style: auto-format Python files with autoflake + autopep8 --- community/codex-task-runner/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/community/codex-task-runner/main.py b/community/codex-task-runner/main.py index ddc70fa0..f0d165b6 100644 --- a/community/codex-task-runner/main.py +++ b/community/codex-task-runner/main.py @@ -32,7 +32,7 @@ class CodexTaskRunnerCapability(MatchingCapability): worker: AgentWorker = None capability_worker: CapabilityWorker = None - #{{register capability}} + # {{register capability}} def call(self, worker: AgentWorker): """OpenHome SDK hook; starts async ability flow.""" From 067a59f29ec8b7b24c25d2d975b4890850bcee0d Mon Sep 17 00:00:00 2001 From: Juyoung Lee Date: Sun, 22 Feb 2026 21:41:55 -0800 Subject: [PATCH 05/14] fix: preserve register capability tag format --- community/codex-task-runner/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/community/codex-task-runner/main.py b/community/codex-task-runner/main.py index f0d165b6..ddc70fa0 100644 --- a/community/codex-task-runner/main.py +++ b/community/codex-task-runner/main.py @@ -32,7 +32,7 @@ class CodexTaskRunnerCapability(MatchingCapability): worker: AgentWorker = None capability_worker: CapabilityWorker = None - # {{register capability}} + #{{register capability}} def call(self, worker: AgentWorker): """OpenHome SDK hook; starts async ability flow.""" From fd1d78ef4c98ff95b895c15031aa54d417697fda Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 23 Feb 2026 05:42:17 +0000 Subject: [PATCH 06/14] style: auto-format Python files with autoflake + autopep8 --- community/codex-task-runner/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/community/codex-task-runner/main.py b/community/codex-task-runner/main.py index ddc70fa0..f0d165b6 100644 --- a/community/codex-task-runner/main.py +++ b/community/codex-task-runner/main.py @@ -32,7 +32,7 @@ class CodexTaskRunnerCapability(MatchingCapability): worker: AgentWorker = None capability_worker: CapabilityWorker = None - #{{register capability}} + # {{register capability}} def call(self, worker: AgentWorker): """OpenHome SDK hook; starts async ability flow.""" From a6125eb4ea99c25fe06d8287407a3564c887ac5d Mon Sep 17 00:00:00 2001 From: Juyoung Lee Date: Sun, 22 Feb 2026 22:02:28 -0800 Subject: [PATCH 07/14] fix: address PR feedback on async webhook and docs hardening --- community/codex-task-runner/README.md | 17 ++++++++++++++--- community/codex-task-runner/main.py | 6 ++++-- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/community/codex-task-runner/README.md b/community/codex-task-runner/README.md index 890db2c0..0fd55796 100644 --- a/community/codex-task-runner/README.md +++ b/community/codex-task-runner/README.md @@ -36,6 +36,8 @@ Use the exact same `WEBHOOK_TOKEN` value on both sides. ## 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 @@ -44,14 +46,20 @@ 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.abspath(os.environ.get("CODEX_WORKDIR", ".")) +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.abspath(path) - return target == DEFAULT_WORKDIR or target.startswith(DEFAULT_WORKDIR + os.sep) + 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") @@ -140,6 +148,9 @@ The ability expects JSON with this shape: } ``` +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`). diff --git a/community/codex-task-runner/main.py b/community/codex-task-runner/main.py index f0d165b6..b83b909e 100644 --- a/community/codex-task-runner/main.py +++ b/community/codex-task-runner/main.py @@ -11,6 +11,7 @@ - Server: any webhook server implementation exposing POST /run. """ +import asyncio import requests from src.agent.capability import MatchingCapability from src.main import AgentWorker @@ -99,7 +100,8 @@ async def _call_webhook(self, user_request: str) -> dict | None: ) try: - webhook_response = requests.post( + webhook_response = await asyncio.to_thread( + requests.post, WEBHOOK_URL, headers=headers, json={"prompt": user_request}, @@ -155,7 +157,7 @@ async def run(self): ) return - # 2) Gather user task and handle fast cancel path. + # 1) Gather user task and handle fast cancel path. await self.capability_worker.speak( "Tell me the coding task you want Codex to run." ) From 9a7dff3f6ba4cfa87a5d078b8a714f5d7c3852bc Mon Sep 17 00:00:00 2001 From: Juyoung Lee Date: Sun, 22 Feb 2026 22:07:17 -0800 Subject: [PATCH 08/14] fix: preserve register capability tag format --- community/codex-task-runner/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/community/codex-task-runner/main.py b/community/codex-task-runner/main.py index b83b909e..463b1e43 100644 --- a/community/codex-task-runner/main.py +++ b/community/codex-task-runner/main.py @@ -33,7 +33,7 @@ class CodexTaskRunnerCapability(MatchingCapability): worker: AgentWorker = None capability_worker: CapabilityWorker = None - # {{register capability}} + #{{register capability}} # noqa: E265 def call(self, worker: AgentWorker): """OpenHome SDK hook; starts async ability flow.""" From 32590abaa4b95b8cda461a8f840423b8dfaf59cd Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 23 Feb 2026 06:07:32 +0000 Subject: [PATCH 09/14] style: auto-format Python files with autoflake + autopep8 --- community/codex-task-runner/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/community/codex-task-runner/main.py b/community/codex-task-runner/main.py index 463b1e43..ea544489 100644 --- a/community/codex-task-runner/main.py +++ b/community/codex-task-runner/main.py @@ -33,7 +33,7 @@ class CodexTaskRunnerCapability(MatchingCapability): worker: AgentWorker = None capability_worker: CapabilityWorker = None - #{{register capability}} # noqa: E265 + # {{register capability}} # noqa: E265 def call(self, worker: AgentWorker): """OpenHome SDK hook; starts async ability flow.""" From 5146102bc71addeb1b3f354c91be2c5d5d662e04 Mon Sep 17 00:00:00 2001 From: Juyoung Lee Date: Sun, 22 Feb 2026 22:10:17 -0800 Subject: [PATCH 10/14] fix: keep validator register tag stable under auto-format --- community/codex-task-runner/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/community/codex-task-runner/main.py b/community/codex-task-runner/main.py index ea544489..4d53edfe 100644 --- a/community/codex-task-runner/main.py +++ b/community/codex-task-runner/main.py @@ -33,7 +33,7 @@ class CodexTaskRunnerCapability(MatchingCapability): worker: AgentWorker = None capability_worker: CapabilityWorker = None - # {{register capability}} # noqa: E265 + # register capability tag: #{{register capability}} def call(self, worker: AgentWorker): """OpenHome SDK hook; starts async ability flow.""" From 2796cfdaab256879ea0ea5f4a57952d1db8c8340 Mon Sep 17 00:00:00 2001 From: Juyoung Lee Date: Sun, 22 Feb 2026 22:32:39 -0800 Subject: [PATCH 11/14] refactor: simplify Codex Task Runner flow --- community/codex-task-runner/main.py | 42 ++++++++++++++--------------- 1 file changed, 20 insertions(+), 22 deletions(-) diff --git a/community/codex-task-runner/main.py b/community/codex-task-runner/main.py index 4d53edfe..dec6e34e 100644 --- a/community/codex-task-runner/main.py +++ b/community/codex-task-runner/main.py @@ -53,12 +53,10 @@ def _preview(self, text: str) -> str: def _is_configured(self) -> bool: """Return True when placeholders were replaced with real values.""" - return ( - WEBHOOK_URL - and WEBHOOK_URL != "YOUR_WEBHOOK_URL_HERE" - and WEBHOOK_TOKEN - and WEBHOOK_TOKEN != "YOUR_WEBHOOK_TOKEN_HERE" - ) + 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.""" @@ -132,13 +130,13 @@ async def _call_webhook(self, user_request: str) -> dict | None: ) return None - if not isinstance(response_payload, dict): - self.worker.editor_logging_handler.error( - "[CodexTaskRunner] webhook response is not an object" - ) - return None + if isinstance(response_payload, dict): + return response_payload - 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.""" @@ -172,21 +170,21 @@ async def run(self): ) return + request_preview = self._preview(user_request) self.worker.editor_logging_handler.info( "[CodexTaskRunner] user_request received " - f"preview='{self._preview(user_request)}'" + f"preview='{request_preview}'" ) lowered = user_request.lower().strip() - if any(word in lowered for word in EXIT_WORDS): + 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 - # 3) Explicit confirmation before external execution. - request_preview = self._preview(user_request) + # 2) Explicit confirmation before external execution. self.worker.editor_logging_handler.info( "[CodexTaskRunner] confirmation requested " f"preview='{request_preview}'" @@ -201,7 +199,7 @@ async def run(self): await self.capability_worker.speak("Okay, I won't run it.") return - # 4) Execute request via webhook. + # 3) Execute request via webhook. await self.capability_worker.speak( "Running Codex now. This may take up to a few minutes." ) @@ -217,9 +215,9 @@ async def run(self): ) return - raw_summary = webhook_result.get("summary", "") - if not raw_summary: - raw_summary = "Codex finished, but the webhook returned no summary text." + 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', '')} " @@ -227,12 +225,12 @@ async def run(self): 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) - artifact_path = webhook_result.get("artifact_path") - if artifact_path: + if webhook_result.get("artifact_path"): await self.capability_worker.speak( "I also saved the full output in the run artifacts." ) From de41251b1e448d58b0020b39a553432761831978 Mon Sep 17 00:00:00 2001 From: Juyoung Lee Date: Mon, 23 Feb 2026 15:53:25 -0800 Subject: [PATCH 12/14] =?UTF-8?q?rename=20codex-task-runner=20=E2=86=92=20?= =?UTF-8?q?coding-agent-runner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename folder, class, log tag, and voice prompts to be agent-agnostic - Add _refine_prompt to clean up STT transcription before sending to agent - README shows Claude Code and Codex equally with unified webhook example - End-to-end tested via OpenHome + ngrok + Claude Code webhook --- community/codex-task-runner/README.md | 182 ------------- community/codex-task-runner/main.py | 250 ------------------ community/coding-agent-runner/README.md | 91 +++++++ .../__init__.py | 0 community/coding-agent-runner/main.py | 157 +++++++++++ 5 files changed, 248 insertions(+), 432 deletions(-) delete mode 100644 community/codex-task-runner/README.md delete mode 100644 community/codex-task-runner/main.py create mode 100644 community/coding-agent-runner/README.md rename community/{codex-task-runner => coding-agent-runner}/__init__.py (100%) create mode 100644 community/coding-agent-runner/main.py diff --git a/community/codex-task-runner/README.md b/community/codex-task-runner/README.md deleted file mode 100644 index 0fd55796..00000000 --- a/community/codex-task-runner/README.md +++ /dev/null @@ -1,182 +0,0 @@ -# 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/main.py b/community/codex-task-runner/main.py deleted file mode 100644 index dec6e34e..00000000 --- a/community/codex-task-runner/main.py +++ /dev/null @@ -1,250 +0,0 @@ -"""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() diff --git a/community/coding-agent-runner/README.md b/community/coding-agent-runner/README.md new file mode 100644 index 00000000..6bc9e663 --- /dev/null +++ b/community/coding-agent-runner/README.md @@ -0,0 +1,91 @@ +# Coding Agent 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 invokes Claude Code or Codex headlessly, then reads back a short spoken result. + +## Trigger Words +- "run coding task" +- "run a coding agent" +- "execute coding task" + +## Setup +1. Run any webhook server that accepts `POST /run` with bearer auth (see example below). +2. In `main.py`, replace `WEBHOOK_URL` and `WEBHOOK_TOKEN` placeholders. Use the same token on both sides. +3. Upload this ability to OpenHome and set trigger words in the dashboard. + +If OpenHome can't reach your server directly, use a tunnel (e.g. `ngrok http 8080`). + +## Webhook Contract + +The ability sends: +``` +POST /run +Authorization: Bearer +{"prompt": "Add tests for the validator script"} +``` + +And expects back: +```json +{"ok": true, "summary": "Added tests and they pass."} +``` + +Optional response fields: `artifact_path`, `request_id`. + +## Minimal Webhook Server + +The webhook just needs to run Claude Code or Codex and return the output. Swap the command to match your agent. + +> **Safety note:** Both examples use autonomous execution flags. Only run in a +> sandboxed environment or a directory you're comfortable modifying. + +```python +# Runs on a separate server, not inside OpenHome. +import subprocess +from flask import Flask, jsonify, request + +app = Flask(__name__) +TOKEN = "your-secret-token" +AGENT = "claude" # "claude" or "codex" +WORKDIR = "/path/to/your/project" # sandbox / working directory + +def agent_cmd(prompt): + if AGENT == "codex": + return ["codex", "exec", "--full-auto", prompt] + return ["claude", "-p", prompt, "--allowedTools", "Bash,Read,Write,Edit"] + +@app.post("/run") +def run(): + if request.headers.get("Authorization") != f"Bearer {TOKEN}": + return jsonify(ok=False, error="unauthorized"), 401 + + prompt = (request.get_json(silent=True) or {}).get("prompt", "").strip() + if not prompt: + return jsonify(ok=False, error="prompt required"), 400 + + result = subprocess.run( + agent_cmd(prompt), + capture_output=True, text=True, timeout=600, check=False, + cwd=WORKDIR, + ) + if result.returncode != 0: + return jsonify(ok=False, error=f"exit code {result.returncode}"), 500 + + return jsonify(ok=True, summary=result.stdout.strip() or "Done.") +``` + +## Example Conversation +> **User:** "run coding task" +> **AI:** "Tell me the coding task you'd like to run." +> **User:** "Add basic tests for the validator script and run them." +> **AI:** "Got it. Want me to run that now?" +> **User:** "Yes" +> **AI:** "Tests were added and they all pass." + +## Logs +Look for `[CodingAgentRunner]` entries in OpenHome Live Editor logs. + +## Token Hygiene +For demos, static tokens are fine. After testing, rotate on both sides. diff --git a/community/codex-task-runner/__init__.py b/community/coding-agent-runner/__init__.py similarity index 100% rename from community/codex-task-runner/__init__.py rename to community/coding-agent-runner/__init__.py diff --git a/community/coding-agent-runner/main.py b/community/coding-agent-runner/main.py new file mode 100644 index 00000000..021bfc79 --- /dev/null +++ b/community/coding-agent-runner/main.py @@ -0,0 +1,157 @@ +"""OpenHome ability – voice-triggered coding task execution via webhook. + +Flow: ask → confirm → refine prompt → call webhook → speak result. +""" + +import asyncio + +import requests +from src.agent.capability import MatchingCapability +from src.main import AgentWorker +from src.agent.capability_worker import CapabilityWorker + +WEBHOOK_URL = "YOUR_WEBHOOK_URL_HERE" +WEBHOOK_TOKEN = "YOUR_WEBHOOK_TOKEN_HERE" +REQUEST_TIMEOUT_SECONDS = 180 +EXIT_WORDS = {"stop", "cancel", "exit", "quit", "never mind"} + +TAG = "[CodingAgentRunner]" + + +class CodingAgentRunnerCapability(MatchingCapability): + """Voice ability that sends coding tasks to an external webhook.""" + + worker: AgentWorker = None + capability_worker: CapabilityWorker = None + + #{{register capability}} + + def call(self, worker: AgentWorker): + self.worker = worker + self.capability_worker = CapabilityWorker(self.worker) + self.worker.session_tasks.create(self.run()) + + async def run(self): + try: + # 1) Guard: ensure webhook is configured. + if WEBHOOK_URL in ("", "YOUR_WEBHOOK_URL_HERE") \ + or WEBHOOK_TOKEN in ("", "YOUR_WEBHOOK_TOKEN_HERE"): + await self.capability_worker.speak( + "This coding agent runner is not configured yet. " + "Please set the webhook URL and token in the ability code." + ) + return + + # 2) Ask for the coding task. + await self.capability_worker.speak( + "Tell me the coding task you'd like to run." + ) + task = await self.capability_worker.user_response() + + if not task: + await self.capability_worker.speak( + "I didn't catch that. Please try again." + ) + return + + lowered = task.lower().strip() + if any(lowered == w or lowered.startswith(f"{w} ") for w in EXIT_WORDS): + await self.capability_worker.speak("Okay, canceled.") + return + + # 3) Confirm before running. + if not await self.capability_worker.run_confirmation_loop( + "Got it. Want me to run that now?" + ): + await self.capability_worker.speak("Okay, I won't run it.") + return + + # 4) Refine transcription → call the webhook. + prompt = self._refine_prompt(task) + await self.capability_worker.speak( + "Running your coding task now. This may take up to a few minutes." + ) + result = await self._call_webhook(prompt) + + if not result or not result.get("ok"): + await self.capability_worker.speak( + "I couldn't complete that coding task. " + "Check your webhook server logs." + ) + return + + # 5) Speak the result. + spoken = self._rewrite_for_voice( + result.get("summary") or "Task finished but returned no summary." + ) + await self.capability_worker.speak(spoken) + + if 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"{TAG} unexpected error: {err}" + ) + await self.capability_worker.speak( + "Something went wrong while running the coding task." + ) + finally: + self.capability_worker.resume_normal_flow() + + async def _call_webhook(self, prompt: str) -> dict | None: + """POST the task to the webhook; return parsed JSON or None.""" + try: + resp = await asyncio.to_thread( + requests.post, + WEBHOOK_URL, + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {WEBHOOK_TOKEN}", + }, + json={"prompt": prompt}, + timeout=REQUEST_TIMEOUT_SECONDS, + ) + resp.raise_for_status() + payload = resp.json() + if not isinstance(payload, dict): + raise ValueError("response is not a JSON object") + except Exception as err: + self.worker.editor_logging_handler.error( + f"{TAG} webhook failed: {err}" + ) + return None + return payload + + def _refine_prompt(self, raw: str) -> str: + """Use the LLM to clean up a voice transcription into a clear coding task.""" + try: + text = self.capability_worker.text_to_text_response( + "The following is a voice transcription of a coding task. " + "Clean it up into a clear, actionable prompt for a coding agent. " + "Fix transcription errors, remove filler words, and keep the intent. " + "Return only the refined prompt, nothing else.\n\n" + f"Transcription:\n{raw}", + self.worker.agent_memory.full_message_history, + ) + return (text or "").strip() or raw + except Exception: + return raw + + def _rewrite_for_voice(self, raw: str) -> str: + """Use the LLM to rewrite a raw summary into spoken-friendly text.""" + try: + text = self.capability_worker.text_to_text_response( + "Rewrite this coding result for spoken voice. " + "Use 1-2 short conversational sentences. " + "No list numbers, markdown, file paths, or code snippets. " + "Keep only the key outcome and one optional follow-up.\n\n" + f"Result:\n{raw}", + self.worker.agent_memory.full_message_history, + ) + cleaned = (text or "").replace("```", "").strip() + return cleaned or raw + except Exception: + return raw From 3d4d3785382f24a8811003efc9a8a9dea76df972 Mon Sep 17 00:00:00 2001 From: Juyoung Lee Date: Sun, 29 Mar 2026 16:09:39 -0700 Subject: [PATCH 13/14] feat: add private notes note-taking agent --- community/private-notes/README.md | 48 +++++ community/private-notes/SPEC.md | 145 +++++++++++++++ community/private-notes/__init__.py | 1 + community/private-notes/main.py | 268 ++++++++++++++++++++++++++++ 4 files changed, 462 insertions(+) create mode 100644 community/private-notes/README.md create mode 100644 community/private-notes/SPEC.md create mode 100644 community/private-notes/__init__.py create mode 100644 community/private-notes/main.py diff --git a/community/private-notes/README.md b/community/private-notes/README.md new file mode 100644 index 00000000..86bde400 --- /dev/null +++ b/community/private-notes/README.md @@ -0,0 +1,48 @@ +# Private Notes + +`Private Notes` is a voice-first note-taking agent for OpenHome. It stores notes in persistent `private_notes.json`, so note contents stay out of the Personality prompt and are only spoken when the user explicitly asks. + +## What It Does + +- saves a new note +- reads one or more notes +- overwrites a specific note after confirmation +- deletes one or more notes after confirmation + +The ability uses a single LLM tool loop with conversation history. Python owns all note reads and writes. + +## Example Phrases + +- `take a note` +- `note this down: call Sarah after lunch` +- `read my notes` +- `read my last note` +- `update my grocery note` +- `delete my last note` +- `delete my notes` + +## Storage + +- File: `private_notes.json` +- Persistence: `temp=False` +- JSON saves safely overwrite by deleting any existing file before writing because `write_file()` appends by default +- No `.md` files are written, so the Memory Watcher does not inject note contents into the Personality prompt + +## Voice UX + +- if no request is captured, the ability asks what the user wants to do +- reads are capped to the 3 most recent matches to avoid long voice dumps +- overwrite and delete actions always require confirmation +- final responses stay short, warm, and conversational + +## Suggested Trigger Words + +Configure these in the OpenHome dashboard: + +- `private note` +- `private notes` +- `take a note` +- `note this down` +- `write this down` +- `read my notes` +- `delete my notes` diff --git a/community/private-notes/SPEC.md b/community/private-notes/SPEC.md new file mode 100644 index 00000000..70100457 --- /dev/null +++ b/community/private-notes/SPEC.md @@ -0,0 +1,145 @@ +# Private Notes Spec + +## Goal + +`private-notes` is a voice-first personal note-taking agent for OpenHome. + +- save a note +- read one or more notes +- overwrite a specific note +- delete one or more notes + +--- + +## Core Principles + +1. Notes are private user data stored in JSON. +2. The LLM picks which tool to call. Python executes it. +3. Tool execution is id-based, not title-based. +4. Voice responses are short and natural. +5. No open-ended agent loop. Capped at 4 turns. + +--- + +## Architecture + +A uniform tool loop with one system prompt and conversation history: + +```text +history = [user: initial context] + +while turns remain: + tool_call = LLM(history, SYSTEM_PROMPT) + history += assistant: tool_call + + finish -> speak response, stop + ask_followup -> speak question, history += user: answer, continue + write/read/delete -> execute in Python, history += user: result, continue +``` + +One system prompt. One conversation via `history`. `finish` is a tool like any other. The LLM writes confirmation messages for destructive actions. + +--- + +## Data Model + +### Note + +```json +{ + "id": "uuid", + "title": "string", + "content": "string", + "created_at": "ISO timestamp", + "updated_at": "ISO timestamp" +} +``` + +### Store + +```json +{ + "schema_version": 2, + "notes": [Note] +} +``` + +Persistent storage lives in `private_notes.json`. + +--- + +## Tools + +### `write_note` + +```json +{"name": "write_note", "arguments": {"note_id": null, "title": "string", "content": "string", "confirmation": "string or null"}} +``` + +- `note_id = null` creates a new note (no confirmation needed). +- `note_id = ` overwrites an existing note. LLM provides the `confirmation` prompt. +- If `note_id` does not exist, Python returns an error result instead of crashing the ability. + +### `read_notes` + +```json +{"name": "read_notes", "arguments": {"note_ids": ["uuid"]}} +``` + +- Readback capped to 3 notes. +- Returns raw note data (title, content, updated_at). LLM formats for speech via `finish`. + +### `delete_notes` + +```json +{"name": "delete_notes", "arguments": {"note_ids": ["uuid"], "confirmation": "string"}} +``` + +- LLM provides the `confirmation` prompt. Always confirmed before deleting. + +### `ask_followup` + +```json +{"name": "ask_followup", "arguments": {"question": "string"}} +``` + +- Used when the request is ambiguous. + +### `finish` + +```json +{"name": "finish", "arguments": {"response": "string"}} +``` + +- Spoken response to the user. Ends the loop. + +--- + +## Context + +The first message in history contains: + +1. Current local time (captured once for caching) +2. User request +3. Minimal note index: id, title, updated_at for each note (sorted by recency) + +Subsequent turns append tool results and follow-up answers as history entries. The LLM resolves "my latest note" to the first id in the index. + +--- + +## Safety Rules + +1. Python executes all note mutations, not the LLM. +2. Overwrite requires confirmation (LLM writes the prompt). +3. Delete requires confirmation (LLM writes the prompt). +4. JSON saves safely overwrite by deleting any existing file before writing because `write_file()` appends to existing files. +5. The loop is capped at 4 turns. + +--- + +## Validation + +``` +python3 -m py_compile abilities/community/private-notes/main.py +python3 abilities/validate_ability.py abilities/community/private-notes +``` diff --git a/community/private-notes/__init__.py b/community/private-notes/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/community/private-notes/__init__.py @@ -0,0 +1 @@ + diff --git a/community/private-notes/main.py b/community/private-notes/main.py new file mode 100644 index 00000000..3954346b --- /dev/null +++ b/community/private-notes/main.py @@ -0,0 +1,268 @@ +import json +from datetime import datetime +from uuid import uuid4 + +from zoneinfo import ZoneInfo + +from src.agent.capability import MatchingCapability +from src.agent.capability_worker import CapabilityWorker +from src.main import AgentWorker + +NOTES_FILE = "private_notes.json" +MAX_READBACK = 3 +MAX_TURNS = 4 + +# LLM picks one tool per turn; loop ends when it calls "finish". +SYSTEM_PROMPT = """ +You are an OpenHome private notes ability. + +Return ONLY valid JSON with exactly this shape: +{ + "name": "write_note|read_notes|delete_notes|ask_followup|finish", + "arguments": {} +} + +Available tools: + +1) write_note — create or overwrite a note +{"name": "write_note", "arguments": {"note_id": null, "title": "string", "content": "string", "confirmation": "string or null"}} +note_id=null creates a new note (no confirmation needed). +note_id= overwrites — provide a short spoken confirmation question. + +2) read_notes — read notes by id +{"name": "read_notes", "arguments": {"note_ids": ["uuid"]}} + +3) delete_notes — delete notes by id +{"name": "delete_notes", "arguments": {"note_ids": ["uuid"], "confirmation": "string"}} +Always provide a short spoken confirmation question (e.g. "Delete your grocery list?"). + +4) ask_followup — ask the user for clarification +{"name": "ask_followup", "arguments": {"question": "string"}} + +5) finish — speak a final response to the user +{"name": "finish", "arguments": {"response": "string"}} + +Rules: +- The note index is sorted by updated_at descending. Latest note = first id. +- Never invent note ids. Resolve titles to ids from the note index. +- If ambiguous, use ask_followup. +- Create short, useful titles. Keep content faithful to the user's meaning. +- After a tool result is shown, call finish with a concise voice-friendly response. +- When reading notes aloud, say the title, a natural relative timestamp (e.g. "from today", "yesterday afternoon"), and the content. +- Keep responses short, warm, and conversational. Like talking to a friend. +""".strip() + + +class PrivateNotesCapability(MatchingCapability): + worker: AgentWorker = None + capability_worker: CapabilityWorker = None + + #{{register capability}} + + def call(self, worker: AgentWorker): + """Entry point. Framework calls this when the ability is triggered.""" + self.worker = worker + self.capability_worker = CapabilityWorker(self.worker) + self.worker.session_tasks.create(self.run()) + + async def run(self): + """ + Main flow: + 1. Get user request (from transcription or by asking) + 2. Load notes from file + 3. Run the tool loop until the LLM calls finish + """ + self.worker.editor_logging_handler.info("[PrivateNotes] Ability started") + try: + request_text = (await self.capability_worker.wait_for_complete_transcription() or "").strip() + if not request_text: + await self.capability_worker.speak( + "Private notes is open. What would you like to do?" + ) + request_text = await self._get_user_input( + "I didn't catch anything for private notes." + ) + if not request_text: + return + + notebook = await self._load_notebook() + + # Capture time once so the context prefix stays identical across turns (LLM caching). + now = datetime.now(ZoneInfo(self.capability_worker.get_timezone())) + + tool_handlers = { + "write_note": self._handle_write_note, + "read_notes": self._handle_read_notes, + "delete_notes": self._handle_delete_notes, + } + + history = [{"role": "user", "content": self._build_context(request_text, notebook, now)}] + + for _ in range(MAX_TURNS): + # text_to_text_response is sync — no await + llm_response = self.capability_worker.text_to_text_response( + history[-1]["content"], + history=history[:-1], + system_prompt=SYSTEM_PROMPT, + ) + # LLMs sometimes wrap JSON in markdown fences + tool_call = json.loads(llm_response.replace("```json", "").replace("```", "").strip()) + tool_name = tool_call.get("name", "") + tool_args = tool_call.get("arguments", {}) + self.worker.editor_logging_handler.info(f"[PrivateNotes] Tool={tool_name}") + + history.append({"role": "assistant", "content": llm_response}) + + if tool_name == "finish": + await self.capability_worker.speak(tool_args.get("response", "")) + return + + if tool_name == "ask_followup": + await self.capability_worker.speak(tool_args.get("question", "")) + followup = await self._get_user_input( + "I didn't catch anything, so I didn't change your notes." + ) + if not followup: + return + history.append({"role": "user", "content": followup}) + continue + + handler = tool_handlers.get(tool_name) + if not handler: + break # unknown tool — fall through to "couldn't complete" message + + result = await handler(notebook, tool_args, now) + + if result.get("notes_changed"): + await self._save_notebook(notebook) + + # Feed result back so the LLM can call finish with a spoken summary + history.append({"role": "user", "content": json.dumps(result, ensure_ascii=True)}) + + await self.capability_worker.speak( + "I couldn't complete that note request." + ) + except Exception as exc: + self.worker.editor_logging_handler.error(f"[PrivateNotes] Unexpected error: {exc}") + await self.capability_worker.speak( + "Something went wrong with your private notes." + ) + finally: + self.worker.editor_logging_handler.info("[PrivateNotes] Ability ended") + self.capability_worker.resume_normal_flow() + + # --- Context --- + + def _build_context(self, request_text: str, notebook: dict, now: datetime) -> str: + """Build the initial user message with time, request, and note index.""" + notes = sorted( + notebook["notes"], key=lambda n: n.get("updated_at", ""), reverse=True + ) + note_index = { + "note_count": len(notes), + "notes": [ + {"id": n.get("id"), "title": n.get("title"), "updated_at": n.get("updated_at")} + for n in notes + ], + } + return ( + f"Current local time: {now.isoformat()}\n" + f"User request: {request_text}\n" + f"Note index:\n{json.dumps(note_index, ensure_ascii=True)}" + ) + + # --- Tool handlers --- + + async def _handle_write_note(self, notebook: dict, args: dict, now: datetime) -> dict: + """Create a new note (note_id=null) or overwrite an existing one (with confirmation).""" + note_id = args.get("note_id") + title = args.get("title", "") + content = args.get("content", "") + timestamp = now.isoformat() + + if not note_id: + notebook["notes"].append({ + "id": str(uuid4()), + "title": title, + "content": content, + "created_at": timestamp, + "updated_at": timestamp, + }) + return {"ok": True, "notes_changed": True, "status": "created", "title": title} + + existing = next((n for n in notebook["notes"] if n.get("id") == note_id), None) + if not existing: + return {"ok": False, "notes_changed": False, "error": "note not found"} + + if not await self.capability_worker.run_confirmation_loop(args.get("confirmation", "")): + return {"ok": True, "notes_changed": False, "status": "cancelled"} + + existing["title"] = title + existing["content"] = content + existing["updated_at"] = timestamp + return {"ok": True, "notes_changed": True, "status": "updated", "title": title} + + async def _handle_read_notes(self, notebook: dict, args: dict, _now: datetime) -> dict: + """Return matched notes (capped at MAX_READBACK). LLM formats them for speech.""" + note_ids = set(args.get("note_ids", [])) + matched = sorted( + [n for n in notebook["notes"] if n.get("id") in note_ids], + key=lambda n: n.get("updated_at", ""), + reverse=True, + ) + capped = matched[:MAX_READBACK] + return { + "ok": True, + "notes_changed": False, + "total_matched": len(matched), + "total_returned": len(capped), + "total_remaining": max(len(matched) - len(capped), 0), + "notes": [ + {"title": n.get("title"), "content": n.get("content"), "updated_at": n.get("updated_at")} + for n in capped + ], + } + + async def _handle_delete_notes(self, notebook: dict, args: dict, _now: datetime) -> dict: + """Delete notes by id after user confirms.""" + ids_to_delete = set(args.get("note_ids", [])) + + if not await self.capability_worker.run_confirmation_loop(args.get("confirmation", "")): + return {"ok": True, "notes_changed": False, "deleted_count": 0, "status": "cancelled"} + + before = len(notebook["notes"]) + notebook["notes"] = [n for n in notebook["notes"] if n.get("id") not in ids_to_delete] + deleted_count = before - len(notebook["notes"]) + return {"ok": True, "notes_changed": deleted_count > 0, "deleted_count": deleted_count, "status": "deleted"} + + # --- Storage --- + + async def _load_notebook(self) -> dict: + """Load notes from JSON file, or return empty notebook if missing.""" + if not await self.capability_worker.check_if_file_exists(NOTES_FILE, False): + return {"schema_version": 2, "notes": []} + raw = await self.capability_worker.read_file(NOTES_FILE, False) + return json.loads(raw) + + async def _save_notebook(self, notebook: dict): + """Write notes to JSON file, sorted by most recently updated.""" + notebook["notes"] = sorted( + notebook["notes"], key=lambda n: n.get("updated_at", ""), reverse=True + ) + # write_file appends, so delete first to avoid corrupted JSON + if await self.capability_worker.check_if_file_exists(NOTES_FILE, False): + await self.capability_worker.delete_file(NOTES_FILE, False) + await self.capability_worker.write_file( + NOTES_FILE, json.dumps(notebook, ensure_ascii=True), False, + ) + + # --- Helpers --- + + async def _get_user_input(self, fallback_msg: str) -> str | None: + """Wait for transcription; speak fallback and return None if empty.""" + text = (await self.capability_worker.wait_for_complete_transcription() or "").strip() + if not text: + await self.capability_worker.speak(fallback_msg) + return text or None + + From b29d5574fd2a6144fc63677362556252ec7bc4a0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 29 Mar 2026 23:10:18 +0000 Subject: [PATCH 14/14] style: auto-format Python files with autoflake + autopep8 --- community/coding-agent-runner/main.py | 2 +- community/private-notes/main.py | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/community/coding-agent-runner/main.py b/community/coding-agent-runner/main.py index 021bfc79..a2bfc304 100644 --- a/community/coding-agent-runner/main.py +++ b/community/coding-agent-runner/main.py @@ -24,7 +24,7 @@ class CodingAgentRunnerCapability(MatchingCapability): worker: AgentWorker = None capability_worker: CapabilityWorker = None - #{{register capability}} + # {{register capability}} def call(self, worker: AgentWorker): self.worker = worker diff --git a/community/private-notes/main.py b/community/private-notes/main.py index 3954346b..0a7f600b 100644 --- a/community/private-notes/main.py +++ b/community/private-notes/main.py @@ -57,7 +57,7 @@ class PrivateNotesCapability(MatchingCapability): worker: AgentWorker = None capability_worker: CapabilityWorker = None - #{{register capability}} + # {{register capability}} def call(self, worker: AgentWorker): """Entry point. Framework calls this when the ability is triggered.""" @@ -264,5 +264,3 @@ async def _get_user_input(self, fallback_msg: str) -> str | None: if not text: await self.capability_worker.speak(fallback_msg) return text or None - -