diff --git a/rebuild_v1/.env.example b/rebuild_v1/.env.example new file mode 100644 index 00000000..38354282 --- /dev/null +++ b/rebuild_v1/.env.example @@ -0,0 +1,2 @@ +# Optional: set your OpenRouter API key for Phase B +OPENROUTER_API_KEY= diff --git a/rebuild_v1/.gitignore b/rebuild_v1/.gitignore new file mode 100644 index 00000000..404642ea --- /dev/null +++ b/rebuild_v1/.gitignore @@ -0,0 +1,5 @@ +config.json +.env +.venv +__pycache__/ +*.pyc diff --git a/rebuild_v1/README.md b/rebuild_v1/README.md new file mode 100644 index 00000000..fbc3c200 --- /dev/null +++ b/rebuild_v1/README.md @@ -0,0 +1,48 @@ +# Self-Operating Computer v1 (clean rebuild) + +A minimal Windows-friendly rewrite that runs a decide-and-act loop with a local Ollama model (no API keys required). Phase B wiring for OpenRouter is also in place. + +## Features +- Tkinter GUI with Tasks, Settings, and Logs tabs +- Dry Run safety (on by default) and STOP hotkey/button +- Loop: screenshot β†’ LLM JSON action β†’ validate β†’ (optionally) execute via `pyautogui` +- Config persisted to `rebuild_v1/config.json` + +## Requirements +- Python 3.11 on Windows +- [Ollama](https://ollama.com) running locally (default host `http://localhost:11434`) +- Optional: OpenRouter API key for Phase B + +Install Python packages: + +```bash +python -m venv .venv +.venv\\Scripts\\activate +pip install -r rebuild_v1/requirements.txt +``` + +## Running the app +```bash +python -m rebuild_v1.main +``` +If Tkinter fails to start in a headless environment, run on a local desktop session. + +## Using Ollama (Phase A) +1. Start Ollama: `ollama serve` +2. Pull the default model once: `ollama pull llama3.2:3b` +3. Ensure the host and model fields in **Settings** match your setup. + +The app checks connectivity to Ollama and shows a friendly message if the server or model is unavailable. + +## Safety controls +- **Dry Run**: enabled by default; shows actions without executing. +- **STOP hotkey**: `Ctrl+Alt+S` (configurable) registered via the `keyboard` library. +- **STOP button**: halts the current loop. +- **Max steps** and **delay** configurable in Settings. + +## OpenRouter (Phase B) +A provider dropdown exists; if "OpenRouter (API)" is chosen, set your API key, model, and base URL in Settings. Requests use the OpenAI-compatible SDK. + +## Notes +- Screenshots use Pillow's `ImageGrab`; ensure a visible desktop session. +- Avoid sharing `config.json`β€”it is gitignored and may contain sensitive keys. diff --git a/rebuild_v1/automation.py b/rebuild_v1/automation.py new file mode 100644 index 00000000..c9a5aaa3 --- /dev/null +++ b/rebuild_v1/automation.py @@ -0,0 +1,133 @@ +import threading +import time +from dataclasses import dataclass +from typing import Callable, Dict, Optional + +import keyboard +import pyautogui +from PIL import ImageGrab + +pyautogui.FAILSAFE = False + + +@dataclass +class ActionResult: + action: Dict[str, object] + executed: bool + error: Optional[str] = None + + +class AutomationEngine: + def __init__(self, dry_run: bool = True, stop_hotkey: str = "ctrl+alt+s"): + self.dry_run = dry_run + self.stop_hotkey = stop_hotkey + self._stop_flag = threading.Event() + self._hotkey_registered = False + + def register_stop_hotkey(self) -> None: + if self._hotkey_registered: + return + try: + keyboard.add_hotkey(self.stop_hotkey, self.stop) + self._hotkey_registered = True + except keyboard.KeyboardException: + # Keyboard may need elevated privileges; ignore if unavailable + pass + + def stop(self) -> None: + self._stop_flag.set() + + def reset_stop(self) -> None: + self._stop_flag.clear() + + def should_stop(self) -> bool: + return self._stop_flag.is_set() + + def capture_screenshot(self, save_path: Optional[str] = None): + image = ImageGrab.grab() + if save_path: + image.save(save_path) + return image + + def execute_action(self, action: Dict[str, object]) -> ActionResult: + if self.dry_run: + return ActionResult(action=action, executed=False, error=None) + + try: + action_type = action.get("type") + if action_type == "click": + pyautogui.click(x=int(action["x"]), y=int(action["y"])) + elif action_type == "type": + pyautogui.typewrite(str(action["text"])) + elif action_type == "hotkey": + keys = [str(k) for k in action.get("keys", [])] + pyautogui.hotkey(*keys) + elif action_type == "wait": + time.sleep(float(action.get("seconds", 0))) + elif action_type == "done": + # No-op + pass + else: + return ActionResult(action=action, executed=False, error="Unknown action type") + return ActionResult(action=action, executed=True, error=None) + except Exception as exc: # pylint: disable=broad-except + return ActionResult(action=action, executed=False, error=str(exc)) + + +class ActionLooper: + def __init__( + self, + automation: AutomationEngine, + request_action: Callable[[str, str], str], + parse_action: Callable[[str], Optional[Dict[str, object]]], + max_steps: int, + delay_seconds: float, + log_callback: Callable[[str], None], + ): + self.automation = automation + self.request_action = request_action + self.parse_action = parse_action + self.max_steps = max_steps + self.delay_seconds = delay_seconds + self.log_callback = log_callback + + def run(self, objective: str) -> str: + self.automation.reset_stop() + self.automation.register_stop_hotkey() + last_note = "Screenshot captured" + for step in range(1, self.max_steps + 1): + if self.automation.should_stop(): + return "Stopped by user" + try: + self.automation.capture_screenshot() + except Exception as exc: # pylint: disable=broad-except + self.log_callback(f"Screenshot failed: {exc}") + last_note = "screenshot failed" + else: + last_note = "screenshot taken" + + retries = 0 + action_data = None + raw = "" + while retries < 3 and action_data is None: + raw = self.request_action(objective, last_note) + self.log_callback(f"Raw model response: {raw}") + action_data = self.parse_action(raw) + if action_data is None: + retries += 1 + self.log_callback("Model returned invalid JSON. Retrying...") + if action_data is None: + return "Failed to parse action after retries" + + result = self.automation.execute_action(action_data) + executed_text = "executed" if result.executed else "dry-run" + self.log_callback(f"Action step {step}: {action_data} ({executed_text})") + if result.error: + self.log_callback(f"Action error: {result.error}") + if action_data.get("type") == "done": + return str(action_data.get("reason", "Done")) + + if self.automation.should_stop(): + return "Stopped by user" + time.sleep(self.delay_seconds) + return "Reached max steps" diff --git a/rebuild_v1/config.py b/rebuild_v1/config.py new file mode 100644 index 00000000..5fc071a7 --- /dev/null +++ b/rebuild_v1/config.py @@ -0,0 +1,39 @@ +import json +from pathlib import Path +from typing import Any, Dict + +CONFIG_PATH = Path(__file__).parent / "config.json" + +DEFAULT_CONFIG: Dict[str, Any] = { + "provider": "Ollama (Local)", + "ollama_host": "http://localhost:11434", + "ollama_model": "llama3.2:3b", + "openrouter_api_key": "", + "openrouter_model": "openrouter/auto", + "openrouter_base_url": "https://openrouter.ai/api/v1", + "max_steps": 10, + "delay_seconds": 0.6, + "stop_hotkey": "ctrl+alt+s", + "dry_run": True, +} + + +def load_config() -> Dict[str, Any]: + if CONFIG_PATH.exists(): + try: + with CONFIG_PATH.open("r", encoding="utf-8") as f: + data = json.load(f) + merged = {**DEFAULT_CONFIG, **data} + return merged + except (json.JSONDecodeError, OSError): + return DEFAULT_CONFIG.copy() + return DEFAULT_CONFIG.copy() + + +def save_config(config: Dict[str, Any]) -> None: + try: + with CONFIG_PATH.open("w", encoding="utf-8") as f: + json.dump(config, f, indent=2) + except OSError: + # Prefer silent failure over crashing the UI when filesystem is read-only + pass diff --git a/rebuild_v1/engine_llm.py b/rebuild_v1/engine_llm.py new file mode 100644 index 00000000..46092b57 --- /dev/null +++ b/rebuild_v1/engine_llm.py @@ -0,0 +1,151 @@ +import json +from typing import Dict, List, Optional + +import requests +from openai import OpenAI + + +class LLMError(Exception): + """Raised when the language model call fails.""" + + +class LLMEngine: + def __init__(self, config: Dict[str, object]): + self.config = config + + def _ollama_chat(self, messages: List[Dict[str, str]]) -> str: + host = str(self.config.get("ollama_host") or "http://localhost:11434") + model = str(self.config.get("ollama_model") or "llama3.2:3b") + try: + health = requests.get(f"{host}/api/tags", timeout=3) + except requests.RequestException as exc: + raise LLMError( + "Could not reach Ollama. Please ensure it is running on this machine." + ) from exc + + if health.status_code != 200: + raise LLMError( + "Ollama responded unexpectedly. Please restart Ollama and try again." + ) + + payload = { + "model": model, + "messages": messages, + "stream": False, + } + try: + response = requests.post( + f"{host}/api/chat", json=payload, timeout=30, stream=False + ) + except requests.RequestException as exc: + raise LLMError( + "Failed to call Ollama. Is the service running on the configured host?" + ) from exc + + if response.status_code == 404: + raise LLMError( + "Model not found. Please install it with: ollama pull llama3.2:3b" + ) + if response.status_code >= 500: + raise LLMError("Ollama server error. Please try again after a moment.") + if response.status_code >= 400: + raise LLMError( + "Ollama rejected the request. If the model is missing, run: ollama pull llama3.2:3b" + ) + + data = response.json() + message = data.get("message", {}) + content = message.get("content") + if not content: + raise LLMError("Ollama returned an empty response.") + return content + + def _openrouter_chat(self, messages: List[Dict[str, str]]) -> str: + api_key = str(self.config.get("openrouter_api_key") or "") + model = str(self.config.get("openrouter_model") or "openrouter/auto") + base_url = str(self.config.get("openrouter_base_url") or "https://openrouter.ai/api/v1") + + if not api_key: + raise LLMError("OpenRouter API key is missing in settings.") + + client = OpenAI(api_key=api_key, base_url=base_url) + try: + chat = client.chat.completions.create( + model=model, + messages=messages, + stream=False, + ) + except Exception as exc: # pylint: disable=broad-except + raise LLMError("OpenRouter call failed. Check network and API key.") from exc + + try: + content = chat.choices[0].message.content + except (AttributeError, IndexError): + content = None + if not content: + raise LLMError("OpenRouter returned an empty response.") + return content + + def chat(self, messages: List[Dict[str, str]]) -> str: + provider = self.config.get("provider", "Ollama (Local)") + if provider == "Ollama (Local)": + return self._ollama_chat(messages) + if provider == "OpenRouter (API)": + return self._openrouter_chat(messages) + raise LLMError("Unsupported provider selected.") + + def request_action(self, objective: str, screenshot_note: str) -> str: + prompt = ( + "You are controlling a computer. Decide the SINGLE next action as strict JSON only. " + "Use one of: click, type, hotkey, wait, done. Respond with JSON only." + ) + instructions = ( + "Schema: {\"type\":\"click\",\"x\":int,\"y\":int} | " + "{\"type\":\"type\",\"text\":str} | " + "{\"type\":\"hotkey\",\"keys\":[str,...]} | " + "{\"type\":\"wait\",\"seconds\":float} | " + "{\"type\":\"done\",\"reason\":str}. " + "Only one action. No explanation." + ) + user_message = ( + f"Objective: {objective}\n" + f"Latest screenshot: {screenshot_note}\n" + "Output JSON only." + ) + messages = [ + {"role": "system", "content": prompt}, + {"role": "user", "content": instructions}, + {"role": "user", "content": user_message}, + ] + return self.chat(messages) + + +def parse_action_text(text: str) -> Optional[Dict[str, object]]: + try: + data = json.loads(text) + except json.JSONDecodeError: + return None + if not isinstance(data, dict): + return None + action_type = data.get("type") + if action_type not in {"click", "type", "hotkey", "wait", "done"}: + return None + if action_type == "click": + if isinstance(data.get("x"), int) and isinstance(data.get("y"), int): + return {"type": "click", "x": data["x"], "y": data["y"]} + elif action_type == "type": + if isinstance(data.get("text"), str): + return {"type": "type", "text": data["text"]} + elif action_type == "hotkey": + keys = data.get("keys") + if isinstance(keys, list) and all(isinstance(k, str) for k in keys): + return {"type": "hotkey", "keys": keys} + elif action_type == "wait": + seconds = data.get("seconds") + if isinstance(seconds, (int, float)): + return {"type": "wait", "seconds": float(seconds)} + elif action_type == "done": + reason = data.get("reason") + if isinstance(reason, str): + return {"type": "done", "reason": reason} + return None diff --git a/rebuild_v1/gui.py b/rebuild_v1/gui.py new file mode 100644 index 00000000..e3bcca15 --- /dev/null +++ b/rebuild_v1/gui.py @@ -0,0 +1,214 @@ +import threading +import tkinter as tk +from tkinter import ttk +from typing import Callable, Dict, List + +from config import load_config, save_config + + +class AppGUI: + def __init__( + self, + root: tk.Tk, + run_callback: Callable[[List[str]], None], + stop_callback: Callable[[], None], + log_export: Callable[[Callable[[str], None]], None], + ): + self.root = root + self.run_callback = run_callback + self.stop_callback = stop_callback + self.log_export = log_export + self.config = load_config() + self.tasks: List[str] = [] + + root.title("Self-Operating Computer v1") + root.geometry("760x520") + + notebook = ttk.Notebook(root) + notebook.pack(fill=tk.BOTH, expand=True) + + self.log_text = tk.Text(root, wrap="word", height=10, state=tk.DISABLED) + + self._build_tasks_tab(notebook) + self._build_settings_tab(notebook) + self._build_logs_tab(notebook) + + self.log_export(self.log) + + def _build_tasks_tab(self, notebook: ttk.Notebook) -> None: + frame = ttk.Frame(notebook) + notebook.add(frame, text="Tasks") + + ttk.Label(frame, text="Objective").pack(anchor=tk.W, padx=8, pady=(8, 2)) + self.objective_input = tk.Text(frame, height=4) + self.objective_input.pack(fill=tk.X, padx=8) + + button_frame = ttk.Frame(frame) + button_frame.pack(fill=tk.X, padx=8, pady=6) + + ttk.Button(button_frame, text="Add Task", command=self.add_task).pack( + side=tk.LEFT, padx=4 + ) + ttk.Button(button_frame, text="Run", command=self.run_tasks).pack( + side=tk.LEFT, padx=4 + ) + ttk.Button(button_frame, text="Stop", command=self.stop_callback).pack( + side=tk.LEFT, padx=4 + ) + ttk.Button(button_frame, text="Clear", command=self.clear_tasks).pack( + side=tk.LEFT, padx=4 + ) + + self.dry_run_var = tk.BooleanVar(value=bool(self.config.get("dry_run", True))) + dry_run_check = ttk.Checkbutton( + frame, + text="Dry Run (do not execute actions)", + variable=self.dry_run_var, + command=self._persist_dry_run, + ) + dry_run_check.pack(anchor=tk.W, padx=8, pady=4) + + ttk.Label(frame, text="Task Queue").pack(anchor=tk.W, padx=8, pady=(10, 2)) + self.tasks_list = tk.Listbox(frame, height=8) + self.tasks_list.pack(fill=tk.BOTH, expand=True, padx=8, pady=(0, 8)) + + def _build_settings_tab(self, notebook: ttk.Notebook) -> None: + frame = ttk.Frame(notebook) + notebook.add(frame, text="Settings") + + provider_frame = ttk.Frame(frame) + provider_frame.pack(fill=tk.X, padx=8, pady=6) + ttk.Label(provider_frame, text="Provider").pack(anchor=tk.W) + self.provider_var = tk.StringVar(value=str(self.config.get("provider", "Ollama (Local)"))) + provider_menu = ttk.Combobox( + provider_frame, + textvariable=self.provider_var, + values=["Ollama (Local)", "OpenRouter (API)"], + state="readonly", + ) + provider_menu.pack(fill=tk.X, pady=2) + provider_menu.bind("<>", lambda _event: self._toggle_provider_fields()) + + self.ollama_host_var = tk.StringVar(value=str(self.config.get("ollama_host"))) + self.ollama_model_var = tk.StringVar(value=str(self.config.get("ollama_model"))) + self.openrouter_key_var = tk.StringVar(value=str(self.config.get("openrouter_api_key"))) + self.openrouter_model_var = tk.StringVar(value=str(self.config.get("openrouter_model"))) + self.openrouter_base_var = tk.StringVar(value=str(self.config.get("openrouter_base_url"))) + + self.provider_container = ttk.Frame(frame) + self.provider_container.pack(fill=tk.X, padx=8, pady=4) + self._build_provider_fields() + + extras = ttk.Frame(frame) + extras.pack(fill=tk.X, padx=8, pady=6) + + ttk.Label(extras, text="Max steps").pack(anchor=tk.W) + self.max_steps_var = tk.IntVar(value=int(self.config.get("max_steps", 10))) + ttk.Entry(extras, textvariable=self.max_steps_var).pack(fill=tk.X, pady=2) + + ttk.Label(extras, text="Delay between actions (seconds)").pack(anchor=tk.W, pady=(8, 0)) + self.delay_var = tk.DoubleVar(value=float(self.config.get("delay_seconds", 0.6))) + ttk.Entry(extras, textvariable=self.delay_var).pack(fill=tk.X, pady=2) + + ttk.Label(extras, text="Stop hotkey (e.g., ctrl+alt+s)").pack(anchor=tk.W, pady=(8, 0)) + self.stop_hotkey_var = tk.StringVar(value=str(self.config.get("stop_hotkey", "ctrl+alt+s"))) + ttk.Entry(extras, textvariable=self.stop_hotkey_var).pack(fill=tk.X, pady=2) + + ttk.Button(frame, text="Save Settings", command=self.save_settings).pack( + padx=8, pady=10, anchor=tk.E + ) + + def _build_provider_fields(self) -> None: + for child in list(self.provider_container.winfo_children()): + child.destroy() + + provider = self.provider_var.get() + if provider == "Ollama (Local)": + ttk.Label(self.provider_container, text="Ollama Host").pack(anchor=tk.W) + ttk.Entry(self.provider_container, textvariable=self.ollama_host_var).pack( + fill=tk.X, pady=2 + ) + ttk.Label(self.provider_container, text="Ollama Model").pack(anchor=tk.W, pady=(6, 0)) + ttk.Entry(self.provider_container, textvariable=self.ollama_model_var).pack( + fill=tk.X, pady=2 + ) + else: + ttk.Label(self.provider_container, text="OpenRouter API Key").pack(anchor=tk.W) + ttk.Entry(self.provider_container, textvariable=self.openrouter_key_var, show="*").pack( + fill=tk.X, pady=2 + ) + ttk.Label(self.provider_container, text="OpenRouter Model").pack(anchor=tk.W, pady=(6, 0)) + ttk.Entry(self.provider_container, textvariable=self.openrouter_model_var).pack( + fill=tk.X, pady=2 + ) + ttk.Label(self.provider_container, text="OpenRouter Base URL").pack(anchor=tk.W, pady=(6, 0)) + ttk.Entry(self.provider_container, textvariable=self.openrouter_base_var).pack( + fill=tk.X, pady=2 + ) + + def _build_logs_tab(self, notebook: ttk.Notebook) -> None: + frame = ttk.Frame(notebook) + notebook.add(frame, text="Logs") + scrollbar = ttk.Scrollbar(frame) + scrollbar.pack(side=tk.RIGHT, fill=tk.Y) + self.log_text = tk.Text(frame, wrap="word", state=tk.DISABLED) + self.log_text.pack(fill=tk.BOTH, expand=True) + self.log_text.config(yscrollcommand=scrollbar.set) + scrollbar.config(command=self.log_text.yview) + + def add_task(self) -> None: + text = self.objective_input.get("1.0", tk.END).strip() + if text: + self.tasks.append(text) + self.tasks_list.insert(tk.END, text) + self.objective_input.delete("1.0", tk.END) + + def clear_tasks(self) -> None: + self.tasks.clear() + self.tasks_list.delete(0, tk.END) + + def run_tasks(self) -> None: + if not self.tasks: + text = self.objective_input.get("1.0", tk.END).strip() + if text: + self.tasks.append(text) + self.tasks_list.insert(tk.END, text) + if not self.tasks: + self.log("No tasks to run.") + return + threading.Thread(target=self.run_callback, args=(self.tasks.copy(),), daemon=True).start() + + def _persist_dry_run(self) -> None: + self.config["dry_run"] = self.dry_run_var.get() + save_config(self.config) + + def save_settings(self) -> None: + self.config.update( + { + "provider": self.provider_var.get(), + "ollama_host": self.ollama_host_var.get(), + "ollama_model": self.ollama_model_var.get(), + "openrouter_api_key": self.openrouter_key_var.get(), + "openrouter_model": self.openrouter_model_var.get(), + "openrouter_base_url": self.openrouter_base_var.get(), + "max_steps": self.max_steps_var.get(), + "delay_seconds": self.delay_var.get(), + "stop_hotkey": self.stop_hotkey_var.get(), + "dry_run": self.dry_run_var.get(), + } + ) + save_config(self.config) + self.log("Settings saved.") + self._build_provider_fields() + + def _toggle_provider_fields(self) -> None: + self.save_settings() + self._build_provider_fields() + + def log(self, message: str) -> None: + self.log_text.configure(state=tk.NORMAL) + self.log_text.insert(tk.END, message + "\n") + self.log_text.see(tk.END) + self.log_text.configure(state=tk.DISABLED) + + diff --git a/rebuild_v1/main.py b/rebuild_v1/main.py new file mode 100644 index 00000000..36a0821b --- /dev/null +++ b/rebuild_v1/main.py @@ -0,0 +1,67 @@ +import tkinter as tk +from typing import Callable, List + +from automation import ActionLooper, AutomationEngine +from config import load_config +from engine_llm import LLMEngine, LLMError, parse_action_text +from gui import AppGUI + + +class AppController: + def __init__(self, root: tk.Tk): + self.root = root + self.logger: Callable[[str], None] = lambda msg: None + self.config = load_config() + self.automation = AutomationEngine( + dry_run=bool(self.config.get("dry_run", True)), + stop_hotkey=str(self.config.get("stop_hotkey", "ctrl+alt+s")), + ) + self.gui = AppGUI(root, self.run_tasks, self.stop, self.export_logger) + + def export_logger(self, logger: Callable[[str], None]) -> None: + self.logger = logger + + def log(self, message: str) -> None: + self.logger(message) + + def stop(self) -> None: + self.automation.stop() + self.log("Stop signal sent.") + + def run_tasks(self, tasks: List[str]) -> None: + for index, objective in enumerate(tasks, start=1): + self.config = load_config() + self.automation.dry_run = bool(self.config.get("dry_run", True)) + self.automation.stop_hotkey = str(self.config.get("stop_hotkey", "ctrl+alt+s")) + llm = LLMEngine(self.config) + looper = ActionLooper( + automation=self.automation, + request_action=llm.request_action, + parse_action=parse_action_text, + max_steps=int(self.config.get("max_steps", 10)), + delay_seconds=float(self.config.get("delay_seconds", 0.6)), + log_callback=self.log, + ) + self.log(f"Running task {index}/{len(tasks)}: {objective}") + try: + outcome = looper.run(objective) + except LLMError as exc: + self.log(f"LLM error: {exc}") + break + except Exception as exc: # pylint: disable=broad-except + self.log(f"Unexpected error: {exc}") + break + self.log(f"Task result: {outcome}") + if self.automation.should_stop(): + self.log("Stopped before finishing all tasks.") + break + + +def main() -> None: + root = tk.Tk() + AppController(root) + root.mainloop() + + +if __name__ == "__main__": + main() diff --git a/rebuild_v1/requirements.txt b/rebuild_v1/requirements.txt new file mode 100644 index 00000000..2aa9e07e --- /dev/null +++ b/rebuild_v1/requirements.txt @@ -0,0 +1,6 @@ +openai +requests +python-dotenv +pillow +pyautogui +keyboard diff --git a/rebuild_v1/test_parse.py b/rebuild_v1/test_parse.py new file mode 100644 index 00000000..6843df91 --- /dev/null +++ b/rebuild_v1/test_parse.py @@ -0,0 +1,23 @@ +"""Minimal parser validation for JSON cleaning.""" +from engine_llm import parse_action_text + + +def run_case(name: str, text: str) -> None: + result = parse_action_text(text) + status = "PASS" if result is not None else "FAIL" + print(f"{name}: {status} -> {result}") + + +def main() -> None: + wrapped = """ +```json +{"type": "wait", "seconds": 1} +``` +""" + noisy = "Some intro {\n \"type\": \"done\", \"reason\": \"ok\"\n}\n trailing" + run_case("Wrapped JSON", wrapped) + run_case("Noisy text", noisy) + + +if __name__ == "__main__": + main()