From e5256a2384c76a132ee205bb9a410a2f7f09bb85 Mon Sep 17 00:00:00 2001 From: scawful Date: Sun, 5 Oct 2025 13:48:38 -0400 Subject: [PATCH] feat: Update README and Add Build Cleaner Script - Updated the README to reflect the latest version and last updated date, enhancing documentation clarity. - Introduced a new build_cleaner.py script to automate source list maintenance and self-header includes for YAZE, improving project organization and build efficiency. - Added detailed instructions for hybrid CLI and GUI workflows, enhancing user guidance for utilizing the z3ed toolset. --- .gitignore | 1 + docs/z3ed/README.md | 52 +++++- scripts/build_cleaner.py | 362 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 409 insertions(+), 6 deletions(-) create mode 100644 scripts/build_cleaner.py diff --git a/.gitignore b/.gitignore index aaed4c75..2395334b 100644 --- a/.gitignore +++ b/.gitignore @@ -90,3 +90,4 @@ assets/etc/ imgui.ini .gitignore recent_files.txt +.vs/* \ No newline at end of file diff --git a/docs/z3ed/README.md b/docs/z3ed/README.md index 3ec1d94e..4f8fcddd 100644 --- a/docs/z3ed/README.md +++ b/docs/z3ed/README.md @@ -1,7 +1,7 @@ # z3ed: AI-Powered CLI for YAZE **Version**: 0.1.0-alpha -**Last Updated**: October 4, 2025 +**Last Updated**: October 5, 2025 ## 1. Overview @@ -76,6 +76,15 @@ z3ed agent diff --proposal-id z3ed agent accept --proposal-id ``` +### Hybrid CLI ↔ GUI Workflow + +1. **Build once for both surfaces**: `cmake -B build -DZ3ED_AI=ON -DYAZE_WITH_GRPC=ON` followed by `cmake --build build --target z3ed` ensures the CLI, editor chat widget, and ImGui test harness share the same AI and gRPC feature set. +2. **Plan in the CLI**: Use `z3ed agent plan --prompt "Describe the overworld tile 10,10" --rom zelda3.sfc --sandbox` to preview the command sequence the agent intends to execute against an isolated ROM copy. +3. **Execute and validate**: Run `z3ed agent run ... --sandbox` to apply the plan, then launch YAZE with the same ROM and open **Debug → Agent Chat** to review proposal details, streamed logs, and harness status without leaving the editor. +4. **Hand off to GUI automation**: From the Agent Chat widget, trigger the same plan or replay the last CLI run by selecting **Replay Last Plan** (uses the shared proposal registry) to watch the ImGui harness drive the UI. +5. **Tighten the loop**: While the harness executes, use `z3ed agent diff --proposal-id ` in the terminal and the Proposal Drawer inside YAZE to compare results side-by-side. Accept or reject directly in either surface—state stays in sync. +6. **Iterate rapidly**: When adjustments are needed, refine the prompt or modify the generated test script, rerun from the CLI, and immediately observe outcomes in the editor via the gRPC-backed harness telemetry panel. + ## 3. Architecture The z3ed system is composed of several layers, from the high-level AI agent down to the YAZE GUI and test harness. @@ -105,6 +114,7 @@ The z3ed system is composed of several layers, from the high-level AI agent down │ ImGuiTestHarness (gRPC Server in YAZE) │ │ ├─ Ping, Click, Type, Wait, Assert, Screenshot │ │ └─ Introspection & Discovery RPCs │ +│ └─ Automation API shared by CLI & Agent Chat │ └────────────────────┬────────────────────────────────────┘ │ ┌────────────────────▼────────────────────────────────────┐ @@ -151,6 +161,14 @@ The `z3ed` CLI is the foundation for an AI-driven Model-Code-Program (MCP) loop, - `overworld get-tile|find-tile|set-tile`: Commands for overworld editing. - `dungeon list-sprites|list-rooms`: Commands for dungeon inspection. +#### `agent test`: Live Harness Automation + +- **Discover widgets**: `z3ed agent test discover --rom zelda3.sfc --grpc localhost:50051` enumerates ImGui widget IDs through the gRPC-backed harness for later scripting. +- **Record interactions**: `z3ed agent test record --suite harness/tests/overworld_entry.jsonl` launches YAZE, mirrors your clicks/keystrokes, and persists an editable JSONL trace. +- **Replay & assert**: `z3ed agent test replay harness/tests/overworld_entry.jsonl --watch` drives the GUI in real time and streams pass/fail telemetry back to both the CLI and Agent Chat widget telemetry panel. +- **Integrate with proposals**: `z3ed agent test verify --proposal-id ` links a recorded scenario with a proposal to guarantee UI state after sandboxed edits. +- **Debug in the editor**: While a replay is running, open **Debug → Agent Chat → Harness Monitor** to step through events, capture screenshots, or restart the scenario without leaving ImGui. + ## 6. Chat Modes ### FTXUI Chat (`agent chat`) @@ -853,6 +871,16 @@ The AI response appears in your chat history and can reference specific details - Check firewall settings - Confirm server URL is correct +**"Harness client cannot reach gRPC"** +- Confirm YAZE was built with `-DYAZE_WITH_GRPC=ON` and the harness server is enabled via **Debug → Preferences → Automation**. +- Run `z3ed agent test ping --grpc localhost:50051` to verify the CLI can reach the embedded harness endpoint; restart YAZE if the ping fails. +- Inspect the Agent Chat **Harness Monitor** panel for connection status; use **Reconnect** to re-bind if the harness server was restarted. + +**"Widget discovery returns empty"** +- Ensure the target ImGui window is open; the harness only indexes visible widgets. +- Toggle **Automation → Enable Introspection** in YAZE to allow the gRPC server to expose widget metadata. +- Run `z3ed agent test discover --window "ProposalDrawer"` to scope discovery to the window you have open. + **"Session not found"** - Verify session code is correct (case-insensitive) - Check if session expired (server restart clears sessions) @@ -903,13 +931,25 @@ The AI response appears in your chat history and can reference specific details - Health monitoring and metrics endpoints - Rate limiting and security features +### 📌 Current Progress Highlights (October 5, 2025) + +- **Agent Platform Expansion**: AgentEditor now delivers full bot lifecycle controls, live prompt editing, multi-session management, and metrics synchronized with chat history and popup views. +- **Enhanced Chat Popup**: Left-side AgentChatHistoryPopup evolved into a theme-aware, fully interactive mini-chat with inline sending, multimodal capture, filtering, and proposal indicators to minimize context switching. +- **Proposal Workflow**: Sandbox-backed proposal review is end-to-end with inline quick actions, ProposalDrawer tie-ins, ROM version protections, and collaboration-aware approvals. +- **Collaboration & Networking**: yaze-server v2.0 protocol, cross-platform WebSocket client, collaboration panel, and gRPC ROM service unlock real-time edits, diff sharing, and remote automation. +- **AI & Automation Stack**: Proactive prompt v3, native Gemini function calling, learn/TODO systems, GUI automation planners, multimodal vision suite, and dashboard-surfaced test harness coverage broaden intelligent tooling. + ### 🚧 Active & Next Steps -1. **Live LLM Testing (1-2h)**: Verify function calling with real models (Ollama/Gemini). -2. **Expand Tool Coverage (8-10h)**: Add new read-only tools for inspecting dialogue, sprites, and regions. -3. **Full WebSocket Protocol (2-3 days)**: Upgrade from HTTP polling to true WebSocket frames using ixwebsocket or websocketpp. -4. **Collaboration UI Enhancements (1 day)**: Add UI elements for ROM sync, snapshot sharing, and proposal management in the Agent Chat widget. -5. **Windows Cross-Platform Testing (8-10h)**: Validate `z3ed` and the test harness on Windows. +1. **Harden Live LLM Tooling**: Finalize native function-calling loops with Ollama/Gemini and broaden safe read-only tool coverage for dialogue, sprite, and region introspection. +2. **Real-Time Transport Upgrade**: Replace HTTP polling with full WebSocket support across CLI/editor and expose ROM sync, snapshot, and proposal voting controls directly inside the AgentChat widget. +3. **Cross-Platform Certification**: Complete Windows validation for AI, gRPC, collaboration, and build presets leveraging the documented vcpkg workflow. +4. **UI/UX Roadmap Delivery**: Advance EditorManager menu refactors, enhanced hex/palette tooling, Vim-mode terminal chat, and richer popup affordances such as search, export, and resizing. +5. **Collaboration Safeguards**: Layer encrypted sessions, conflict resolution flows, AI-assisted proposal review, and deeper gRPC ROM service integrations to strengthen multi-user safety. +6. **Testing & Observability**: Automate multimodal/GUI harness scenarios, add performance benchmarks, and enable export/replay pipelines for the Test Dashboard. +7. **Hybrid Workflow Examples**: Document and dogfood end-to-end CLI→GUI automation loops (plan/run/diff + harness replay) with screenshots and recorded sessions. +8. **Automation API Unification**: Extract a reusable harness automation API consumed by both CLI `agent test` commands and the Agent Chat widget to prevent serialization drift. +9. **UI Abstraction Cleanup**: Introduce dedicated presenter/controller layers so `editor_manager.cc` delegates to automation and collaboration services, keeping ImGui widgets declarative. ### ✅ Recently Completed (v0.2.0-alpha - October 5, 2025) diff --git a/scripts/build_cleaner.py b/scripts/build_cleaner.py new file mode 100644 index 00000000..842b615b --- /dev/null +++ b/scripts/build_cleaner.py @@ -0,0 +1,362 @@ +#!/usr/bin/env python3 +"""Automate source list maintenance and self-header includes for YAZE.""" + +from __future__ import annotations + +import argparse +from dataclasses import dataclass, field +import re +from pathlib import Path +from typing import Dict, Iterable, List, Optional, Sequence, Set + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +SOURCE_ROOT = PROJECT_ROOT / "src" + +SUPPORTED_EXTENSIONS = (".cc", ".c", ".cpp", ".cxx", ".mm") +HEADER_EXTENSIONS = (".h", ".hh", ".hpp", ".hxx") +BUILD_CLEANER_IGNORE_TOKEN = "build_cleaner:ignore" + + +@dataclass(frozen=True) +class DirectorySpec: + path: Path + recursive: bool = True + extensions: Sequence[str] = SUPPORTED_EXTENSIONS + + def iter_files(self) -> Iterable[Path]: + if not self.path.exists(): + return [] + if self.recursive: + iterator = self.path.rglob("*") + else: + iterator = self.path.glob("*") + for candidate in iterator: + if candidate.is_file() and candidate.suffix in self.extensions: + yield candidate + + +@dataclass +class CMakeSourceBlock: + variable: str + cmake_path: Path + directories: Sequence[DirectorySpec] + exclude: Set[Path] = field(default_factory=set) + + +CONFIG: Sequence[CMakeSourceBlock] = ( + CMakeSourceBlock( + variable="YAZE_APP_EMU_SRC", + cmake_path=SOURCE_ROOT / "CMakeLists.txt", + directories=(DirectorySpec(SOURCE_ROOT / "app/emu"),), + ), + CMakeSourceBlock( + variable="YAZE_APP_CORE_SRC", + cmake_path=SOURCE_ROOT / "app/core/core_library.cmake", + directories=(DirectorySpec(SOURCE_ROOT / "app/core", recursive=False),), + ), + CMakeSourceBlock( + variable="YAZE_APP_GFX_SRC", + cmake_path=SOURCE_ROOT / "app/gfx/gfx_library.cmake", + directories=(DirectorySpec(SOURCE_ROOT / "app/gfx"),), + ), + CMakeSourceBlock( + variable="YAZE_GUI_SRC", + cmake_path=SOURCE_ROOT / "app/gui/gui_library.cmake", + directories=(DirectorySpec(SOURCE_ROOT / "app/gui"),), + ), + CMakeSourceBlock( + variable="YAZE_APP_EDITOR_SRC", + cmake_path=SOURCE_ROOT / "app/editor/editor_library.cmake", + directories=(DirectorySpec(SOURCE_ROOT / "app/editor"),), + ), + CMakeSourceBlock( + variable="YAZE_APP_ZELDA3_SRC", + cmake_path=SOURCE_ROOT / "app/zelda3/zelda3_library.cmake", + directories=(DirectorySpec(SOURCE_ROOT / "app/zelda3"),), + ), + CMakeSourceBlock( + variable="YAZE_NET_SRC", + cmake_path=SOURCE_ROOT / "app/net/net_library.cmake", + directories=(DirectorySpec(SOURCE_ROOT / "app/net"),), + exclude={Path("app/net/rom_service_impl.cc")}, + ), + CMakeSourceBlock( + variable="YAZE_UTIL_SRC", + cmake_path=SOURCE_ROOT / "util/util.cmake", + directories=(DirectorySpec(SOURCE_ROOT / "util"),), + ), +) + + +def relative_to_source(path: Path) -> Path: + return path.relative_to(SOURCE_ROOT) + + +def parse_block(lines: List[str], start_idx: int) -> int: + """Return index of the closing ')' line for a set/list block.""" + for idx in range(start_idx + 1, len(lines)): + if lines[idx].strip().startswith(")"): + return idx + raise ValueError(f"Unterminated set/list block starting at line {start_idx}") + + +def parse_entry(line: str) -> Optional[str]: + stripped = line.strip() + if not stripped or stripped.startswith("#"): + return None + # Remove trailing inline comment + stripped = stripped.split("#", 1)[0].strip() + if not stripped: + return None + if stripped.startswith("$"): + return None + return stripped + + +def gather_expected_sources(block: CMakeSourceBlock) -> List[str]: + entries: Set[str] = set() + for directory in block.directories: + for source_file in directory.iter_files(): + if should_ignore_path(source_file): + continue + rel_path = relative_to_source(source_file) + if rel_path in block.exclude: + continue + entries.add(str(rel_path).replace("\\", "/")) + return sorted(entries) + + +def should_ignore_path(path: Path) -> bool: + try: + with path.open("r", encoding="utf-8") as handle: + head = handle.read(256) + except (OSError, UnicodeDecodeError): + return False + return BUILD_CLEANER_IGNORE_TOKEN in head + + +def update_cmake_block(block: CMakeSourceBlock, dry_run: bool) -> bool: + cmake_lines = (block.cmake_path.read_text(encoding="utf-8")).splitlines() + pattern = re.compile(rf"\s*set\(\s*{re.escape(block.variable)}\b") + + start_idx: Optional[int] = None + for idx, line in enumerate(cmake_lines): + if pattern.match(line): + start_idx = idx + break + + if start_idx is None: + for idx, line in enumerate(cmake_lines): + stripped = line.strip() + if not stripped.startswith("set("): + continue + remainder = stripped[4:].strip() + if remainder: + if remainder.startswith(block.variable): + start_idx = idx + break + continue + lookahead = idx + 1 + while lookahead < len(cmake_lines): + next_line = cmake_lines[lookahead].strip() + if not next_line or next_line.startswith("#"): + lookahead += 1 + continue + if next_line == block.variable: + start_idx = idx + break + if start_idx is not None: + break + + if start_idx is None: + raise ValueError(f"Could not locate set({block.variable}) in {block.cmake_path}") + + end_idx = parse_block(cmake_lines, start_idx) + block_slice = cmake_lines[start_idx + 1 : end_idx] + + prelude: List[str] = [] + postlude: List[str] = [] + existing_entries: List[str] = [] + + first_entry_idx: Optional[int] = None + + for idx, line in enumerate(block_slice): + entry = parse_entry(line) + if entry: + if entry == block.variable and not existing_entries: + prelude.append(line) + continue + existing_entries.append(entry) + if first_entry_idx is None: + first_entry_idx = idx + else: + if first_entry_idx is None: + prelude.append(line) + else: + postlude.append(line) + + expected_entries = gather_expected_sources(block) + expected_set = set(expected_entries) + + if set(existing_entries) == expected_set: + return False + + indent = " " + if first_entry_idx is not None: + sample_line = block_slice[first_entry_idx] + indent = sample_line[: len(sample_line) - len(sample_line.lstrip())] + + rebuilt_block = prelude + [f"{indent}{entry}" for entry in expected_entries] + postlude + + if dry_run: + print(f"[DRY-RUN] Would update {block.cmake_path.relative_to(PROJECT_ROOT)}") + return True + + cmake_lines[start_idx + 1 : end_idx] = rebuilt_block + block.cmake_path.write_text("\n".join(cmake_lines) + "\n", encoding="utf-8") + print(f"Updated {block.cmake_path.relative_to(PROJECT_ROOT)}") + missing = sorted(expected_set - set(existing_entries)) + removed = sorted(set(existing_entries) - expected_set) + if missing: + print(f" Added: {', '.join(missing)}") + if removed: + print(f" Removed: {', '.join(removed)}") + return True + + +def find_self_header(source: Path) -> Optional[Path]: + for ext in HEADER_EXTENSIONS: + candidate = source.with_suffix(ext) + if candidate.exists(): + return candidate + return None + + +def has_include(lines: Sequence[str], header_variants: Iterable[str]) -> bool: + include_patterns = {f'#include "{variant}"' for variant in header_variants} + return any(line.strip() in include_patterns for line in lines) + + +def find_insert_index(lines: List[str]) -> int: + include_block_start = None + for idx, line in enumerate(lines): + if line.startswith("#include"): + include_block_start = idx + break + + if include_block_start is not None: + return include_block_start + + # No includes yet; skip leading comments/blank lines + index = 0 + in_block_comment = False + while index < len(lines): + stripped = lines[index].strip() + if not stripped: + index += 1 + continue + if stripped.startswith("/*") and not stripped.endswith("*/"): + in_block_comment = True + index += 1 + continue + if in_block_comment: + if "*/" in stripped: + in_block_comment = False + index += 1 + continue + if stripped.startswith("//"): + index += 1 + continue + break + return index + + +def ensure_self_header_include(source: Path, dry_run: bool) -> bool: + if should_ignore_path(source): + return False + + header = find_self_header(source) + if header is None: + return False + + try: + lines = source.read_text(encoding="utf-8").splitlines() + except UnicodeDecodeError: + return False + + header_variants = { + header.name, + str(header.relative_to(SOURCE_ROOT)).replace("\\", "/"), + } + + if has_include(lines, header_variants): + return False + + include_line = f'#include "{header.name}"' + + insert_idx = find_insert_index(lines) + lines.insert(insert_idx, include_line) + + if dry_run: + rel = source.relative_to(PROJECT_ROOT) + print(f"[DRY-RUN] Would insert self-header include into {rel}") + return True + + source.write_text("\n".join(lines) + "\n", encoding="utf-8") + print(f"Inserted self-header include into {source.relative_to(PROJECT_ROOT)}") + return True + + +def collect_source_files() -> Set[Path]: + managed_dirs: Set[Path] = set() + for block in CONFIG: + for directory in block.directories: + managed_dirs.add(directory.path) + + result: Set[Path] = set() + for directory in managed_dirs: + if not directory.exists(): + continue + for file_path in directory.rglob("*"): + if file_path.is_file() and file_path.suffix in SUPPORTED_EXTENSIONS: + result.add(file_path) + return result + + +def run(dry_run: bool, cmake_only: bool, includes_only: bool) -> int: + if cmake_only and includes_only: + raise ValueError("Cannot use --cmake-only and --includes-only together") + + changed = False + + if not includes_only: + for block in CONFIG: + changed |= update_cmake_block(block, dry_run) + + if not cmake_only: + for source in collect_source_files(): + changed |= ensure_self_header_include(source, dry_run) + + if dry_run and not changed: + print("No changes required (dry-run)") + if not dry_run and not changed: + print("No changes required") + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description="Maintain CMake source lists and self-header includes.") + parser.add_argument("--dry-run", action="store_true", help="Report prospective changes without editing files") + parser.add_argument("--cmake-only", action="store_true", help="Only update CMake source lists") + parser.add_argument("--includes-only", action="store_true", help="Only ensure self-header includes") + args = parser.parse_args() + + try: + return run(args.dry_run, args.cmake_only, args.includes_only) + except Exception as exc: # pylint: disable=broad-except + print(f"build_cleaner failed: {exc}") + return 1 + + +if __name__ == "__main__": + raise SystemExit(main())