From 3cec17947c3474c760612c24d2918f1b24d12106 Mon Sep 17 00:00:00 2001
From: Vic <125237471+vicsanity623@users.noreply.github.com>
Date: Mon, 23 Mar 2026 05:45:56 -0700
Subject: [PATCH 1/3] Delete src/pyob directory
---
src/pyob/__init__.py | 0
src/pyob/autoreviewer.py | 454 -------------------------
src/pyob/cascade_queue_handler.py | 58 ----
src/pyob/core_utils.py | 509 ---------------------------
src/pyob/dashboard_html.py | 438 ------------------------
src/pyob/dashboard_server.py | 234 -------------
src/pyob/data_parser.py | 38 ---
src/pyob/entrance.py | 547 ------------------------------
src/pyob/entrance_mixins.py | 174 ----------
src/pyob/evolution_mixins.py | 321 ------------------
src/pyob/feature_mixins.py | 340 -------------------
src/pyob/get_valid_edit.py | 270 ---------------
src/pyob/models.py | 386 ---------------------
src/pyob/prompts_and_memory.py | 153 ---------
src/pyob/pyob_code_parser.py | 141 --------
src/pyob/pyob_dashboard.py | 510 ----------------------------
src/pyob/pyob_launcher.py | 221 ------------
src/pyob/reviewer_mixins.py | 446 ------------------------
src/pyob/scanner_mixins.py | 21 --
src/pyob/stats_updater.py | 71 ----
src/pyob/targeted_reviewer.py | 26 --
src/pyob/xml_mixin.py | 240 -------------
22 files changed, 5598 deletions(-)
delete mode 100644 src/pyob/__init__.py
delete mode 100644 src/pyob/autoreviewer.py
delete mode 100644 src/pyob/cascade_queue_handler.py
delete mode 100644 src/pyob/core_utils.py
delete mode 100644 src/pyob/dashboard_html.py
delete mode 100644 src/pyob/dashboard_server.py
delete mode 100644 src/pyob/data_parser.py
delete mode 100644 src/pyob/entrance.py
delete mode 100644 src/pyob/entrance_mixins.py
delete mode 100644 src/pyob/evolution_mixins.py
delete mode 100644 src/pyob/feature_mixins.py
delete mode 100644 src/pyob/get_valid_edit.py
delete mode 100644 src/pyob/models.py
delete mode 100644 src/pyob/prompts_and_memory.py
delete mode 100644 src/pyob/pyob_code_parser.py
delete mode 100644 src/pyob/pyob_dashboard.py
delete mode 100644 src/pyob/pyob_launcher.py
delete mode 100644 src/pyob/reviewer_mixins.py
delete mode 100644 src/pyob/scanner_mixins.py
delete mode 100644 src/pyob/stats_updater.py
delete mode 100644 src/pyob/targeted_reviewer.py
delete mode 100644 src/pyob/xml_mixin.py
diff --git a/src/pyob/__init__.py b/src/pyob/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/src/pyob/autoreviewer.py b/src/pyob/autoreviewer.py
deleted file mode 100644
index 569676c..0000000
--- a/src/pyob/autoreviewer.py
+++ /dev/null
@@ -1,454 +0,0 @@
-import ast
-import os
-import random
-import subprocess
-import sys
-import time
-import uuid
-
-import requests
-
-from .core_utils import (
- ANALYSIS_FILE,
- FAILED_FEATURE_FILE_NAME,
- FAILED_PR_FILE_NAME,
- FEATURE_FILE_NAME,
- GEMINI_API_KEYS,
- HISTORY_FILE,
- MEMORY_FILE_NAME,
- PR_FILE_NAME,
- SYMBOLS_FILE,
- CoreUtilsMixin,
- logger,
-)
-from .feature_mixins import FeatureOperationsMixin
-from .get_valid_edit import GetValidEditMixin
-from .prompts_and_memory import PromptsAndMemoryMixin
-from .reviewer_mixins import ValidationMixin
-from .scanner_mixins import ScannerMixin
-
-
-class AutoReviewer(
- CoreUtilsMixin,
- PromptsAndMemoryMixin,
- ValidationMixin,
- FeatureOperationsMixin,
- ScannerMixin,
- GetValidEditMixin,
-):
- _shared_cooldowns: dict[str, float] | None = None
- DASHBOARD_BASE_URL: str = os.environ.get(
- "PYOB_DASHBOARD_URL", "http://localhost:8000"
- )
-
- def __init__(self, target_dir: str):
- self.target_dir = os.path.abspath(target_dir)
- self.pyob_dir = os.path.join(self.target_dir, ".pyob")
- os.makedirs(self.pyob_dir, exist_ok=True)
- self.pr_file = os.path.join(self.pyob_dir, PR_FILE_NAME)
- self.feature_file = os.path.join(self.pyob_dir, FEATURE_FILE_NAME)
- self.failed_pr_file = os.path.join(self.pyob_dir, FAILED_PR_FILE_NAME)
- self.failed_feature_file = os.path.join(self.pyob_dir, FAILED_FEATURE_FILE_NAME)
- self.memory_path = os.path.join(self.pyob_dir, MEMORY_FILE_NAME)
- self.analysis_path = os.path.join(self.pyob_dir, ANALYSIS_FILE)
- self.history_path = os.path.join(self.pyob_dir, HISTORY_FILE)
- self.symbols_path = os.path.join(self.pyob_dir, SYMBOLS_FILE)
- self.memory = self.load_memory()
- self.session_context: list[str] = []
- self.manual_target_file: str | None = None
- self._ensure_prompt_files()
- if AutoReviewer._shared_cooldowns is None:
- AutoReviewer._shared_cooldowns = {
- key: 0.0 for key in GEMINI_API_KEYS if key.strip()
- }
-
- self.key_cooldowns = AutoReviewer._shared_cooldowns
-
- def get_language_info(self, filepath: str) -> tuple[str, str]:
- ext = os.path.splitext(filepath)[1].lower()
- mapping = {
- ".py": ("Python", "python"),
- ".js": ("JavaScript", "javascript"),
- ".ts": ("TypeScript", "typescript"),
- ".html": ("HTML", "html"),
- ".css": ("CSS", "css"),
- ".json": ("JSON", "json"),
- ".sh": ("Bash", "bash"),
- ".md": ("Markdown", "markdown"),
- }
- return mapping.get(ext, ("Code", ""))
-
- def scan_for_lazy_code(self, filepath: str, content: str) -> list[str]:
- issues = []
- lines = content.splitlines()
-
- if len(lines) > 800:
- issues.append(
- f"Architectural Bloat: File has {len(lines)} lines. This exceeds the 800-line modularity threshold. Priority: HIGH. Action: Split into smaller modules."
- )
-
- try:
- tree = ast.parse(content)
- except SyntaxError as e:
- return [f"SyntaxError during AST parse: {e}"]
- for node in ast.walk(tree):
- if isinstance(node, ast.Name) and node.id == "Any":
- issues.append("Found use of 'Any' type hint.")
- elif isinstance(node, ast.Attribute) and node.attr == "Any":
- issues.append("Found use of 'typing.Any'.")
- return issues
-
- def _generate_unique_session_id(self) -> str:
- """Generates a unique session ID for dashboard interactions."""
- return str(uuid.uuid4())
-
- def _get_dashboard_decision(self, allow_delete: bool) -> str:
- """
- Initiates an interactive web-based review process for pending proposals
- and waits for the user's decision from the dashboard.
- The user makes the decision directly on the dashboard UI.
- """
- is_cloud = (
- os.environ.get("GITHUB_ACTIONS") == "true"
- or os.environ.get("CI") == "true"
- or "GITHUB_RUN_ID" in os.environ
- )
- if is_cloud or not sys.stdin.isatty():
- logger.info(
- "Headless environment detected: Auto-approving dashboard proposal."
- )
- return "PROCEED"
-
- session_id = self._generate_unique_session_id()
- dashboard_url = f"{self.DASHBOARD_BASE_URL}/review/{session_id}"
- decision_api_url = f"{self.DASHBOARD_BASE_URL}/api/decision/{session_id}"
-
- logger.info("==================================================")
- logger.info(" ACTION REQUIRED: Interactive Proposal Review")
- logger.info("==================================================")
- logger.info(
- "Pending proposals require your review. Please open your web browser to:"
- )
- logger.info(f" -> {dashboard_url}")
- logger.info(
- "Waiting for your decision from the dashboard (PROCEED, SKIP, or DELETE)..."
- )
-
- decision = None
- poll_interval_seconds = 2
- max_retries = 3
-
- retries = 0
- while decision is None:
- try:
- response = requests.get(decision_api_url, timeout=5)
- response.raise_for_status()
- data = response.json()
- if data.get("decision"):
- decision = data["decision"].upper()
- if decision not in ["PROCEED", "SKIP"] and (
- not allow_delete or decision != "DELETE"
- ):
- logger.warning(
- f"Invalid or disallowed decision '{decision}' received from dashboard. Defaulting to SKIP."
- )
- decision = "SKIP"
- else:
- time.sleep(poll_interval_seconds)
- retries = 0
- except requests.exceptions.ConnectionError as e:
- retries += 1
- logger.error(
- f"Could not connect to dashboard server at {self.DASHBOARD_BASE_URL}. (Attempt {retries}/{max_retries}) Error: {e}"
- )
- if retries >= max_retries:
- logger.info(
- "Max connection retries reached. Falling back to CLI input for decision."
- )
- break
- time.sleep(poll_interval_seconds * 2)
- except requests.exceptions.Timeout:
- logger.debug("Dashboard decision poll timed out, retrying...")
- time.sleep(poll_interval_seconds)
- except requests.exceptions.RequestException as e:
- logger.error(
- f"An HTTP request error occurred while polling dashboard: {e}"
- )
- time.sleep(poll_interval_seconds)
- except Exception as e:
- logger.error(
- f"An unexpected error occurred while polling dashboard: {e}"
- )
- time.sleep(poll_interval_seconds)
-
- if decision is None:
- prompt_options = "'PROCEED' to apply, 'SKIP' to ignore"
- if allow_delete:
- prompt_options += ", 'DELETE' to discard"
- try:
- user_decision = input(f"Enter {prompt_options}: ").strip().upper()
- if user_decision not in ["PROCEED", "SKIP"] and (
- not allow_delete or user_decision != "DELETE"
- ):
- logger.warning(
- f"Invalid input '{user_decision}'. Defaulting to SKIP."
- )
- decision = "SKIP"
- else:
- decision = user_decision
- except EOFError:
- logger.warning(
- "EOFError caught during input. Auto-approving to prevent crash."
- )
- decision = "PROCEED"
-
- logger.info(f"Dashboard decision received: {decision}")
- return decision
- is_cloud = (
- os.environ.get("GITHUB_ACTIONS") == "true"
- or os.environ.get("CI") == "true"
- or "GITHUB_RUN_ID" in os.environ
- )
- if is_cloud or not sys.stdin.isatty():
- logger.info(
- "Headless environment detected: Auto-approving dashboard proposal."
- )
- return "PROCEED"
-
- session_id = self._generate_unique_session_id()
- dashboard_url = f"{self.DASHBOARD_BASE_URL}/review/{session_id}"
-
- logger.info("==================================================")
- logger.info(" ACTION REQUIRED: Interactive Proposal Review")
- logger.info("==================================================")
- logger.info(
- "Pending proposals require your review. Please open your web browser to:"
- )
- logger.info(f" -> {dashboard_url}")
- logger.info(
- f"Please open your web browser to the URL above for context. Decision will be taken via CLI (PROCEED, SKIP, or {'DELETE' if allow_delete else 'CANCEL'})..."
- )
-
- prompt_options = "'PROCEED' to apply, 'SKIP' to ignore"
- if allow_delete:
- prompt_options += ", 'DELETE' to discard"
-
- try:
- user_decision = (
- input(f"Simulating dashboard decision (enter {prompt_options}): ")
- .strip()
- .upper()
- )
-
- if user_decision not in ["PROCEED", "SKIP", "DELETE"]:
- logger.warning(f"Invalid input '{user_decision}'. Defaulting to SKIP.")
- user_decision = "SKIP"
-
- except EOFError:
- logger.warning(
- "EOFError caught during input. Auto-approving to prevent crash."
- )
- user_decision = "PROCEED"
-
- logger.info(f"Dashboard decision received: {user_decision}")
- return user_decision
-
- def set_manual_target_file(self, filepath: str | None):
- if filepath:
- if not os.path.exists(filepath):
- logger.warning(
- f"Manual target file '{filepath}' does not exist. Ignoring."
- )
- self.manual_target_file = None
- else:
- self.manual_target_file = os.path.abspath(filepath)
- logger.info(f"Manual target file set to: {self.manual_target_file}")
- else:
- self.manual_target_file = None
- logger.info("Manual target file cleared. Reverting to directory scan.")
-
- def run_linters(self, filepath: str) -> tuple[str, str]:
-
- ruff_out, mypy_out = "", ""
- try:
- ruff_out = subprocess.run(
- ["ruff", "check", filepath], capture_output=True, text=True
- ).stdout.strip()
- except FileNotFoundError:
- pass
- try:
- res = subprocess.run(["mypy", filepath], capture_output=True, text=True)
- mypy_out = res.stdout.strip()
- except FileNotFoundError:
- pass
- return ruff_out, mypy_out
-
- def build_patch_prompt(
- self,
- lang_name: str,
- lang_tag: str,
- content: str,
- ruff_out: str,
- mypy_out: str,
- custom_issues: list[str],
- ) -> str:
- memory_section = self._get_rich_context()
- ruff_section = f"### Ruff Errors:\n{ruff_out}\n\n" if ruff_out else ""
- mypy_section = f"### Mypy Errors:\n{mypy_out}\n\n" if mypy_out else ""
- custom_issues_section = (
- "### Code Quality Issues:\n"
- + "\n".join(f"- {i}" for i in custom_issues)
- + "\n\n"
- if custom_issues
- else ""
- )
- return str(
- self.load_prompt(
- "PP.md",
- lang_name=lang_name,
- lang_tag=lang_tag,
- content=content,
- memory_section=memory_section,
- ruff_section=ruff_section,
- mypy_section=mypy_section,
- custom_issues_section=custom_issues_section,
- )
- )
-
- def _handle_pending_proposals(
- self, prompt_message: str, allow_delete: bool
- ) -> bool:
- """
- Handles user approval for pending PR/feature files, applies them,
- manages rollback on failure, or deletes them.
- Returns True if proposals were successfully applied or deleted,
- False if skipped or failed to apply.
- """
- if not (os.path.exists(self.pr_file) or os.path.exists(self.feature_file)):
- return False
-
- user_input = self._get_dashboard_decision(allow_delete)
-
- if user_input == "PROCEED":
- backup_state = self.backup_workspace()
- success = True
- if os.path.exists(self.pr_file):
- with open(self.pr_file, "r", encoding="utf-8") as f:
- if not self.implement_pr(f.read()):
- success = False
- if success and os.path.exists(self.feature_file):
- with open(self.feature_file, "r", encoding="utf-8") as f:
- if not self.implement_feature(f.read()):
- success = False
- if not success:
- self.restore_workspace(backup_state)
- logger.warning("Rollback performed due to unfixable errors.")
- self.session_context.append(
- "CRITICAL: The last refactor/feature attempt FAILED and was ROLLED BACK. "
- "The files on disk have NOT changed. Check FAILED_FEATURE.md for error logs."
- )
-
- failure_report = f"\n\n### FAILURE ATTEMPT LOGS ({time.strftime('%Y-%m-%d %H:%M:%S')})\n"
- failure_report += self.session_context[-1]
- if len(self.session_context) > 1:
- failure_report += "\n" + "\n".join(self.session_context[-3:-1])
-
- if os.path.exists(self.pr_file):
- with open(self.pr_file, "r", encoding="utf-8") as f:
- content = f.read()
- with open(self.failed_pr_file, "w") as f:
- f.write(content + failure_report)
- os.remove(self.pr_file)
-
- if os.path.exists(self.feature_file):
- with open(self.feature_file, "r", encoding="utf-8") as f:
- content = f.read()
- with open(self.failed_feature_file, "w") as f:
- f.write(content + failure_report)
- os.remove(self.feature_file)
- return False
- return True
- elif allow_delete and user_input == "DELETE":
- if os.path.exists(self.pr_file):
- os.remove(self.pr_file)
- if os.path.exists(self.feature_file):
- os.remove(self.feature_file)
- logger.info("Deleted pending proposal files. Starting fresh scan...")
- return True
- else:
- logger.info(
- "Changes not applied manually. They will remain for the next loop iteration."
- )
- return False
-
- def run_pipeline(self, current_iteration: int):
- changes_made = False
- try:
- if os.path.exists(self.pr_file) or os.path.exists(self.feature_file):
- logger.info("==================================================")
- logger.info(
- f"Found pending {PR_FILE_NAME} and/or {FEATURE_FILE_NAME} from a previous run."
- )
- proposals_handled = self._handle_pending_proposals(
- "Hit ENTER to PROCEED, type 'SKIP' to ignore",
- allow_delete=True,
- )
- if not proposals_handled:
- logger.info(
- "Pending proposals were not applied or deleted. Halting current pipeline iteration to await user action."
- )
- return
- changes_made = True
-
- if not changes_made:
- logger.info("==================================================")
- logger.info("PHASE 1: Initial Assessment & Codebase Scan")
- logger.info("==================================================")
- if self.manual_target_file:
- if os.path.exists(self.manual_target_file):
- all_files = [self.manual_target_file]
- logger.info(
- f"Manual target file override active: {self.manual_target_file}"
- )
- else:
- logger.warning(
- f"Manual target file '{self.manual_target_file}' not found. Reverting to full scan."
- )
- self.manual_target_file = None
- all_files = self.scan_directory()
- else:
- all_files = self.scan_directory()
- if not all_files:
- return logger.warning("No supported source files found.")
- for idx, filepath in enumerate(all_files, start=1):
- self.analyze_file(filepath, idx, len(all_files))
- logger.info("==================================================")
- logger.info(" Phase 1 Complete.")
- logger.info("==================================================")
- if os.path.exists(self.pr_file):
- logger.info(
- "Skipping Phase 2 (Feature Proposal) because Phase 1 found bugs."
- )
- logger.info("Applying fixes first to prevent code collisions...")
- elif all_files:
- logger.info("Moving to Phase 2: Generating Feature Proposal...")
- self.propose_feature(random.choice(all_files))
- if os.path.exists(self.pr_file) or os.path.exists(self.feature_file):
- print("\n" + "=" * 50)
- print(" ACTION REQUIRED: Proposals Generated")
- self._handle_pending_proposals(
- "Hit ENTER to PROCEED, or type 'SKIP' to cancel",
- allow_delete=False,
- )
- else:
- logger.info("\nNo issues found, no features proposed.")
- finally:
- self.update_memory()
- if current_iteration % 2 == 0:
- self.refactor_memory()
- logger.info("Pipeline iteration complete.")
-
-
-if __name__ == "__main__":
- print("Please run `python entrance.py` instead to use the targeted memory flow.")
- sys.exit(0)
diff --git a/src/pyob/cascade_queue_handler.py b/src/pyob/cascade_queue_handler.py
deleted file mode 100644
index 3cffc5f..0000000
--- a/src/pyob/cascade_queue_handler.py
+++ /dev/null
@@ -1,58 +0,0 @@
-import json
-from typing import Protocol
-
-
-class CascadeControllerProtocol(Protocol):
- def add_to_cascade_queue(self, item: str) -> None: ...
- def remove_cascade_queue_item(self, item_id: str) -> None: ...
- def move_cascade_queue_item(self, item_id: str, direction: str) -> None: ...
-
-
-class CascadeQueueHandler:
- def __init__(self, controller: CascadeControllerProtocol):
- self.controller = controller
-
- def handle_add_to_cascade_queue(self, item: str):
- try:
- self.controller.add_to_cascade_queue(item)
- return json.dumps(
- {"message": f"Item '{item}' added to cascade queue successfully"}
- ).encode()
- except AttributeError:
- return json.dumps(
- {
- "error": "Controller method 'add_to_cascade_queue' not found. Ensure entrance.py is updated."
- }
- ).encode()
- except Exception as e:
- return json.dumps({"error": f"Internal server error: {str(e)}"}).encode()
-
- def handle_remove_from_cascade_queue(self, item_id: str):
- try:
- self.controller.remove_cascade_queue_item(item_id)
- return json.dumps(
- {"message": f"Item {item_id} removed successfully"}
- ).encode()
- except AttributeError:
- return json.dumps(
- {
- "error": "Controller method 'remove_cascade_queue_item' not found. Ensure entrance.py is updated."
- }
- ).encode()
- except Exception as e:
- return json.dumps({"error": f"Internal server error: {str(e)}"}).encode()
-
- def handle_move_cascade_queue_item(self, item_id: str, direction: str):
- try:
- self.controller.move_cascade_queue_item(item_id, direction)
- return json.dumps(
- {"message": f"Item {item_id} moved {direction} successfully"}
- ).encode()
- except AttributeError:
- return json.dumps(
- {
- "error": "Controller method 'move_cascade_queue_item' not found. Ensure entrance.py is updated."
- }
- ).encode()
- except Exception as e:
- return json.dumps({"error": f"Internal server error: {str(e)}"}).encode()
diff --git a/src/pyob/core_utils.py b/src/pyob/core_utils.py
deleted file mode 100644
index 363a14a..0000000
--- a/src/pyob/core_utils.py
+++ /dev/null
@@ -1,509 +0,0 @@
-import json
-import logging
-import os
-import re
-import select
-import shutil
-import subprocess
-import sys
-import textwrap
-import time
-from typing import Callable, Optional
-
-from .models import (
- get_valid_llm_response_engine,
- stream_gemini,
- stream_github_models,
- stream_ollama,
- stream_single_llm,
-)
-
-env_keys = os.environ.get("PYOB_GEMINI_KEYS", "")
-GEMINI_API_KEYS = [k.strip() for k in env_keys.split(",") if k.strip()]
-GEMINI_MODEL = os.environ.get("PYOB_GEMINI_MODEL", "gemini-2.5-flash")
-LOCAL_MODEL = os.environ.get("PYOB_LOCAL_MODEL", "qwen3-coder:30b")
-PR_FILE_NAME = "PEER_REVIEW.md"
-FEATURE_FILE_NAME = "FEATURE.md"
-FAILED_PR_FILE_NAME = "FAILED_PEER_REVIEW.md"
-FAILED_FEATURE_FILE_NAME = "FAILED_FEATURE.md"
-MEMORY_FILE_NAME = "MEMORY.md"
-ANALYSIS_FILE = "ANALYSIS.md"
-HISTORY_FILE = "HISTORY.md"
-SYMBOLS_FILE = "SYMBOLS.json"
-PYOB_DATA_DIR = ".pyob"
-
-IGNORE_DIRS = {
- ".git",
- ".github",
- # ".pyob",
- "autovenv",
- "build_env",
- "pyob.egg-info",
- "TapEvent",
- "build",
- "dist",
- "docs",
- "venv",
- ".venv",
- "code",
- ".mypy_cache",
- ".ruff_cache",
- ".pytest_cache",
- "patch_test",
- "env",
- "__pycache__",
- "node_modules",
- ".vscode",
- ".idea",
- "tests",
-}
-
-IGNORE_FILES = {
- "package-lock.json",
- "LICENSE",
- "manifest.json",
- "action.yml",
- "Dockerfile",
- "build_pyinstaller_multiOS.py",
- "check.sh",
- ".pyob_config",
- ".DS_Store",
- ".gitignore",
- "pyob.icns",
- "pyob.ico",
- "pyob.png",
- "ROADMAP.md",
- "README.md",
- "DOCUMENTATION.md",
- "observer.html",
-}
-SUPPORTED_EXTENSIONS = {".py", ".js", ".ts", ".html", ".css", ".json", ".sh"}
-
-
-class CyberpunkFormatter(logging.Formatter):
- GREEN = "\033[92m"
- YELLOW = "\033[93m"
- RED = "\033[91m"
- BLUE = "\033[94m"
- RESET = "\033[0m"
-
- def format(self, record: logging.LogRecord) -> str:
- cols, _ = shutil.get_terminal_size((80, 20))
- color = self.RESET
- if record.levelno == logging.INFO:
- color = self.GREEN
- elif record.levelno == logging.WARNING:
- color = self.YELLOW
- elif record.levelno >= logging.ERROR:
- color = self.RED
-
- prefix = f"{time.strftime('%H:%M:%S')} | "
- available_width = max(cols - len(prefix) - 1, 20)
- message = record.getMessage()
- wrapped_lines = textwrap.wrap(message, width=available_width)
-
- formatted_msg = ""
- for i, line in enumerate(wrapped_lines):
- if i == 0:
- formatted_msg += (
- f"{self.BLUE}{prefix}{self.RESET}{color}{line}{self.RESET}"
- )
- else:
- formatted_msg += f"\n{' ' * len(prefix)}{color}{line}{self.RESET}"
- return formatted_msg
-
-
-logger = logging.getLogger("PyOuroBoros")
-logger.setLevel(logging.INFO)
-handler = logging.StreamHandler(sys.stdout)
-handler.setFormatter(CyberpunkFormatter())
-logger.addHandler(handler)
-logger.propagate = False
-
-
-class CoreUtilsMixin:
- target_dir: str
- memory_path: str
- key_cooldowns: dict[str, float]
-
- def generate_pr_summary(self, rel_path: str, diff_text: str) -> dict:
- """Analyzes a git diff and returns a professional title and body for the PR."""
- prompt = f"""
- Analyze the following git diff for file `{rel_path}` and write a professional, high-quality PR title and description.
- RULES:
- 1. PR Title: Start with a category (e.g., "Refactor:", "Feature:", "Fix:", "Security:") followed by a concise summary.
- 2. PR Body: Use professional markdown. Include sections for 'Summary of Changes' and 'Technical Impact'.
- 3. NO TIMESTAMPS: Do not mention the time or date.
- GIT DIFF:
- {diff_text}
- OUTPUT FORMAT (STRICT JSON):
- {{"title": "...", "body": "..."}}
- """
-
- try:
- response = self.get_valid_llm_response(
- prompt,
- lambda t: '"title":' in t and '"body":' in t,
- context="PR Architect",
- )
-
- json_match = re.search(r"(\{.*\})", response, re.DOTALL)
- if json_match:
- clean_json = json_match.group(1)
- else:
- clean_json = re.sub(
- r"^```json\s*|\s*```$", "", response.strip(), flags=re.MULTILINE
- )
-
- data = json.loads(clean_json, strict=False)
-
- if isinstance(data, dict):
- return data
-
- raise ValueError("LLM response was not a valid dictionary object")
-
- except Exception as e:
- logger.warning(f"Librarian failed to generate AI summary: {e}")
- return {
- "title": f"Evolution: Refactor of `{rel_path}`",
- "body": f"Automated self-evolution update for `{rel_path}`. Verified stable via runtime testing.",
- }
-
- def stream_gemini(
- self, prompt: str, api_key: str, on_chunk: Callable[[], None]
- ) -> str:
- return stream_gemini(prompt, api_key, on_chunk)
-
- def stream_ollama(self, prompt: str, on_chunk: Callable[[], None]) -> str:
- return str(stream_ollama(prompt, on_chunk))
-
- def stream_github_models(
- self, prompt: str, on_chunk: Callable[[], None], model_name: str = "Llama-3"
- ) -> str:
- return str(stream_github_models(prompt, on_chunk, model_name))
-
- def _stream_single_llm(
- self,
- prompt: str,
- key: Optional[str] = None,
- context: str = "",
- gh_model: str = "Llama-3",
- ) -> str:
- return str(stream_single_llm(prompt, key, context, gh_model))
-
- def get_user_approval(self, prompt_text: str, timeout: int = 220) -> str:
- if (
- not sys.stdin.isatty()
- or os.environ.get("GITHUB_ACTIONS") == "true"
- or os.environ.get("CI") == "true"
- or "GITHUB_RUN_ID" in os.environ
- ):
- logger.info(" Headless environment detected: Auto-approving action.")
- return "PROCEED"
- print(f"\n{prompt_text}")
- start_time = time.time()
- input_str = ""
- if sys.platform == "win32":
- import msvcrt
-
- prev_line_len = 0
- while True:
- remaining = int(timeout - (time.time() - start_time))
- if remaining <= 0:
- return "PROCEED"
- current_display_str = f" {remaining}s remaining | You: {input_str}"
- padding_needed = max(0, prev_line_len - len(current_display_str))
- sys.stdout.write(f"\r{current_display_str}{' ' * padding_needed}")
- prev_line_len = len(current_display_str) + padding_needed
- sys.stdout.flush()
- if msvcrt.kbhit():
- char = msvcrt.getwch()
- if char in ("\r", "\n"):
- print()
- val = input_str.strip().upper()
- return val if val else "PROCEED"
- elif char == "\x08":
- input_str = input_str[:-1]
- else:
- input_str += char
- time.sleep(0.1)
- else:
- import termios
- import tty
-
- fd = sys.stdin.fileno()
- old_settings = termios.tcgetattr(fd)
- try:
- tty.setcbreak(fd)
- while True:
- remaining = int(timeout - (time.time() - start_time))
- if remaining <= 0:
- return "PROCEED"
- sys.stdout.write(
- f"\r {remaining}s remaining | You: {input_str}\033[K"
- )
- sys.stdout.flush()
- i, o, e = select.select([sys.stdin], [], [], 0.1)
- if i:
- char = sys.stdin.read(1)
- if char in ("\n", "\r"):
- print()
- val = input_str.strip().upper()
- return val if val else "PROCEED"
- elif char in ("\x08", "\x7f"):
- input_str = input_str[:-1]
- else:
- input_str += char
- finally:
- termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
-
- def _open_editor_for_content(
- self,
- initial_content: str,
- file_suffix: str = ".txt",
- log_message: str = "Opening editor",
- error_message: str = "Using original content.",
- ) -> str:
- import tempfile
-
- editor = os.environ.get("EDITOR", "nano")
- with tempfile.NamedTemporaryFile(
- mode="w+", delete=False, encoding="utf-8", suffix=file_suffix
- ) as tmp_file:
- tmp_file.write(initial_content)
- tmp_file_path = tmp_file.name
- logger.info(f"{log_message}: {editor} {tmp_file_path}")
- try:
- subprocess.run([editor, tmp_file_path], check=True)
- with open(tmp_file_path, "r", encoding="utf-8") as f:
- edited_content = f.read()
- return edited_content
- except FileNotFoundError:
- logger.error(f"Editor '{editor}' not found. {error_message}")
- return initial_content
- except subprocess.CalledProcessError:
- logger.error(f"Editor '{editor}' exited with an error. {error_message}")
- return initial_content
- except Exception:
- logger.error(f"An unexpected error occurred with editor. {error_message}")
- return initial_content
- finally:
- if os.path.exists(tmp_file_path):
- os.remove(tmp_file_path)
-
- def _launch_external_code_editor(
- self, initial_content: str, file_suffix: str = ".py"
- ) -> str:
- return self._open_editor_for_content(
- initial_content,
- file_suffix,
- log_message="Opening prompt augmentation editor",
- error_message="Using original content.",
- )
-
- def _edit_prompt_with_external_editor(self, initial_prompt: str) -> str:
- return self._open_editor_for_content(
- initial_prompt,
- log_message="Opening prompt in editor",
- error_message="Using original prompt.",
- )
-
- def backup_workspace(self) -> dict[str, str]:
- state: dict[str, str] = {}
- for root, dirs, files in os.walk(self.target_dir):
- dirs[:] = [d for d in dirs if d not in IGNORE_DIRS]
-
- for file in files:
- if file in IGNORE_FILES:
- continue
-
- if any(file.endswith(ext) for ext in SUPPORTED_EXTENSIONS):
- path = os.path.join(root, file)
- try:
- with open(path, "r", encoding="utf-8") as f:
- state[path] = f.read()
- except Exception:
- pass
- return state
-
- def restore_workspace(self, state: dict[str, str]):
- for path, content in state.items():
- try:
- with open(path, "w", encoding="utf-8") as f:
- f.write(content)
- except Exception as e:
- logger.error(f"Failed to restore {path}: {e}")
- logger.warning("Workspace restored to safety due to unfixable AI errors.")
-
- def load_memory(self) -> str:
- """Loads persistent memory and injects repo-level human directives."""
- memory_content = ""
-
- if os.path.exists(self.memory_path):
- try:
- with open(self.memory_path, "r", encoding="utf-8") as f:
- memory_content = f.read().strip()
- except Exception:
- pass
-
- directives_path = os.path.join(self.target_dir, "DIRECTIVES.md")
- if os.path.exists(directives_path):
- try:
- with open(directives_path, "r", encoding="utf-8") as f:
- human_orders = f.read().strip()
- if human_orders:
- logger.info(
- f"🎯 BEACON ACTIVE: Injected {len(human_orders.splitlines())} lines from DIRECTIVES.md"
- )
-
- memory_content = (
- f"# CRITICAL HUMAN DIRECTIVES - PRIORITY 1\n"
- f"{human_orders}\n"
- f"\n# END CRITICAL DIRECTIVES\n"
- f"---\n{memory_content}"
- )
- except Exception as e:
- logger.warning(f"Could not read DIRECTIVES.md: {e}")
-
- return memory_content
-
- def get_valid_llm_response(
- self, prompt: str, validator: Callable[[str], bool], context: str = ""
- ) -> str:
- """Wrapper that ensures key rotation is used for all requests."""
- return str(
- get_valid_llm_response_engine(
- prompt, validator, self.key_cooldowns, context
- )
- )
-
- def _find_entry_file(self) -> str | None:
- priority_files = [
- "entrance.py",
- "main.py",
- "app.py",
- "gui.py",
- "pyob_launcher.py",
- "package.json",
- "server.js",
- "app.js",
- "index.html",
- ]
-
- for f_name in priority_files:
- target = os.path.join(self.target_dir, f_name)
- if os.path.exists(target):
- if (
- f_name == "package.json"
- or f_name.endswith(".html")
- or f_name.endswith(".htm")
- ):
- return target
-
- try:
- with open(target, "r", encoding="utf-8", errors="ignore") as f:
- content = f.read()
- if target.endswith(".py"):
- if (
- 'if __name__ == "__main__":' in content
- or "if __name__ == '__main__':" in content
- ):
- return target
- if target.endswith(".js") and len(content.strip()) > 10:
- return target
- except Exception:
- continue
-
- html_fallback = None
- for root, dirs, files in os.walk(self.target_dir):
- dirs[:] = [
- d for d in dirs if d not in IGNORE_DIRS and not d.startswith(".")
- ]
-
- for file in files:
- if file in IGNORE_FILES:
- continue
-
- file_path = os.path.join(root, file)
-
- if file.endswith(".py"):
- try:
- with open(
- file_path, "r", encoding="utf-8", errors="ignore"
- ) as f_obj:
- content = f_obj.read()
- if (
- 'if __name__ == "__main__":' in content
- or "if __name__ == '__main__':" in content
- ):
- return file_path
- except Exception:
- continue
-
- if file.endswith(".html") and not html_fallback:
- html_fallback = file_path
-
- python_entry_points = []
- other_script_entry_points = []
- html_entry_points = []
-
- for root, dirs, files in os.walk(self.target_dir):
- dirs[:] = [
- d for d in dirs if d not in IGNORE_DIRS and not d.startswith(".")
- ]
-
- for file in files:
- if file in IGNORE_FILES:
- continue
-
- file_path = os.path.join(root, file)
-
- if file.endswith(".py"):
- try:
- with open(
- file_path, "r", encoding="utf-8", errors="ignore"
- ) as f_obj:
- content = f_obj.read()
- if (
- 'if __name__ == "__main__":' in content
- or "if __name__ == '__main__':" in content
- ):
- python_entry_points.append(file_path)
- except Exception:
- continue
- elif file.endswith((".js", ".ts", ".sh")):
- other_script_entry_points.append(file_path)
- elif file.endswith((".html", ".htm")):
- html_entry_points.append(file_path)
- elif file == "package.json":
- html_entry_points.append(
- file_path
- ) # Treat package.json as a fallback similar to HTML
-
- if python_entry_points:
- # Prioritize common names if multiple python entry points are found
- for p_file in priority_files:
- for entry in python_entry_points:
- if entry.endswith(p_file):
- return entry
- return python_entry_points[
- 0
- ] # Fallback to first found if no priority match
-
- if other_script_entry_points:
- # Prioritize common names if multiple script entry points are found
- for p_file in priority_files:
- for entry in other_script_entry_points:
- if entry.endswith(p_file):
- return entry
- return other_script_entry_points[0]
-
- if html_entry_points:
- # Prioritize common names if multiple html/package.json entry points are found
- for p_file in priority_files:
- for entry in html_entry_points:
- if entry.endswith(p_file):
- return entry
- return html_entry_points[0]
-
- return None
diff --git a/src/pyob/dashboard_html.py b/src/pyob/dashboard_html.py
deleted file mode 100644
index e576615..0000000
--- a/src/pyob/dashboard_html.py
+++ /dev/null
@@ -1,438 +0,0 @@
-OBSERVER_HTML = """
-
-
-
-
-
- PyOB // ARCHITECT HUD
-
-
-
-
-
-
-
- PyOB // Evolution Engine
- READY
-
-
-
-
Iteration--
-
Symbolic Ledger--
-
Pending Cascades--
-
-
-
-
Interactive Visualization
-
-
-
-
Logic Memory (MEMORY.md)
-
-
-
-
-
System Logs (HISTORY.md)
-
No history yet.
-
-
-
Architectural Analysis
-
-
Scanning structure...
-
-
-
Pending Patch Reviews
-
No pending patches.
-
-
-
Manual Override
-
-
-
-
-
Manual Cascade Injection
-
-
-
-
-
-
-
-
-
-
-
-
-"""
diff --git a/src/pyob/dashboard_server.py b/src/pyob/dashboard_server.py
deleted file mode 100644
index aded7d3..0000000
--- a/src/pyob/dashboard_server.py
+++ /dev/null
@@ -1,234 +0,0 @@
-import json
-import logging
-import os
-import signal
-import sys
-import threading
-from datetime import datetime
-
-from flask import Flask, jsonify, render_template, request
-
-from pyob.data_parser import DataParser
-
-app = Flask(__name__)
-
-logger = logging.getLogger(__name__)
-status_lock = threading.Lock() # Initialize a lock for issue_statuses.json
-decision_lock = threading.Lock() # Initialize a lock for proposal_decisions.json
-data_parser_instance = DataParser() # Initialize DataParser once globally
-
-
-@app.route("/")
-def index():
- return render_template("index.html")
-
-
-@app.route("/analysis")
-def analysis():
- try:
- analysis_content = read_file("ANALYSIS.md")
- return jsonify({"success": True, "data": analysis_content})
- except FileNotFoundError:
- return jsonify({"success": False, "message": "Analysis not available"}), 404
- except UnicodeDecodeError:
- return jsonify(
- {
- "success": False,
- "message": "Error reading analysis content due to encoding issue",
- }
- ), 500
-
-
-@app.route("/history")
-def history():
- try:
- history_content = read_file("HISTORY.md")
- return jsonify({"success": True, "data": history_content})
- except FileNotFoundError:
- return jsonify({"success": False, "message": "History not available"}), 404
- except UnicodeDecodeError:
- return jsonify(
- {
- "success": False,
- "message": "Error reading history content due to encoding issue",
- }
- ), 500
-
-
-@app.route("/api/analysis/issues//acknowledge", methods=["POST"])
-def acknowledge_issue(issue_id):
- """
- API endpoint to acknowledge a specific analysis issue.
- The status is stored in a simple JSON file.
- """
- try:
- status_file = "issue_statuses.json"
- issue_statuses = {}
- with status_lock: # Acquire lock for the entire read-modify-write operation
- if os.path.exists(status_file):
- with open(status_file, "r", encoding="utf-8") as f:
- issue_statuses = json.load(f)
-
- # Update status for the given issue_id
- issue_statuses[issue_id] = {
- "status": "acknowledged",
- "timestamp": datetime.now().isoformat(),
- }
- with open(status_file, "w", encoding="utf-8") as f:
- json.dump(issue_statuses, f, indent=4)
-
- logger.info(f"Issue {issue_id} acknowledged by user.")
- return jsonify({"success": True, "message": f"Issue {issue_id} acknowledged."})
- except (OSError, json.JSONDecodeError) as e:
- logger.error(f"Error acknowledging issue {issue_id}: {e}")
- return jsonify(
- {"success": False, "message": "Failed to acknowledge issue."}
- ), 500
-
-
-@app.route("/api/decision/", methods=["GET", "POST"])
-def handle_proposal_decision(session_id):
- """
- API endpoint to handle and retrieve decisions for a given session_id.
- - POST: User makes a decision (PROCEED/SKIP/DELETE) for a proposal.
- Expects JSON body: {"action": "PROCEED" | "SKIP" | "DELETE"}
- - GET: entrance.py polls for the decision for a specific session_id.
- Returns: {"success": True, "decision": {"status": "pending" | "PROCEED" | "SKIP" | "DELETE", "timestamp": "..."}}
- """
- decision_file = "proposal_decisions.json"
-
- with decision_lock: # Acquire lock for the entire read-modify-write operation
- decisions = {}
- if os.path.exists(decision_file):
- try:
- with open(decision_file, "r", encoding="utf-8") as f:
- decisions = json.load(f)
- except json.JSONDecodeError:
- logger.warning(
- f"Could not decode {decision_file}, starting fresh for proposal decisions."
- )
- decisions = {}
-
- if request.method == "POST":
- data = request.get_json()
- action = data.get("action")
- if not action or action not in ["PROCEED", "SKIP", "DELETE"]:
- return jsonify(
- {
- "success": False,
- "message": "Invalid or missing 'action' in request body.",
- }
- ), 400
-
- decisions[session_id] = {
- "status": action,
- "timestamp": datetime.now().isoformat(),
- }
- with open(decision_file, "w", encoding="utf-8") as f:
- json.dump(decisions, f, indent=4)
-
- logger.info(f"Decision '{action}' recorded for session {session_id}.")
- return jsonify(
- {
- "success": True,
- "message": f"Decision '{action}' recorded for session {session_id}.",
- }
- )
-
- elif request.method == "GET":
- # Return the current status for the session_id, default to "pending"
- decision_status = decisions.get(session_id, {"status": "pending"})
- return jsonify({"success": True, "decision": decision_status})
-
-
-@app.route("/api/analysis-data")
-def api_analysis_data():
- """
- Returns parsed analysis data, enriched with acknowledgment statuses.
- Assumes DataParser provides unique 'id' for each issue.
- """
- try:
- analysis_content = read_file("ANALYSIS.md")
- parsed_data = data_parser_instance.parse_analysis_content(analysis_content)
-
- issue_statuses = {}
- status_file = "issue_statuses.json"
- with status_lock: # Acquire lock before reading shared resource
- if os.path.exists(status_file):
- with open(status_file, "r", encoding="utf-8") as f:
- issue_statuses = json.load(f)
-
- # Merge statuses into parsed_data.
- # This logic assumes parsed_data is a dictionary with an 'issues' key,
- # where 'issues' is a list of dictionaries, each with an 'id'.
- # Adjust if DataParser returns a different structure.
- if isinstance(parsed_data, dict) and "issues" in parsed_data:
- for issue in parsed_data.get("issues", []):
- issue_id = issue.get("id") # DataParser must provide unique IDs
- if issue_id and issue_id in issue_statuses:
- issue["status"] = issue_statuses[issue_id]["status"]
- issue["acknowledged_at"] = issue_statuses[issue_id]["timestamp"]
- else:
- issue["status"] = "new" # Default status for unacknowledged issues
- elif isinstance(
- parsed_data, list
- ): # Fallback if DataParser returns a list directly
- for issue in parsed_data:
- issue_id = issue.get("id")
- if issue_id and issue_id in issue_statuses:
- issue["status"] = issue_statuses[issue_id]["status"]
- issue["acknowledged_at"] = issue_statuses[issue_id]["timestamp"]
- else:
- issue["status"] = "new"
-
- return jsonify(parsed_data)
- except FileNotFoundError:
- return jsonify(
- {"success": False, "message": "Analysis data not available"}
- ), 404
- except UnicodeDecodeError:
- return jsonify(
- {
- "success": False,
- "message": "Error parsing analysis content due to encoding issue",
- }
- ), 500
- except Exception as e:
- logger.error(f"Error processing analysis data: {e}")
- return jsonify(
- {"success": False, "message": f"Error processing analysis data: {e}"}
- ), 500
-
-
-@app.route("/api/history-data")
-def api_history_data():
- try:
- history_content = read_file("HISTORY.md")
- parsed_data = data_parser_instance.parse_history_content(history_content)
- return jsonify(parsed_data)
- except FileNotFoundError:
- return jsonify({"success": False, "message": "History data not available"}), 404
-
-
-def read_file(filename):
- with open(filename, "r", encoding="utf-8") as f:
- return f.read()
-
-
-def run_server():
- logger.info("Starting Flask server...")
- # Use an environment variable to control debug mode for safety
- debug_mode = os.environ.get("FLASK_DEBUG", "False").lower() == "true"
- app.run(debug=debug_mode, use_reloader=False)
-
-
-if __name__ == "__main__":
- # Cleanup Flask server before exit
- def cleanup(signum, frame):
- logger.info("Shutting down Flask server...")
- sys.exit(0)
-
- signal.signal(signal.SIGTERM, cleanup)
- signal.signal(signal.SIGINT, cleanup)
-
- run_server()
diff --git a/src/pyob/data_parser.py b/src/pyob/data_parser.py
deleted file mode 100644
index e40eaca..0000000
--- a/src/pyob/data_parser.py
+++ /dev/null
@@ -1,38 +0,0 @@
-import re
-
-
-class DataParser:
- def parse_analysis_content(self, content: str) -> dict:
- """
- Safely parses numeric stats from analysis content while stripping CSS units
- to prevent 'invalid decimal literal' errors.
- """
- data = []
- for line in content.splitlines():
- # Skip complex CSS calc() functions that confuse the parser
- if "calc(" in line:
- continue
-
- # This regex captures the label and the number, then ignores optional units
- match = re.search(r"(\w+)\s*:\s*([\d\.]+)(?:px|em|rem|%|s)?", line)
- if match:
- key = match.group(1)
- value_str = match.group(2)
- try:
- # Convert to float if there is a decimal, otherwise int
- value = float(value_str) if "." in value_str else int(value_str)
- data.append({"key": key, "value": value})
- except ValueError:
- # If conversion fails for any reason, skip this line safely
- continue
- return {"data": data}
-
- def parse_history_content(self, content: str) -> dict:
- """Parses event history into structured data."""
- data = []
- for line in content.splitlines():
- # Matches 'EventName: YYYY-MM-DD'
- match = re.search(r"(\w+): (\d{4}-\d{2}-\d{2})", line)
- if match:
- data.append({"event": match.group(1), "date": match.group(2)})
- return {"data": data}
diff --git a/src/pyob/entrance.py b/src/pyob/entrance.py
deleted file mode 100644
index 6858fd6..0000000
--- a/src/pyob/entrance.py
+++ /dev/null
@@ -1,547 +0,0 @@
-import ast
-import atexit
-import difflib
-import json
-import logging
-import os
-import re
-import shutil
-import subprocess
-import sys
-import time
-from subprocess import DEVNULL
-from typing import Optional
-
-_current_file_dir = os.path.dirname(os.path.abspath(__file__))
-_pyob_package_root_dir = os.path.dirname(_current_file_dir)
-if _pyob_package_root_dir not in sys.path:
- sys.path.insert(0, _pyob_package_root_dir)
-
-from pyob.autoreviewer import AutoReviewer # noqa: E402
-from pyob.core_utils import CoreUtilsMixin # noqa: E402
-from pyob.entrance_mixins import EntranceMixin # noqa: E402
-from pyob.evolution_mixins import EvolutionMixin # noqa: E402
-from pyob.pyob_code_parser import CodeParser # noqa: E402
-
-logging.basicConfig(level=logging.INFO, format="%(asctime)s | %(message)s")
-logger = logging.getLogger(__name__)
-
-
-def load_config() -> dict:
- """Loads configuration and validates gemini_keys.
-
- As per MEMORY.md, non-empty gemini_keys are required.
- This function checks for the GEMINI_API_KEY environment variable.
- """
- config = {}
- # Define paths to search for config.json, in order of increasing precedence
- config_paths = [
- os.path.join(_current_file_dir, "config.json"),
- os.path.join(_pyob_package_root_dir, "config.json"),
- os.path.join(os.getcwd(), "config.json"),
- ]
-
- # Merge configurations from files
- for path in config_paths:
- if os.path.exists(path):
- try:
- with open(path, "r", encoding="utf-8") as f:
- file_config = json.load(f)
- config.update(file_config) # Merge, later files override earlier
- except json.JSONDecodeError as e:
- logger.warning(f"WARNING: Could not parse config.json at {path}: {e}")
-
- # Environment variable overrides config.json value
- gemini_key_env = os.environ.get("PYOB_GEMINI_KEYS")
- if gemini_key_env:
- config["gemini_api_key"] = gemini_key_env
- # Now, gemini_key should be retrieved from the merged config for validation
- gemini_key = config.get("gemini_api_key")
- if not gemini_key:
- logger.critical(
- "CRITICAL ERROR: GEMINI_API_KEY environment variable is not set or is empty."
- )
- logger.critical(
- "Please set the GEMINI_API_KEY environment variable to proceed."
- )
- sys.exit(1)
- return config
-
-
-class EntranceController(EntranceMixin, CoreUtilsMixin, EvolutionMixin):
- ENGINE_FILES = [
- "autoreviewer.py",
- "cascade_queue_handler.py",
- "core_utils.py",
- "dashboard_html.py",
- "dashboard_server.py",
- "data_parser.py",
- "entrance.py",
- "entrance_mixins.py",
- "evolution_mixins.py",
- "feature_mixins.py",
- "models.py",
- "prompts_and_memory.py",
- "pyob_code_parser.py",
- "pyob_dashboard.py",
- "pyob_launcher.py",
- "reviewer_mixins.py",
- "scanner_mixins.py",
- "get_valid_edit.py",
- "stats_updater.py",
- "targeted_reviewer.py",
- "xml_mixin.py",
- ]
-
- def __init__(self, target_dir: str, dashboard_active: bool = True):
- self.target_dir = os.path.abspath(target_dir)
- self.pyob_dir = os.path.join(self.target_dir, ".pyob")
- os.makedirs(self.pyob_dir, exist_ok=True)
-
- from pyob.core_utils import GEMINI_API_KEYS
-
- self.key_cooldowns: dict[str, float] = {
- key: 0.0 for key in GEMINI_API_KEYS if key.strip()
- }
-
- self.skip_dashboard = ("--no-dashboard" in sys.argv) or (not dashboard_active)
-
- self.analysis_path = os.path.join(self.pyob_dir, "ANALYSIS.md")
- self.history_path = os.path.join(self.pyob_dir, "HISTORY.md")
- self.symbols_path = os.path.join(self.pyob_dir, "SYMBOLS.json")
- self.memory_path = os.path.join(self.pyob_dir, "MEMORY.md")
- self.llm_engine = AutoReviewer(self.target_dir)
- self.code_parser = CodeParser()
- self.ledger = self.load_ledger()
- self.cascade_queue: list[str] = []
- self.cascade_diffs: dict[str, str] = {}
- self.self_evolved_flag: bool = False
- self.manual_target_file: Optional[str] = None
- self.dashboard_process: Optional[subprocess.Popen] = None
- self.session_pr_count = 0
- self.current_iteration = 1
-
- if not self.skip_dashboard:
- logger.info(
- "Dashboard active: Initializing with EntranceController instance."
- )
- self.start_dashboard()
-
- def set_manual_target_file(self, file_path: str) -> tuple[bool, str]:
- """Sets a file path to be targeted in the next iteration, overriding LLM choice."""
- abs_path = os.path.join(self.target_dir, file_path)
- if os.path.exists(abs_path):
- self.manual_target_file = file_path
- logger.info(f"Manual target set for next iteration: {file_path}")
- return True, f"Manual target set for next iteration: {file_path}"
- else:
- logger.warning(f"Manual target file not found: {file_path}")
- return False, f"Error: File not found at path: {file_path}"
-
- def start_dashboard(self):
- """Starts the Flask dashboard server in a separate process."""
- logger.info("Starting PyOB Dashboard server...")
- try:
- env = os.environ.copy()
- env["PYOB_DIR"] = self.pyob_dir # Pass the .pyob directory path
- self.dashboard_process = subprocess.Popen(
- [sys.executable, "-m", "pyob.dashboard_server"],
- cwd=self.target_dir,
- stdout=DEVNULL,
- stderr=DEVNULL,
- text=True,
- env=env,
- )
- logger.info(
- f"Dashboard server started with PID: {self.dashboard_process.pid}"
- )
- atexit.register(self._terminate_dashboard_process)
- except Exception as e:
- logger.error(f"Failed to start dashboard server: {e}")
- self.dashboard_process = None
-
- def _terminate_dashboard_process(self):
- """Terminates the dashboard server process if it's running."""
- if self.dashboard_process and self.dashboard_process.poll() is None:
- logger.info("Terminating PyOB Dashboard server...")
- self.dashboard_process.terminate()
- try:
- self.dashboard_process.wait(timeout=5)
- except subprocess.TimeoutExpired:
- self.dashboard_process.kill()
- logger.warning("Dashboard server did not terminate gracefully, killed.")
- logger.info("PyOB Dashboard server terminated.")
-
- def sync_with_remote(self) -> bool:
- """Fetches remote updates and merges main if we are behind."""
- if not os.path.exists(os.path.join(self.target_dir, ".git")):
- return False
-
- logger.info("Checking for remote updates from main...")
- self._run_git_command(["git", "fetch", "origin"])
-
- result = subprocess.run(
- ["git", "rev-list", "--count", "HEAD..origin/main"],
- cwd=self.target_dir,
- capture_output=True,
- text=True,
- )
-
- commits_behind = int(result.stdout.strip() or 0)
-
- if commits_behind > 0:
- logger.warning(
- f"Project is behind main by {commits_behind} commits. Syncing..."
- )
-
- if self._run_git_command(["git", "merge", "origin/main"]):
- logger.info("Sync complete. Local files updated.")
- return True
- else:
- logger.error(
- "Sync failed (likely a merge conflict). Manual intervention required."
- )
-
- return False
-
- def reboot_pyob(self):
- """Verified Hot-Reboot: Checks for syntax/import errors before restarting."""
- logger.info("PRE-FLIGHT: Verifying engine integrity before reboot...")
-
- test_cmd = [sys.executable, "-c", "import pyob.entrance; print('SUCCESS')"]
- env = os.environ.copy()
- current_pythonpath_list = env.get("PYTHONPATH", "").split(os.pathsep)
- if _pyob_package_root_dir not in current_pythonpath_list:
- if env.get("PYTHONPATH"):
- env["PYTHONPATH"] = (
- f"{_pyob_package_root_dir}{os.pathsep}{env['PYTHONPATH']}"
- )
- else:
- env["PYTHONPATH"] = _pyob_package_root_dir
-
- try:
- result = subprocess.run(test_cmd, capture_output=True, text=True, env=env)
- if "SUCCESS" in result.stdout:
- logger.warning(
- "SELF-EVOLUTION COMPLETE: Rebooting fresh PYOB engine..."
- )
- os.execv(
- sys.executable,
- [sys.executable, "-m", "pyob.pyob_launcher", self.target_dir],
- )
- else:
- logger.error(
- f"REBOOT ABORTED: The evolved code has import/syntax errors:\n{result.stderr}"
- )
- self.self_evolved_flag = False
- except Exception as e:
- logger.error(f"Pre-flight check failed: {e}")
- self.self_evolved_flag = False
-
- def trigger_production_build(self):
- """Advanced Build: Compiles PYOB into a DMG and replaces the system version."""
- build_script = os.path.join(self.target_dir, "build_pyinstaller_multiOS.py")
- if not os.path.exists(build_script):
- logger.error("Build script not found. Skipping production deploy.")
- return
-
- logger.info("STARTING PRODUCTION BUILD... This will take 2-3 minutes.")
- try:
- subprocess.run([sys.executable, build_script], check=True)
-
- app_name = "PyOuroBoros.app"
- dist_path = os.path.join(self.target_dir, "dist", app_name)
- applications_path = f"/Applications/{app_name}"
-
- if sys.platform == "darwin" and os.path.exists(dist_path):
- logger.warning(
- f"FORGE COMPLETE: Deploying new version to {applications_path}..."
- )
-
- if os.path.exists(applications_path):
- shutil.rmtree(applications_path)
- shutil.copytree(dist_path, applications_path)
-
- subprocess.Popen(
- ["open", "-a", applications_path, "--args", self.target_dir]
- )
-
- logger.info("NEW VERSION RELAYED. ENGINE SHUTTING DOWN.")
- sys.exit(0)
-
- except Exception as e:
- logger.error(f"Production Build Failed: {e}")
-
- def load_ledger(self) -> dict:
- """Loads the symbolic ledger from disk with type safety."""
- if os.path.exists(self.symbols_path):
- try:
- with open(self.symbols_path, "r", encoding="utf-8") as f:
- data = json.load(f)
- if isinstance(data, dict):
- return data
- except (FileNotFoundError, json.JSONDecodeError) as e:
- logger.warning(
- f"Failed to load SYMBOLS.json, initializing empty ledger: {e}"
- )
- return {"definitions": {}, "references": {}}
-
- def save_ledger(self):
- """Saves the current symbolic ledger back to disk."""
- with open(self.symbols_path, "w", encoding="utf-8") as f:
- json.dump(self.ledger, f, indent=2)
-
- def run_master_loop(self):
- logger.info(
- "\n" + "=" * 60 + "\nENTRANCE CONTROLLER: SYMBOLIC MODE ACTIVE\n" + "=" * 60
- )
- iteration = 1
- while True:
- self.current_iteration = iteration
- self.self_evolved_flag = False
-
- # Capture Human Directives to prevent AI erasure
- current_mem = self.load_memory()
- directives = ""
- if "# HUMAN DIRECTIVES" in current_mem:
- match = re.search(
- r"(# HUMAN DIRECTIVES.*?)(\n#|\Z)", current_mem, re.DOTALL
- )
- if match:
- directives = match.group(1).strip()
-
- logger.info(
- f"--- REFRESHING SYMBOLIC CONTEXT FOR ITERATION {iteration} ---"
- )
-
- # Clear project map for recursive branch awareness
- if os.path.exists(self.analysis_path):
- os.remove(self.analysis_path)
- if os.path.exists(self.symbols_path):
- os.remove(self.symbols_path)
-
- self.build_initial_analysis()
-
- # Check for remote updates
- if self.sync_with_remote():
- res = subprocess.run(
- ["git", "diff-tree", "--no-commit-id", "--name-only", "-r", "HEAD"],
- cwd=self.target_dir,
- capture_output=True,
- text=True,
- )
- changed_files = res.stdout.strip().splitlines()
- if any(os.path.basename(f) in self.ENGINE_FILES for f in changed_files):
- logger.warning(
- "REMOTE EVOLUTION: Engine files updated via sync. Rebooting..."
- )
- self.self_evolved_flag = True
-
- if self.self_evolved_flag:
- if getattr(sys, "frozen", False):
- logger.warning("COMPILED ENGINE EVOLVED: Initiating Forge Build.")
- self.trigger_production_build()
- else:
- logger.warning(" SCRIPT ENGINE EVOLVED: Initiating Hot-Reboot.")
- self.reboot_pyob()
-
- logger.info(
- f"\n\n{'=' * 70}\nTargeted Pipeline Loop (Iteration {iteration})\n{'=' * 70}"
- )
-
- try:
- # Execute the evolution cycle
- self.execute_targeted_iteration(iteration)
-
- # Restore Human Directives if the AI cleaned the memory file
- if directives:
- post_run_mem = self.load_memory()
- if directives not in post_run_mem:
- logger.warning(
- "AI wiped Human Directives. Restoring memory integrity..."
- )
- with open(self.memory_path, "w", encoding="utf-8") as f:
- f.write(directives + "\n\n" + post_run_mem)
-
- # --- THE WRAP-UP GATE ---
- # session_pr_count is incremented in handle_git_librarian.
- # If we hit the goal and have no pending cross-file tasks, we sign off.
- if getattr(self, "session_pr_count", 0) >= 8 and not getattr(
- self, "cascade_queue", []
- ):
- logger.info(
- f"🏆 SESSION COMPLETE: {self.session_pr_count} PRs achieved with no pending cascades."
- )
- if hasattr(self, "wrap_up_evolution_session"):
- self.wrap_up_evolution_session()
- break # Graceful exit from the 6-hour loop
- # ------------------------
-
- except KeyboardInterrupt:
- logger.info("\nExiting Entrance Controller...")
- break
- except Exception as e:
- logger.error(f"Unexpected error in master loop: {e}", exc_info=True)
-
- iteration += 1
- logger.info("Iteration complete. Waiting for system cooldown...")
- time.sleep(120)
-
- def _extract_path_from_llm_response(self, text: str) -> str:
- """Extracts a clean relative file path from a conversational LLM response."""
- cleaned_text = re.sub(r"[`\"*]", "", text).strip()
-
- matches = re.findall(r"[\w\.\-/]+\.(?:py|js|ts|html|css|json|md)", cleaned_text)
-
- base_dir = self.target_dir
-
- for match in matches:
- clean_match = match.rstrip(".,;:'\")")
- if os.path.exists(os.path.join(base_dir, clean_match)):
- return str(clean_match)
-
- if " " in cleaned_text:
- parts = cleaned_text.split()
- for part in parts:
- clean_part = str(part).rstrip(".,;:'\")")
- if "/" in clean_part or clean_part.endswith(
- (".py", ".js", ".ts", ".html", ".css", ".json", ".md")
- ):
- abs_path = os.path.join(base_dir, clean_part)
- if os.path.exists(abs_path):
- return str(clean_part)
- return str(parts[0].rstrip(".,;:'\")"))
-
- return str(cleaned_text)
-
- def _run_git_command(self, cmd: list[str]) -> bool:
- """Helper to run git commands safely."""
- try:
- result = subprocess.run(
- cmd, cwd=self.target_dir, capture_output=True, text=True
- )
- if result.returncode != 0:
- logger.warning(
- f"Git Command Failed: {' '.join(cmd)}\nSTDOUT: {result.stdout}\nSTDERR: {result.stderr}"
- )
- return False
- return True
- except Exception as e:
- logger.error(f"Git Execution Error: {e}")
- return False
-
- def detect_symbolic_ripples(
- self, old: str, new: str, source_file: str
- ) -> list[str]:
- diff = list(difflib.unified_diff(old.splitlines(), new.splitlines()))
- changed_text = "\n".join(
- [line for line in diff if line.startswith("+") or line.startswith("-")]
- )
- potential_symbols = set(re.findall(r"\b[a-zA-Z_][a-zA-Z0-9_]*\b", changed_text))
- impacted_files = []
- for sym in potential_symbols:
- if self.ledger["definitions"].get(sym) == source_file:
- for target_file, refs in self.ledger["references"].items():
- if sym in refs and target_file != source_file:
- impacted_files.append(target_file)
- return list(set(impacted_files))
-
- def update_analysis_for_single_file(self, target_abs_path: str, rel_path: str):
- if not os.path.exists(self.analysis_path):
- return
- with open(target_abs_path, "r", encoding="utf-8", errors="ignore") as f:
- code = f.read()
- structure = self.code_parser.generate_structure_dropdowns(target_abs_path, code)
- sum_prompt = f"Provide a one-sentence plain text summary of what the file `{rel_path}` does. \n\nStructure:\n{structure}"
- desc = self.get_valid_llm_response(
- sum_prompt, lambda t: "" not in t and len(t) > 5, context=rel_path
- ).strip()
- new_block = f"### `{rel_path}`\n**Summary:** {desc}\n\n" + structure + "\n---\n"
- with open(self.analysis_path, "r", encoding="utf-8") as f:
- analysis_text = f.read()
- pattern = rf"### `{re.escape(rel_path)}`.*?(?=### `|---\n\Z)"
- updated_text, num_subs = re.subn(
- pattern, new_block, analysis_text, flags=re.DOTALL
- )
-
- if num_subs == 0:
- # If no existing block was found, append the new block
- updated_text = analysis_text + new_block
-
- with open(self.analysis_path, "w", encoding="utf-8") as f:
- f.write(updated_text)
-
- def update_ledger_for_file(self, rel_path: str, code: str):
- ext = os.path.splitext(rel_path)[1]
- definitions_to_remove = [
- name
- for name, path in self.ledger["definitions"].items()
- if path == rel_path
- ]
- for name in definitions_to_remove:
- del self.ledger["definitions"][name]
-
- if ext == ".py":
- try:
- tree = ast.parse(code)
- for n in ast.walk(tree):
- if isinstance(n, (ast.FunctionDef, ast.ClassDef)):
- self.ledger["definitions"][n.name] = rel_path
- elif isinstance(n, ast.Assign):
- for target in n.targets:
- if isinstance(target, ast.Name) and target.id.isupper():
- self.ledger["definitions"][target.id] = rel_path
- except Exception as e:
- logger.warning(f"Failed to parse Python AST for {rel_path}: {e}")
- elif ext in [".js", ".ts"]:
- defs = re.findall(
- r"(?:export\s+|async\s+)?(?:function\*?|class|const|var|let)\s+([a-zA-Z0-9_$]+)",
- code,
- )
- for d in defs:
- if len(d) > 3:
- self.ledger["definitions"][d] = rel_path
-
- if rel_path in self.ledger["references"]:
- del self.ledger["references"][rel_path]
-
- potential_refs = re.findall(r"\b[a-zA-Z_][a-zA-Z0-9_]{3,}\b", code)
- self.ledger["references"][rel_path] = list(set(potential_refs))
- self.save_ledger()
-
- def append_to_history(self, rel_path: str, old_code: str, new_code: str):
- diff_lines = list(
- difflib.unified_diff(
- old_code.splitlines(keepends=True),
- new_code.splitlines(keepends=True),
- fromfile="Original",
- tofile="Proposed",
- )
- )
- if not diff_lines:
- return
- summary_diff = (
- "".join(diff_lines[:5])
- + "\n... [TRUNCATED] ...\n"
- + "".join(diff_lines[-5:])
- if len(diff_lines) > 20
- else "".join(diff_lines)
- )
- timestamp = time.strftime("%Y-%m-%d %H:%M:%S")
- with open(self.history_path, "a", encoding="utf-8") as f:
- f.write(
- f"\n## {timestamp} - `{rel_path}`\n```diff\n{summary_diff}\n```\n---\n"
- )
-
- def _read_file(self, f: str) -> str:
- if os.path.exists(f):
- try:
- with open(f, "r", encoding="utf-8", errors="ignore") as f_obj:
- return f_obj.read()
- except Exception:
- return ""
- return ""
-
-
-if __name__ == "__main__":
- EntranceController(sys.argv[1] if len(sys.argv) > 1 else ".").run_master_loop()
diff --git a/src/pyob/entrance_mixins.py b/src/pyob/entrance_mixins.py
deleted file mode 100644
index ab12d08..0000000
--- a/src/pyob/entrance_mixins.py
+++ /dev/null
@@ -1,174 +0,0 @@
-import difflib
-import logging
-import os
-import shutil
-import time
-from pathlib import Path
-from typing import Any, Callable, Optional
-
-logger = logging.getLogger(__name__)
-
-
-class EntranceMixin:
- """
- Mixin providing core iteration logic.
- Attributes are declared here to satisfy strict Mypy checks.
- """
-
- target_dir: str
- pyob_dir: str
- ENGINE_FILES: list[str]
- llm_engine: Any
- code_parser: Any
- cascade_queue: list[str]
- cascade_diffs: dict[str, str]
- session_pr_count: int
- self_evolved_flag: bool
- memory_path: str
- history_path: str
- analysis_path: str
- symbols_path: str
- manual_target_file: Optional[str]
- key_cooldowns: dict[str, float]
-
- def pick_target_file(self) -> str:
- return ""
-
- def _read_file(self, path: str) -> str:
- return ""
-
- def _extract_path_from_llm_response(self, text: str) -> str:
- return ""
-
- def get_valid_llm_response(
- self, p: str, v: Callable[[str], bool], context: str
- ) -> str:
- return ""
-
- def update_analysis_for_single_file(self, abs_p: str, rel_p: str):
- pass
-
- def update_ledger_for_file(self, rel_p: str, code: str):
- pass
-
- def detect_symbolic_ripples(self, o: str, n: str, p: str) -> list[str]:
- return []
-
- def _run_final_verification_and_heal(self, b: dict) -> bool:
- return False
-
- def handle_git_librarian(self, p: str, i: int):
- pass
-
- def append_to_history(self, p: str, o: str, n: str):
- pass
-
- def wrap_up_evolution_session(self):
- pass
-
- def generate_pr_summary(self, rel_path: str, diff_text: str) -> dict:
- return {}
-
- def execute_targeted_iteration(self, iteration: int):
- """Orchestrates a single targeted evolution step."""
- backup_state = self.llm_engine.backup_workspace()
- target_diff = ""
-
- if self.cascade_queue:
- target_rel_path = self.cascade_queue.pop(0)
- target_diff = self.cascade_diffs.get(target_rel_path, "")
- logger.warning(
- f"SYMBOLIC CASCADE: Targeting impacted file: {target_rel_path}"
- )
- is_cascade = True
- else:
- target_rel_path = self.pick_target_file()
- is_cascade = False
-
- if not target_rel_path:
- return
-
- is_engine_file = any(Path(target_rel_path).name == f for f in self.ENGINE_FILES)
-
- if is_engine_file:
- timestamp = time.strftime("%Y%m%d_%H%M%S")
- project_name = os.path.basename(self.target_dir)
- base_backup_path = Path.home() / "Documents" / "PYOB_Backups" / project_name
- pod_path = base_backup_path / f"safety_pod_v{iteration}_{timestamp}"
- try:
- pod_path.mkdir(parents=True, exist_ok=True)
- for f_name in self.ENGINE_FILES:
- src = os.path.join(self.target_dir, "src", "pyob", f_name)
- if os.path.exists(src):
- shutil.copy(src, str(pod_path))
- except Exception as e:
- logger.error(f"Failed to create safety pod: {e}")
-
- target_abs_path = os.path.join(self.target_dir, target_rel_path)
- self.llm_engine.session_context = []
- if is_cascade and target_diff:
- msg = f"CRITICAL SYMBOLIC RIPPLE: This file depends on code that was just modified.\n### CHANGE DIFF:\n{target_diff}"
- self.llm_engine.session_context.append(msg)
-
- old_content = ""
- if os.path.exists(target_abs_path):
- with open(target_abs_path, "r", encoding="utf-8", errors="ignore") as f:
- old_content = f.read()
-
- from pyob.targeted_reviewer import TargetedReviewer
-
- reviewer = TargetedReviewer(self.target_dir, target_abs_path)
- reviewer.session_context = self.llm_engine.session_context[:]
- if hasattr(self, "key_cooldowns"):
- reviewer.key_cooldowns = self.key_cooldowns
- if hasattr(self, "session_pr_count"):
- reviewer.session_pr_count = self.session_pr_count
-
- reviewer.run_pipeline(iteration)
-
- self.llm_engine.session_context = reviewer.session_context[:]
- if hasattr(reviewer, "session_pr_count"):
- self.session_pr_count = reviewer.session_pr_count
-
- new_content = ""
- if os.path.exists(target_abs_path):
- with open(target_abs_path, "r", encoding="utf-8", errors="ignore") as f:
- new_content = f.read()
-
- logger.info(f"Refreshing metadata for `{target_rel_path}`...")
- self.update_analysis_for_single_file(target_abs_path, target_rel_path)
- self.update_ledger_for_file(target_rel_path, new_content)
-
- if old_content != new_content:
- logger.info(f"Edit successful. Verifying {target_rel_path}...")
- self.append_to_history(target_rel_path, old_content, new_content)
- current_diff = "".join(
- difflib.unified_diff(
- old_content.splitlines(keepends=True),
- new_content.splitlines(keepends=True),
- )
- )
- ripples = self.detect_symbolic_ripples(
- old_content, new_content, target_rel_path
- )
- if ripples:
- for r in ripples:
- if r not in self.cascade_queue:
- self.cascade_queue.append(r)
- self.cascade_diffs[r] = current_diff
-
- if not self._run_final_verification_and_heal(backup_state):
- logger.error("Final verification failed. Changes rolled back.")
- else:
- self.handle_git_librarian(target_rel_path, iteration)
- if is_engine_file:
- self.self_evolved_flag = True
-
- # --- THE FINAL WRAP-UP GATE ---
- if getattr(self, "session_pr_count", 0) >= 8 and not getattr(
- self, "cascade_queue", []
- ):
- logger.info(
- f"🏆 MISSION ACCOMPLISHED: {self.session_pr_count} PRs achieved."
- )
- self.wrap_up_evolution_session()
diff --git a/src/pyob/evolution_mixins.py b/src/pyob/evolution_mixins.py
deleted file mode 100644
index ba0d0d6..0000000
--- a/src/pyob/evolution_mixins.py
+++ /dev/null
@@ -1,321 +0,0 @@
-import os
-import re
-import shutil
-import subprocess
-import sys
-import time
-from typing import Any, Optional
-
-from .core_utils import logger
-
-
-class EvolutionMixin:
- """Methods for project analysis, verification, and git librarian duties."""
-
- target_dir: str
- analysis_path: str
- history_path: str
- symbols_path: str
- llm_engine: Any
- code_parser: Any
- ledger: dict
- key_cooldowns: dict
- manual_target_file: Optional[str]
-
- def handle_git_librarian(self, rel_path: str, iteration: int):
- """Creates a branch, generates an AI summary, and opens a professional PR."""
- if not os.path.exists(os.path.join(self.target_dir, ".git")):
- return
-
- # 1. Capture the raw diff before committing
- diff_proc = subprocess.run(
- ["git", "diff", "HEAD", rel_path],
- cwd=self.target_dir,
- capture_output=True,
- text=True,
- )
- diff_text = diff_proc.stdout or "Minor structural refinement."
-
- # 2. Ask the AI to write the PR summary (inherited from CoreUtilsMixin)
- summary = getattr(self, "generate_pr_summary")(rel_path, diff_text)
- title = summary.get("title", f"Evolution: Refactor of {rel_path}")
- body = summary.get(
- "body",
- f"This PR was automatically generated by PyOB.\n\nFile: `{rel_path}`",
- )
-
- # 3. Git Operations
- timestamp = int(time.time())
- branch_name = f"pyob-evolution-v{iteration}-{timestamp}"
- logger.info(f" LIBRARIAN: Publishing Evolution: {title}")
-
- if not getattr(self, "_run_git_command")(
- ["git", "checkout", "-b", branch_name]
- ):
- return
-
- getattr(self, "_run_git_command")(["git", "add", rel_path])
-
- # Use AI-generated title as commit message
- if not getattr(self, "_run_git_command")(["git", "commit", "-m", title]):
- return
-
- # 4. Push to GitHub and create the PR
- if shutil.which("gh"):
- logger.info("Pushing to GitHub and opening Pull Request...")
- if getattr(self, "_run_git_command")(
- ["git", "push", "origin", branch_name]
- ):
- getattr(self, "_run_git_command")(
- [
- "gh",
- "pr",
- "create",
- "--title",
- title,
- "--body",
- body,
- "--base",
- "main",
- ]
- )
- # --- THE WRAP-UP UPDATE ---
- # Increment the session counter only after successful PR creation
- if hasattr(self, "session_pr_count"):
- self.session_pr_count += 1
- logger.info(
- f"Session Progress: {self.session_pr_count}/8 PRs completed."
- )
- # --------------------------
- else:
- logger.warning("GitHub CLI (gh) not found. Committed locally only.")
-
- def _run_final_verification_and_heal(self, backup_state: dict) -> bool:
- """Runs the project entry file for 10 seconds and auto-fixes crashes."""
- entry_file = self.llm_engine._find_entry_file()
- if not entry_file:
- logger.warning("No main entry file found. Skipping runtime test.")
- return True
-
- rel_entry_file = os.path.relpath(entry_file, self.target_dir)
-
- for attempt in range(3):
- logger.info(f"Launching `{rel_entry_file}` (Attempt {attempt + 1}/3)")
- cmd: list[str] = []
- is_html = entry_file.endswith((".html", ".htm"))
- is_py = entry_file.endswith(".py")
- is_js = entry_file.endswith((".js", "package.json"))
-
- if is_py:
- venv_py = os.path.join(self.target_dir, "build_env", "bin", "python3")
- python_cmd = venv_py if os.path.exists(venv_py) else sys.executable
- cmd = [python_cmd, entry_file]
- elif is_js:
- cmd = (
- ["npm", "start"]
- if entry_file.endswith("package.json")
- else ["node", entry_file]
- )
- elif is_html:
- if os.environ.get("GITHUB_ACTIONS") == "true":
- return True
- if sys.platform == "darwin":
- cmd = ["open", entry_file]
- elif sys.platform == "win32":
- cmd = [f'start "" "{entry_file}"']
- else:
- cmd = ["xdg-open", entry_file]
-
- if not cmd:
- return True
- use_shell = bool(cmd and (cmd[0].startswith("start") or cmd[0] == "open"))
-
- start_time = time.time()
- stdout, stderr = "", "" # Initialize stdout and stderr
- try:
- process = subprocess.Popen(
- cmd,
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
- text=True,
- cwd=self.target_dir,
- shell=use_shell,
- )
- if is_html:
- try:
- stdout, stderr = process.communicate(timeout=5)
- if process.returncode == 0:
- return True # HTML launched and exited cleanly within 5s
- except subprocess.TimeoutExpired:
- process.terminate()
- process.wait()
- return True # HTML launched and ran for 5s, considered success
- # If HTML process exited with non-zero code within 5s,
- # stdout/stderr are already captured, fall through to general error handling.
- else: # For non-HTML processes, use the 10s timeout
- stdout, stderr = process.communicate(timeout=10)
- except Exception as e:
- logger.error(f"Execution failed: {e}")
- stdout, stderr = "", str(e)
-
- duration = time.time() - start_time
- has_error = any(
- kw in stderr or kw in stdout
- for kw in [
- "Traceback",
- "Exception",
- "Error:",
- "ModuleNotFoundError",
- "ImportError",
- ]
- )
- if process.returncode not in (0, 15, -15, None) or has_error:
- logger.warning(f"App crashed after {duration:.1f}s!")
- if attempt < 2:
- self.llm_engine._fix_runtime_errors(
- stderr + "\n" + stdout, entry_file
- )
- else:
- logger.info(f"App ran successfully for {duration:.1f}s.")
- return True
-
- self.llm_engine.restore_workspace(backup_state)
- return False
-
- def pick_target_file(self) -> str:
- """Uses LLM to strategically pick the next file to evolve."""
- # Fix: Cast manual_target_file to Optional[str] via local variable for Mypy
- manual: Optional[str] = getattr(self, "manual_target_file", None)
- if manual:
- setattr(self, "manual_target_file", None)
- return str(manual)
-
- analysis = str(getattr(self, "_read_file")(self.analysis_path))
- history = str(
- getattr(self, "_read_file")(self.history_path) or "No history yet."
- )
- last_file = ""
- for line in reversed(history.strip().split("\n")):
- if line.startswith("## "):
- match = re.search(r"`([^`]+)`", line)
- if match:
- last_file = match.group(1)
- break
-
- prompt = (
- f"Choose ONE relative file path to review next based on ANALYSIS.md/HISTORY.md.\n"
- f"STRATEGIC RULES:\n1. DO NOT pick `{last_file}`.\n"
- f"2. Rotate between logic, UI, and styles.\nOutput ONLY the path.\n\n"
- f"### Analysis:\n{analysis}\n### History:\n{history}"
- )
-
- def val(text: str) -> bool:
- path = getattr(self, "_extract_path_from_llm_response")(text)
- return (
- os.path.exists(os.path.join(self.target_dir, path))
- and path != last_file
- )
-
- response = getattr(self, "get_valid_llm_response")(
- prompt, val, context="Target Selector"
- )
- # Fix: Explicitly return a string
- return str(getattr(self, "_extract_path_from_llm_response")(response))
-
- def build_initial_analysis(self):
- """Bootstraps ANALYSIS.md and SYMBOLS.json."""
- logger.info("ANALYSIS.md not found. Bootstrapping Deep Symbolic Scan...")
- all_files = sorted(self.llm_engine.scan_directory())
- struct_map = "\n".join(os.path.relpath(f, self.target_dir) for f in all_files)
-
- p_summary = getattr(self, "get_valid_llm_response")(
- f"Write a 2-sentence summary of this project: {struct_map}",
- lambda t: len(t) > 5,
- context="Project Genesis",
- ).strip()
- content = f"# Project Analysis\n\n**Project Summary:**\n{p_summary}\n\n-----n\n## File Directory\n\n"
-
- file_structures = {}
- for f_path in all_files:
- rel = os.path.relpath(f_path, self.target_dir)
- with open(f_path, "r", encoding="utf-8", errors="ignore") as f:
- code = f.read()
- self.update_ledger_for_file(rel, code)
- file_structures[rel] = self.code_parser.generate_structure_dropdowns(
- f_path, code
- )
-
- batch_prompt = "Output 'filepath: summary' for each:\n" + "\n".join(
- f"{r}: {s}" for r, s in file_structures.items()
- )
- batch_resp = getattr(self, "get_valid_llm_response")(
- batch_prompt, lambda t: ":" in t, context="Batch Genesis"
- ).strip()
-
- summaries = {
- line.split(":", 1)[0].strip("`* "): line.split(":", 1)[1].strip()
- for line in batch_resp.splitlines()
- if ":" in line
- }
-
- for f_path in all_files:
- rel = os.path.relpath(f_path, self.target_dir)
- summary_text = summaries.get(rel, "No summary.")
- content += f"### `{rel}`\n**Summary:** {summary_text}\n\n{file_structures[rel]}\n---\n"
-
- with open(self.analysis_path, "w", encoding="utf-8") as f:
- f.write(content)
- self.save_ledger()
-
- def wrap_up_evolution_session(self):
- """Generates a master summary of the entire session and opens a final PR."""
- logger.info("🎬 INITIATING WRAP-UP PHASE: Generating session summary...")
-
- history_text = getattr(self, "_read_file")(self.history_path)
-
- prompt = f"""
- Analyze the following evolution history for this session and write a 'Master Session Summary'.
- We have successfully submitted {self.session_pr_count} Pull Requests.
- YOUR TASK:
- Create a Markdown report named 'PR_SUMMARY.md'.
- Include:
- 1. ## Session Overview (High-level goals achieved)
- 2. ## Technical Milestones (List the major features/refactors)
- 3. ## Architectural Impact (How the codebase is healthier now)
- HISTORY:
- {history_text}
- Output ONLY the Markdown content for the file. Use a professional, triumphant tone.
- """
- summary_md = self.get_valid_llm_response(
- prompt, lambda t: len(t) > 100, context="Session Architect"
- )
- # Save the file to root
- summary_path = os.path.join(self.target_dir, "PR_SUMMARY.md")
- with open(summary_path, "w", encoding="utf-8") as f:
- f.write(summary_md)
-
- # Create a final branch for the summary
- branch_name = f"evolution-summary-{int(time.time())}"
- self._run_git_command(["git", "checkout", "-b", branch_name])
- self._run_git_command(["git", "add", "PR_SUMMARY.md"])
- self._run_git_command(
- ["git", "commit", "-m", "Final: Evolution Session Summary Report"]
- )
-
- if shutil.which("gh"):
- self._run_git_command(["git", "push", "origin", branch_name])
- self._run_git_command(
- [
- "gh",
- "pr",
- "create",
- "--title",
- f"🏆 Final Evolution Summary: {self.session_pr_count} PRs Completed",
- "--body",
- "This PR contains the architectural summary for the entire autonomous run. PyOB has completed its assigned tasks and is now entering sleep mode.",
- "--base",
- "main",
- ]
- )
-
- logger.info("✅ Final summary submitted. PyOB is now resting.")
diff --git a/src/pyob/feature_mixins.py b/src/pyob/feature_mixins.py
deleted file mode 100644
index cc13529..0000000
--- a/src/pyob/feature_mixins.py
+++ /dev/null
@@ -1,340 +0,0 @@
-import os
-import re
-
-from .core_utils import (
- FEATURE_FILE_NAME,
- PR_FILE_NAME,
- logger,
-)
-
-
-class FeatureOperationsMixin:
- target_dir: str
- pr_file: str
- feature_file: str
- session_context: list[str]
-
- def write_pr(self, filepath: str, explanation: str, llm_response: str):
- rel_path = os.path.relpath(filepath, self.target_dir)
- mode = "a" if os.path.exists(self.pr_file) else "w"
- with open(self.pr_file, mode, encoding="utf-8") as f:
- if mode == "w":
- f.write(
- "# Autonomous Code Review & Patch Proposals\n\n*Automatically generated by AutoPR Reviewer*\n\n---\n"
- )
- f.write(
- f"\n## Review for `{rel_path}`\n**AI Analysis & Fixes:**\n{explanation}\n\n"
- )
- edits = re.findall(
- r".*?", llm_response, re.DOTALL | re.IGNORECASE
- )
- if edits:
- f.write("### Proposed Patch:\n```xml\n")
- for edit in edits:
- f.write(edit.strip() + "\n\n")
- f.write("```\n")
- f.write("\n---\n")
- logger.info(f"Appended successful fix for {rel_path} to {PR_FILE_NAME}")
-
- def analyze_file(self, filepath: str, current_index: int, total_files: int):
- try:
- with open(filepath, "r", encoding="utf-8") as f:
- lines = f.readlines()
- content = "".join(lines)
- except UnicodeDecodeError:
- return
- lang_name, lang_tag = getattr(self, "get_language_info")(filepath)
- filename = os.path.basename(filepath)
- logger.info(
- f"[{current_index}/{total_files}] Scanning {filename} ({lang_name}) - Reading {len(lines)} lines into AI context..."
- )
- ruff_out, mypy_out, custom_issues = "", "", []
- if lang_tag == "python":
- ruff_out, mypy_out = getattr(self, "run_linters")(filepath)
- custom_issues = getattr(self, "scan_for_lazy_code")(filepath, content)
- prompt = getattr(self, "build_patch_prompt")(
- lang_name, lang_tag, content, ruff_out, mypy_out, custom_issues
- )
- new_code, explanation, llm_response = getattr(self, "get_valid_edit")(
- prompt, content, require_edit=False, target_filepath=filepath
- )
- if new_code == content:
- logger.info(f"AI Analysis complete. No changes required for {filename}.\n")
- return
- if new_code != content:
- self.write_pr(filepath, explanation, llm_response)
- self.session_context.append(
- f"Proposed patch for `{filename}` to fix: {explanation}"
- )
- logger.info(
- f" Issues found and patched in {filename}. Added to {PR_FILE_NAME}.\n"
- )
-
- def propose_feature(self, target_path: str):
- rel_path = os.path.relpath(target_path, self.target_dir)
- lang_name, lang_tag = getattr(self, "get_language_info")(target_path)
- with open(target_path, "r", encoding="utf-8") as f:
- content = f.read()
- logger.info(
- f"\nPHASE 2: Generating an interactive feature proposal for [{rel_path}]..."
- )
- memory_section = getattr(self, "_get_rich_context")()
- prompt = getattr(self, "load_prompt")(
- "PF.md",
- lang_name=lang_name,
- memory_section=memory_section,
- lang_tag=lang_tag,
- content=content,
- rel_path=rel_path,
- )
- print("\n" + "=" * 50)
- print(f" Feature Proposal Prompt Ready: [{rel_path}]")
- print("=" * 50)
- print(
- f"The AI has prepared a prompt to generate a feature proposal for: {rel_path}"
- )
- user_choice = getattr(self, "get_user_approval")(
- "Hit ENTER to send as-is, type 'EDIT_PROMPT' to refine the full prompt, or 'SKIP' to cancel this proposal.",
- timeout=220,
- )
- if user_choice == "SKIP":
- logger.info("Feature proposal skipped by user.")
- return
- if user_choice == "EDIT_PROMPT":
- prompt = getattr(self, "_edit_prompt_with_external_editor")(prompt)
- if not prompt.strip():
- logger.warning("Edited prompt is empty. Skipping feature proposal.")
- return
-
- def validator(text):
- return "" in text and "" in text
-
- llm_response = getattr(self, "get_valid_llm_response")(
- prompt, validator, context=rel_path
- )
- thought_match = re.search(
- r"<(?:THOUGHT|EXPLANATION)>(.*?)(?:THOUGHT|EXPLANATION)>",
- llm_response,
- re.DOTALL | re.IGNORECASE,
- )
- snippet_match = re.search(
- r"\n?(.*?)\n?", llm_response, re.DOTALL | re.IGNORECASE
- )
- explanation = (
- thought_match.group(1).strip()
- if thought_match
- else "No explicit explanation generated."
- )
- suggested_code = snippet_match.group(1).strip() if snippet_match else ""
- suggested_code = re.sub(r"^```[a-zA-Z]*\n", "", suggested_code)
- suggested_code = re.sub(r"\n```$", "", suggested_code)
- suggested_code = suggested_code.strip()
- if not suggested_code:
- return logger.error("LLM failed to generate a valid feature snippet.")
- with open(self.feature_file, "w", encoding="utf-8") as f:
- f.write(
- f"# Feature Proposal\n\n**Target File:** `{rel_path}`\n\n**Explanation:**\n{explanation}\n\n### Suggested Addition/Optimization:\n```{lang_tag}\n{suggested_code}\n```\n\n---\n> **ACTION REQUIRED:** Review this feature proposal. Wait for terminal prompt to approve.\n"
- )
- logger.info(f"Feature proposal written to {FEATURE_FILE_NAME}.")
-
- def implement_feature(self, feature_content: str) -> bool:
- match = re.search(r"\*\*Target File:\*\* `(.*?)`", feature_content)
- if not match:
- logger.error("Could not determine target file from FEATURE.md formatting.")
- return False
-
- rel_path = match.group(1)
- target_path = os.path.join(self.target_dir, rel_path)
- target_folder = os.path.dirname(target_path)
- lang_name, lang_tag = getattr(self, "get_language_info")(target_path)
-
- with open(target_path, "r", encoding="utf-8") as f_handle:
- source_code = f_handle.read()
-
- created_files: list[str] = []
- new_file_matches = re.finditer(
- r'(.*?)', feature_content, re.DOTALL
- )
-
- for file_match in new_file_matches:
- new_path_rel = file_match.group(1)
- new_code_payload = file_match.group(2).strip()
-
- # Pathing: Naked filenames go into the same package as the target file
- if "/" not in new_path_rel and "\\" not in new_path_rel:
- new_path_abs = os.path.join(target_folder, new_path_rel)
- else:
- new_path_abs = os.path.join(self.target_dir, new_path_rel)
-
- if not os.path.exists(new_path_abs):
- try:
- os.makedirs(os.path.dirname(new_path_abs), exist_ok=True)
- logger.warning(
- f"ARCHITECTURAL SPLIT: Spawning new module `{os.path.basename(new_path_abs)}`"
- )
- with open(new_path_abs, "w", encoding="utf-8") as f_new:
- f_new.write(new_code_payload)
-
- # Immediately stage for Git so the Librarian sees it
- import subprocess
-
- subprocess.run(["git", "add", new_path_abs], cwd=self.target_dir)
- created_files.append(new_path_abs)
- except OSError as e:
- logger.error(f"Failed to create new module {new_path_rel}: {e}")
-
- exp_match = re.search(
- r"\*\*Explanation:\*\*(.*?)(?:###|---|>)",
- feature_content,
- re.DOTALL | re.IGNORECASE,
- )
- feature_explanation = (
- exp_match.group(1).strip()
- if exp_match
- else f"Implemented a new structural feature for: [{rel_path}]..."
- )
-
- logger.info(
- f"Implementing approved feature seamlessly directly into {rel_path}..."
- )
- memory_section = getattr(self, "_get_rich_context")()
- prompt = getattr(self, "load_prompt")(
- "IF.md",
- memory_section=memory_section,
- feature_content=feature_content,
- lang_name=lang_name,
- lang_tag=lang_tag,
- source_code=source_code,
- rel_path=rel_path,
- )
-
- new_code, _, _ = getattr(self, "get_valid_edit")(
- prompt, source_code, require_edit=True, target_filepath=target_path
- )
-
- if new_code == source_code:
- logger.error("Implementation failed. Rolling back created modules.")
- for file_path in created_files:
- if os.path.exists(file_path):
- os.remove(file_path)
- return False
-
- if lang_tag == "python":
- new_code = getattr(self, "ensure_imports_retained")(
- source_code, new_code, target_path
- )
-
- with open(target_path, "w", encoding="utf-8") as f_out:
- f_out.write(new_code)
-
- if lang_tag == "python":
- # Verification Pipeline
- if not getattr(self, "run_linter_fix_loop")(
- context_of_change=feature_content
- ) or not getattr(self, "run_and_verify_app")(
- context_of_change=feature_content
- ):
- logger.error("Verification failed. Cleaning up spawned modules.")
- for file_path in created_files:
- if os.path.exists(file_path):
- os.remove(file_path)
- return False
-
- if not getattr(self, "check_downstream_breakages")(target_path, rel_path):
- logger.error("Downstream breakages. Rolling back spawned modules.")
- for file_path in created_files:
- if os.path.exists(file_path):
- os.remove(file_path)
- return False
-
- logger.info(f"Successfully implemented feature directly into {rel_path}.")
-
- self.session_context.append(
- f"SUCCESSFUL CHANGE in `{rel_path}`: {feature_explanation}"
- )
-
- if created_files:
- self.session_context.append(
- "Created new modules: "
- + ", ".join([os.path.basename(fp) for fp in created_files])
- )
-
- if os.path.exists(self.feature_file):
- os.remove(self.feature_file)
-
- return True
-
- def implement_pr(self, pr_content: str) -> bool:
- """
- Implements PR patches atomically.
- Only applies changes if ALL patches in the PR pass the XML,
- safety, and verification gates.
- """
- logger.info("Implementing approved PRs seamlessly from XML blocks...")
- file_sections = re.split(r"## (?:🛠 )?Review for `(.*?)`", pr_content)
-
- # Guard: Ensure content actually contains patches
- if len(file_sections) < 3:
- logger.error(f"No valid file patches found in {PR_FILE_NAME} to apply.")
- return False
-
- all_success = True
- patches_to_apply = [] # Atomic transaction buffer
-
- # 1. PRE-FLIGHT VALIDATION (Check all patches before applying any)
- for i in range(1, len(file_sections), 2):
- rel_path = file_sections[i].strip()
- section_content = file_sections[i + 1]
- target_path = os.path.join(self.target_dir, rel_path)
-
- if not os.path.exists(target_path):
- logger.error(f"Target file {rel_path} not found.")
- all_success = False
- continue
-
- with open(target_path, "r", encoding="utf-8") as f:
- source_code = f.read()
-
- # Apply XML edit logic
- new_code, explanation, success = getattr(self, "apply_xml_edits")(
- source_code, section_content
- )
-
- # --- EMERGENCY DELETION GUARD ---
- # If the patch deletes > 50% of the file, it is highly likely a hallucination.
- if len(new_code) < (len(source_code) * 0.5):
- logger.error(
- f"EMERGENCY STOP: Patch for {rel_path} would delete too much code."
- )
- all_success = False
- continue
-
- # Patch Validation
- if not success:
- logger.error(f"Failed to match XML blocks for {rel_path}.")
- all_success = False
- elif new_code == source_code:
- logger.warning(f"Patch for {rel_path} changed nothing. Skipping.")
- else:
- patches_to_apply.append((target_path, new_code, rel_path))
-
- # 2. ATOMIC COMMIT (Only write to disk if everything checked out)
- if all_success and patches_to_apply:
- for target_path, new_code, rel_path in patches_to_apply:
- with open(target_path, "w", encoding="utf-8") as f:
- f.write(new_code)
- logger.info(f"Successfully applied patch to {rel_path}.")
-
- # 3. VERIFICATION PIPELINE (The final test)
- if not getattr(self, "run_linter_fix_loop")(
- context_of_change=pr_content
- ) or not getattr(self, "run_and_verify_app")(context_of_change=pr_content):
- logger.error("Verification failed. Changes will be rolled back.")
- return False
-
- self.session_context.append("Applied automated patch XML edits.")
- if os.path.exists(self.pr_file):
- os.remove(self.pr_file)
- return True
-
- return False
diff --git a/src/pyob/get_valid_edit.py b/src/pyob/get_valid_edit.py
deleted file mode 100644
index 256a074..0000000
--- a/src/pyob/get_valid_edit.py
+++ /dev/null
@@ -1,270 +0,0 @@
-import difflib
-import os
-import re
-import time
-
-from .core_utils import logger
-
-
-class GetValidEditMixin:
- def get_valid_edit(
- self,
- prompt: str,
- source_code: str,
- require_edit: bool = True,
- target_filepath: str = "",
- ) -> tuple[str, str, str]:
- base_dir = getattr(self, "target_dir", os.getcwd())
- display_name = (
- os.path.relpath(target_filepath, base_dir)
- if target_filepath
- else "System Update"
- )
-
- # 1. Pre-Flight Human Check
- prompt, skip = self._handle_pre_generation_approval(prompt, display_name)
- if skip:
- return source_code, "AI generation skipped by user.", ""
-
- attempts = 0
- while True:
- # 2. Fetch from AI (Handles keys, retries, and API limits)
- response_text, attempts = self._fetch_llm_with_retries(
- prompt, display_name, attempts
- )
-
- # 3. Validate and Apply XML Patch
- new_code, explanation, is_valid = self._validate_llm_patch(
- source_code, response_text, require_edit, display_name
- )
-
- if not is_valid:
- attempts += 1
- continue
-
- if new_code == source_code:
- return new_code, explanation, response_text
-
- # 4. Post-Flight Human Review (Diffs and Approval)
- final_code, final_exp, final_resp, action = (
- self._handle_post_generation_review(
- source_code,
- new_code,
- explanation,
- response_text,
- target_filepath,
- display_name,
- )
- )
-
- if action == "REGENERATE":
- attempts += 1
- continue
-
- return final_code, final_exp, final_resp
-
- # ==========================================
- # PRIVATE HELPER METHODS
- # ==========================================
-
- def _handle_pre_generation_approval(
- self, prompt: str, display_name: str
- ) -> tuple[str, bool]:
- print("\n" + "=" * 50)
- print(f"AI Generation Prompt Ready: [{display_name}]")
- print("=" * 50)
- choice = getattr(self, "get_user_approval")(
- "Hit ENTER to send as-is, type 'EDIT_PROMPT', 'AUGMENT_PROMPT', or 'SKIP'.",
- timeout=220,
- )
- if choice == "SKIP":
- return prompt, True
- elif choice == "EDIT_PROMPT":
- prompt = getattr(self, "_edit_prompt_with_external_editor")(prompt)
- elif choice == "AUGMENT_PROMPT":
- aug = getattr(self, "_get_user_prompt_augmentation")()
- if aug.strip():
- prompt += f"\n\n### User Augmentation:\n{aug.strip()}"
- return prompt, False
-
- def _fetch_llm_with_retries(
- self, prompt: str, display_name: str, attempts: int
- ) -> tuple[str, int]:
- is_cloud = (
- os.environ.get("GITHUB_ACTIONS") == "true"
- or os.environ.get("CI") == "true"
- or "GITHUB_RUN_ID" in os.environ
- )
- key_cooldowns = getattr(self, "key_cooldowns", {})
-
- while True:
- now = time.time()
- gemini_keys = [k for k in key_cooldowns.keys() if "github" not in k]
- available_keys = [k for k in gemini_keys if now > key_cooldowns[k]]
- key = None
- gh_model = "Llama-3"
-
- if available_keys:
- key = available_keys[attempts % len(available_keys)]
- logger.info(
- f"\n[Attempting Gemini API Key {attempts % len(available_keys) + 1}/{len(gemini_keys)}]"
- )
- response = getattr(self, "_stream_single_llm")(
- prompt, key=key, context=display_name
- )
- elif is_cloud:
- if now < key_cooldowns.get("github_llama", 0):
- gh_model = "Phi-4"
- if gh_model == "Phi-4" and now < key_cooldowns.get("github_phi", 0):
- wait = 120
- logger.warning(
- f"All API limits exhausted. Sleeping {wait}s for Gemini refill..."
- )
- time.sleep(wait)
- attempts += 1
- continue
-
- logger.warning(
- f"Gemini limited. Pivoting to GitHub Models ({gh_model})..."
- )
- response = getattr(self, "_stream_single_llm")(
- prompt, key=None, context=display_name, gh_model=gh_model
- )
- else:
- logger.info("\n[All keys exhausted. Falling back to Local Ollama]")
- response = getattr(self, "_stream_single_llm")(
- prompt, key=None, context=display_name
- )
-
- if response.startswith("ERROR_CODE_429"):
- if key:
- key_cooldowns[key] = time.time() + 180
- logger.warning("Key rate limited. Pivoting to next key...")
- attempts += 1
- continue
- elif "RateLimitReached" in response:
- match = re.search(r"wait (\d+) seconds", response)
- wait_time = int(match.group(1)) if match else 86400
- if gh_model == "Llama-3":
- key_cooldowns["github_llama"] = time.time() + wait_time + 60
- else:
- key_cooldowns["github_phi"] = time.time() + wait_time + 60
- logger.error(
- f" GITHUB QUOTA REACHED ({gh_model}). Cooldown: {wait_time}s"
- )
- attempts += 1
- continue
- else:
- time.sleep(120)
- attempts += 1
- continue
-
- if "ERROR_CODE_413" in response:
- logger.warning("Payload too large (413). Backing off 120s...")
- time.sleep(120)
- attempts += 1
- continue
-
- if response.startswith("ERROR_CODE_") or not response.strip():
- if key and "429" not in (response or ""):
- key_cooldowns[key] = time.time() + 30
-
- if available_keys:
- logger.warning(
- f"Engine failed with error: {str(response)[:60]}... Rotating..."
- )
- attempts += 1
- time.sleep(5)
- continue
-
- logger.warning("API Error or Empty Response. Sleeping 120s...")
- time.sleep(120)
- attempts += 1
- continue
-
- return response, attempts
-
- def _validate_llm_patch(
- self,
- source_code: str,
- response_text: str,
- require_edit: bool,
- display_name: str,
- ) -> tuple[str, str, bool]:
- new_code, explanation, edit_success = getattr(self, "apply_xml_edits")(
- source_code, response_text
- )
- edit_count = len(re.findall(r"", response_text, re.IGNORECASE))
- lower_exp = explanation.lower()
- ai_approved = "no fixes needed" in lower_exp or "looks good" in lower_exp
-
- if not require_edit and ai_approved:
- return source_code, explanation, True
- if edit_count > 0 and not edit_success:
- logger.warning(
- f"Partial edit failure in {display_name}. Auto-regenerating..."
- )
- time.sleep(30)
- return source_code, explanation, False
- if require_edit and new_code == source_code:
- logger.warning("Search block mismatch. Rotating...")
- time.sleep(30)
- return source_code, explanation, False
- if not require_edit and new_code == source_code and not ai_approved:
- time.sleep(30)
- return source_code, explanation, False
-
- return new_code, explanation, True
-
- def _handle_post_generation_review(
- self,
- source_code: str,
- new_code: str,
- explanation: str,
- response_text: str,
- target_filepath: str,
- display_name: str,
- ) -> tuple[str, str, str, str]:
- print("\n" + "=" * 50)
- print(f"AI Proposed Edit Ready for: [{display_name}]")
- print("=" * 50)
- diff_lines = list(
- difflib.unified_diff(
- source_code.splitlines(keepends=True),
- new_code.splitlines(keepends=True),
- fromfile="Original",
- tofile="Proposed",
- )
- )
- for line in diff_lines[2:22]:
- clean = line.rstrip()
- if clean.startswith("+"):
- print(f"\033[92m{clean}\033[0m")
- elif clean.startswith("-"):
- print(f"\033[91m{clean}\033[0m")
- elif clean.startswith("@@"):
- print(f"\033[94m{clean}\033[0m")
- else:
- print(clean)
-
- choice = getattr(self, "get_user_approval")(
- "Hit ENTER to APPLY, type 'EDIT_CODE', 'EDIT_XML', 'REGENERATE', or 'SKIP'.",
- timeout=220,
- )
-
- if choice == "SKIP":
- return source_code, "Edit skipped.", "", "SKIP"
- elif choice == "REGENERATE":
- return source_code, explanation, response_text, "REGENERATE"
- elif choice == "EDIT_XML":
- resp = getattr(self, "_edit_prompt_with_external_editor")(response_text)
- nc, exp, _ = getattr(self, "apply_xml_edits")(source_code, resp)
- return nc, exp, resp, "APPLY"
- elif choice == "EDIT_CODE":
- ext = os.path.splitext(target_filepath)[1] if target_filepath else ".py"
- ec = getattr(self, "_launch_external_code_editor")(
- new_code, file_suffix=ext
- )
- return ec, explanation + " (User edited)", response_text, "APPLY"
-
- return new_code, explanation, response_text, "APPLY"
diff --git a/src/pyob/models.py b/src/pyob/models.py
deleted file mode 100644
index 45c71da..0000000
--- a/src/pyob/models.py
+++ /dev/null
@@ -1,386 +0,0 @@
-import json
-import logging
-import os
-import re
-import shutil
-import sys
-import threading
-import time
-from typing import Callable, Optional
-
-import requests
-
-logger = logging.getLogger("PyOuroBoros")
-
-try:
- if (
- os.environ.get("GITHUB_ACTIONS") == "true"
- or os.environ.get("CI") == "true"
- or "GITHUB_RUN_ID" in os.environ
- ):
- OLLAMA_AVAILABLE = False
- else:
- import ollama
-
- OLLAMA_AVAILABLE = True
-except ImportError:
- OLLAMA_AVAILABLE = False
-
-GEMINI_MODEL = os.environ.get("PYOB_GEMINI_MODEL", "gemini-2.5-flash")
-LOCAL_MODEL = os.environ.get("PYOB_LOCAL_MODEL", "qwen3-coder:30b")
-
-
-def stream_gemini(prompt: str, api_key: str, on_chunk: Callable[[], None]) -> str:
- url = f"https://generativelanguage.googleapis.com/v1beta/models/{GEMINI_MODEL}:streamGenerateContent?alt=sse&key={api_key}"
- headers = {"Content-Type": "application/json"}
- data = {
- "contents": [{"parts": [{"text": prompt}]}],
- "generationConfig": {"temperature": 0.1},
- }
-
- # We use a long timeout on the request, but we will monitor chunk arrival internally
- response = requests.post(url, headers=headers, json=data, stream=True, timeout=120)
- if response.status_code != 200:
- return f"ERROR_CODE_{response.status_code}: {response.text}"
-
- response_text = ""
- last_chunk_time = time.time()
-
- for line in response.iter_lines(decode_unicode=True):
- if not line:
- # Check for stall
- if time.time() - last_chunk_time > 30:
- logger.warning("Gemini stream stalled. Forcing closure.")
- break
- continue
-
- if line.startswith("data: "):
- last_chunk_time = time.time() # Reset stall timer
- try:
- chunk_data = json.loads(line[6:])
- text = chunk_data["candidates"][0]["content"]["parts"][0]["text"]
- on_chunk()
- response_text += text
- except (KeyError, IndexError, json.JSONDecodeError):
- pass
- return response_text
-
-
-def stream_ollama(prompt: str, on_chunk: Callable[[], None]) -> str:
- if (
- os.environ.get("GITHUB_ACTIONS") == "true"
- or os.environ.get("CI") == "true"
- or "GITHUB_RUN_ID" in os.environ
- ):
- logger.error(
- "SECURITY VIOLATION: Ollama called in Cloud environment. ABORTING."
- )
- time.sleep(60) # CRITICAL: Hard sleep kills outer loop machine-gun attempts
- return "ERROR_CODE_CLOUD_OLLAMA_FORBIDDEN"
-
- if not OLLAMA_AVAILABLE:
- logger.error("Ollama is not available.")
- time.sleep(60)
- return "ERROR_CODE_OLLAMA_UNAVAILABLE"
-
- response_text = ""
- try:
- stream = ollama.chat(
- model=LOCAL_MODEL,
- messages=[{"role": "user", "content": prompt}],
- options={"temperature": 0.1, "num_ctx": 32000},
- stream=True,
- )
- for chunk in stream:
- content = chunk.get("message", {}).get("content", "")
- if content:
- on_chunk()
- print(content, end="", flush=True)
- response_text += content
- except Exception as e:
- logger.error(f"Ollama Error: {e}")
- time.sleep(30)
- return f"ERROR_CODE_EXCEPTION: {e}"
- return response_text
-
-
-def stream_github_models(
- prompt: str, on_chunk: Callable[[], None], model_name: str = "Llama-3"
-) -> str:
- token = os.environ.get("GITHUB_TOKEN")
- if not token:
- return "ERROR_CODE_TOKEN_MISSING"
-
- endpoint = "https://models.inference.ai.azure.com/chat/completions"
- headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
- actual_model = "Llama-3.3-70B-Instruct" if model_name == "Llama-3" else "Phi-4"
-
- data = {
- "model": actual_model,
- "messages": [{"role": "user", "content": prompt}],
- "stream": True,
- "max_tokens": 4096,
- }
-
- full_text = ""
- last_chunk_time = time.time()
-
- try:
- response = requests.post(
- endpoint, headers=headers, json=data, stream=True, timeout=120
- )
-
- for line in response.iter_lines():
- if not line:
- if time.time() - last_chunk_time > 30:
- logger.warning("GitHub stream stalled. Forcing closure.")
- break
- continue
-
- line_str = line.decode("utf-8").replace("data: ", "")
- if line_str.strip() == "[DONE]":
- break
-
- try:
- chunk = json.loads(line_str)
- content = (
- chunk.get("choices", [{}])[0].get("delta", {}).get("content", "")
- )
- if content:
- last_chunk_time = time.time()
- full_text += content
- on_chunk()
- except Exception:
- continue
- return full_text
- except Exception as e:
- return f"ERROR_CODE_EXCEPTION: {str(e)}"
-
-
-def stream_single_llm(
- prompt: str,
- key: Optional[str] = None,
- context: str = "",
- gh_model: str = "Llama-3",
-) -> str:
- input_tokens = len(prompt) // 4
- first_chunk_received = [False]
- gen_start_time = time.time()
- is_cloud = (
- os.environ.get("GITHUB_ACTIONS") == "true"
- or os.environ.get("CI") == "true"
- or "GITHUB_RUN_ID" in os.environ
- )
-
- def spinner():
- spinner_chars = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
- i = 0
- while not first_chunk_received[0]:
- cols, _ = shutil.get_terminal_size((80, 20))
- elapsed = time.time() - gen_start_time
- expected_time = max(1, input_tokens / 34.0)
- progress = min(1.0, elapsed / expected_time)
- bar_len = max(10, cols - 65)
- filled = int(progress * bar_len)
- bar = "█" * filled + "░" * (bar_len - filled)
- status = f"{spinner_chars[i]} Reading [{context}] ~{input_tokens} ctx... [{bar}] {progress * 100:.1f}%"
- sys.stdout.write(f"\r\033[K{status[: cols - 1]}")
- sys.stdout.flush()
- i = (i + 1) % len(spinner_chars)
- time.sleep(0.1)
-
- if is_cloud:
- print(f"Reading [{context}] ~{input_tokens} ctx...", flush=True)
- else:
- t = threading.Thread(target=spinner, daemon=True)
- t.start()
-
- def on_chunk():
- if not first_chunk_received[0]:
- first_chunk_received[0] = True
- if not is_cloud:
- sys.stdout.write("\r\033[K")
- sys.stdout.flush()
- source = f"Gemini ...{key[-4:]}" if key else f"GitHub Models ({gh_model})"
- if not key and not is_cloud:
- source = "Local Ollama"
- print(f"AI Output ({source}): ", end="", flush=True)
-
- response_text = ""
- try:
- if key is not None:
- response_text = stream_gemini(prompt, key, on_chunk)
- elif is_cloud:
- response_text = stream_github_models(prompt, on_chunk, model_name=gh_model)
-
- # Immediately intercept 413, pause 60s, and force Gemini usage so outer loops don't panic
- if response_text and "413" in response_text:
- first_chunk_received[0] = True
- logger.warning(
- "\nPayload too large. Sleeping 60s, then pivoting to Gemini..."
- )
- time.sleep(60)
- gemini_keys = [
- k.strip()
- for k in os.environ.get("PYOB_GEMINI_KEYS", "").split(",")
- if k.strip()
- ]
- if gemini_keys:
- # Return a specific signal string so the caller knows it worked
- return stream_gemini(prompt, gemini_keys[0], on_chunk)
- else:
- return "ERROR_CODE_413_NO_GEMINI_FALLBACK"
-
- # Force mandatory sleep if ANY cloud error escapes, breaking infinite loop triggers
- if response_text and response_text.startswith("ERROR_CODE_"):
- time.sleep(30)
-
- else:
- response_text = stream_ollama(prompt, on_chunk)
- except Exception as e:
- first_chunk_received[0] = True
- if is_cloud:
- time.sleep(30)
- return f"ERROR_CODE_EXCEPTION: {e}"
-
- first_chunk_received[0] = True
- if response_text and not response_text.startswith("ERROR_CODE_"):
- print(
- f"\n\n[Generation Complete: ~{len(response_text) // 4} tokens in {time.time() - gen_start_time:.1f}s]"
- )
- return response_text
-
-
-def get_valid_llm_response_engine(
- prompt: str,
- validator: Callable[[str], bool],
- key_cooldowns: dict[str, float],
- context: str = "",
-) -> str:
- """
- Robust engine that handles key rotation across multiple providers.
- Uses cooldown tracking to ensure maximum utilization of free-tier quotas.
- """
- attempts = 0
- is_cloud = (
- os.environ.get("GITHUB_ACTIONS") == "true"
- or os.environ.get("CI") == "true"
- or "GITHUB_RUN_ID" in os.environ
- )
-
- while True:
- key = None
- now = time.time()
-
- # 1. Identify all registered Gemini keys and find those not on cooldown
- gemini_keys = [k for k in list(key_cooldowns.keys()) if "github" not in k]
- available_keys = [k for k in gemini_keys if now > key_cooldowns[k]]
- response_text = None
-
- # 2. DECISION LOGIC: Prioritize Gemini rotation
- if available_keys:
- # Cycle through available keys using the attempt counter
- key = available_keys[attempts % len(available_keys)]
- logger.info(
- f"Attempting Gemini Key {attempts % len(available_keys) + 1}/{len(gemini_keys)}"
- )
- response_text = stream_single_llm(prompt, key=key, context=context)
-
- elif is_cloud:
- # 3. CLOUD FALLBACK: If all Gemini keys are cooling down, use GitHub Models
- if now < key_cooldowns.get("github_llama", 0):
- # If Llama is also cooling, try Phi
- logger.warning("Llama-3 limited. Trying Phi-4...")
- response_text = stream_single_llm(
- prompt, key=None, context=context, gh_model="Phi-4"
- )
- else:
- logger.warning(
- "Gemini exhausted. Pivoting to GitHub Models (Llama-3)..."
- )
- response_text = stream_single_llm(
- prompt, key=None, context=context, gh_model="Llama-3"
- )
- else:
- # 4. LOCAL FALLBACK: Fallback to Ollama
- logger.info(" All Gemini keys exhausted. Falling back to Local Ollama...")
- response_text = stream_single_llm(prompt, key=None, context=context)
-
- # --- ERROR HANDLING BLOCK ---
- if not response_text or response_text.startswith("ERROR_CODE_"):
- # A. Gemini Rate Limit (429)
- if key and response_text and "429" in response_text:
- key_cooldowns[key] = (
- time.time() + 180
- ) # 3 min rest for the specific key
- logger.warning(f"Key {key[-4:]} rate-limited. Pivoting to next key...")
- attempts += 1
- continue # Immediately retry with the next key in the pool
-
- # B. GitHub Models Daily Quota (429)
- if (
- response_text
- and "429" in response_text
- and "RateLimitReached" in response_text
- ):
- match = re.search(r"wait (\d+) seconds", response_text)
- seconds_to_wait = int(match.group(1)) if match else 86400
-
- # Assign cooldown to the specific model that failed
- if "Llama" in (response_text or ""):
- key_cooldowns["github_llama"] = time.time() + seconds_to_wait + 60
- else:
- key_cooldowns["github_phi"] = time.time() + seconds_to_wait + 60
-
- logger.error(
- f"GITHUB QUOTA REACHED. Cooling down model for {seconds_to_wait}s"
- )
- attempts += 1
- continue
-
- # C. Generic Error Handling / Fail-Safe Sleep
- if not available_keys:
- # If everything is exhausted, take a long nap
- wait = 120
- logger.warning(
- f"All API resources exhausted. Sleeping {wait}s for refill..."
- )
- time.sleep(wait)
- attempts = 0 # RESET: Start fresh with Key 1 after the nap
- continue
- else:
- # Key failed for unknown reason, rotate and retry
- if key:
- key_cooldowns[key] = time.time() + 30
- attempts += 1
- time.sleep(2)
- continue
-
- # --- VALIDATION BLOCK ---
- if validator(response_text):
- if is_cloud:
- time.sleep(
- 5
- ) # Slow down slightly in cloud to prevent 429 machine-gunning
- return response_text
- else:
- # Try cleaning AI chatter (e.g. "Here is the code:") and re-validating
- clean_text = (
- re.sub(
- r"^(Here is the code:)|(I suggest:)|(```[a-z]*)",
- "",
- response_text,
- flags=re.IGNORECASE,
- )
- .strip()
- .rstrip("`")
- )
-
- if validator(clean_text):
- return clean_text
-
- # If still invalid, back off and retry
- wait = 120 if is_cloud else 10
- logger.warning(f"AI response failed validation. Backing off {wait}s...")
- time.sleep(wait)
- attempts += 1
diff --git a/src/pyob/prompts_and_memory.py b/src/pyob/prompts_and_memory.py
deleted file mode 100644
index 043bc32..0000000
--- a/src/pyob/prompts_and_memory.py
+++ /dev/null
@@ -1,153 +0,0 @@
-import os
-import re
-
-from pyob.core_utils import logger
-
-
-class SearchAndFilterMixin:
- def __init__(self):
- self.search_query = ""
- self.filter_date = ""
-
- def handle_search(self, search_query):
- self.search_query = search_query
- # Call the data_parser to filter the memory entries based on the search query
-
- def handle_filter(self, filter_date):
- self.filter_date = filter_date
- # Call the data_parser to filter the memory entries based on the filter date
-
-
-class PromptsAndMemoryMixin(SearchAndFilterMixin):
- target_dir: str
- history_path: str
- analysis_path: str
- memory_path: str
- memory: str
-
- def _ensure_prompt_files(self) -> None:
- data_dir = os.path.join(self.target_dir, ".pyob")
- os.makedirs(data_dir, exist_ok=True)
- templates = {
- "UM.md": "You are the PyOB Memory Manager. Your job is to update MEMORY.md.\n\n### Current Memory:\n{current_memory}\n\n### Recent Actions:\n{session_summary}\n\n### INSTRUCTIONS:\n1. Update the memory with the Recent Actions.\n2. TRANSACTIONAL RECORDING: Only record changes as 'Implemented' if the actions specifically state 'SUCCESSFUL CHANGE'. If you see 'CRITICAL: FAILED' or 'ROLLED BACK', record this as a 'Failed Attempt' with the reason, so the engine knows to try a different approach next time.\n3. BREVITY: Keep the ENTIRE document under 200 words. Be ruthless. Delete old, irrelevant details.\n4. FORMAT: Keep lists strictly to bullet points. No long paragraphs.\n5. **HUMAN DIRECTIVES**: If the current memory contains a section titled HUMAN DIRECTIVES, you are strictly FORBIDDEN from deleting or altering it. Prepend all new session summaries BELOW that section.\n6. Respond EXCLUSIVELY with the raw markdown for MEMORY.md. Do not use ```markdown fences or blocks.\n FINAL WARNING: Only output XML and code. If you add conversational text outside of or , the system will ROLLBACK.",
- "RM.md": "You are the PYOB Memory Manager. The current MEMORY.md is too bloated and is breaking the AI context window.\n\n### Bloated Memory:\n{current_memory}\n\n### INSTRUCTIONS:\n1. AGGRESSIVELY COMPRESS this memory. \n2. Delete duplicate information, repetitive logs, and obvious statements.\n3. Keep ONLY the core architectural rules and crucial file dependencies.\n4. The final output MUST BE UNDER 150 WORDS.\n5. Respond EXCLUSIVELY with the raw markdown. No fences, no thoughts.\n FINAL WARNING: Only output XML and code. If you add conversational text outside of or , the system will ROLLBACK.",
- "PP.md": 'You are an elite PYOB Software Engineer. Analyze the code for bugs or architectural gaps.\n\n{memory_section}{ruff_section}{mypy_section}{custom_issues_section}### Source Code:\n```{lang_tag}\n{content}\n```\n\n### CRITICAL RULES:\n1. **ITERATION BUDGET**: \n - IF iteration < 5: Focus ONLY on bug fixes, linting, and minor performance tweaks. Do NOT create new files, move classes, or extract logic. \n - IF iteration >= 5: You may propose architectural refactors, file extractions, and logic movement.\n2. **SURGICAL FIXES**: Every block must be exactly 2-5 lines. Only provide ONE block per response.\n3. **NO HALLUCINATIONS**: Do not invent bugs. If the code is functional, state \'The code looks good.\'\n4. **ARCHITECTURAL BLOAT**: Only flag bloat for future planning until iteration 5.\n5. **MISSING IMPORTS**: If you use a new module/type, you MUST add an block to import it at the top.\n6. **INDENTATION IS SYNTAX**: Your blocks must have perfect, absolute indentation.\n7. **DEFEATING MYPY**: If fixing a `[union-attr]` error, use `assert variable is not None` or `# type: ignore`.\n8. **IMPORT PATHS (MANDATORY)**: Never use the prefix `src.` in any import statement. The root of the package is `pyob`. (Example: Use `from pyob.core_utils import ...`, NOT `from src.pyob.core_utils import ...`).\n9. **TYPE HINTS (CRITICAL)**: Never use the `|` operator with quoted class names (Forward References). Use `Any` for objects that create circular dependencies.\n10. **ATOMIC REFACTORING**: If you are beyond iteration 5 and need to move logic, use a 3-step process: A) Add new file/imports. B) Update call sites. C) Remove old code in a subsequent turn.\n11. **NO SCAFFOLDING (CRITICAL)**: Never include bot-specific text, logs, or status tags (like "APPROVED" or "LIBRARIAN") inside the code blocks. Your output must be 100% valid code for the target language.\n\n### HOW TO RESPOND (CHOOSE ONE):\n\n**SCENARIO A: Code has bugs/bloat and needs edits:**\n\nSummary: ...\nEvaluation: [Address bloat here if flagged]\nImports Required: ...\nAction: I will fix X by doing Y.\n\n\n\nExact lines to replace (2-5 lines max)\n\n\nNew lines\n\n\n\n**SCENARIO B: Code is perfect. NO EDITS NEEDED:**\n\nSummary: ...\nEvaluation: ...\nAction: The code looks good. No fixes needed.\n\n FINAL WARNING: Only output XML and code. If you add conversational text outside of or , the system will ROLLBACK.',
- "ALF.md": "You are an elite developer fixing syntax errors.\nThe file `{rel_path}` failed validation with these exact errors:\n{err_text}\n\n### Current Code:\n```\n{code}\n```\n\n### Instructions:\n1. Fix the syntax errors (like stray brackets, unexpected tokens, or indentation) using surgical XML edits.\n2. Respond EXCLUSIVELY with a block followed by ONE OR MORE blocks.\n3. Ensure your edits perfectly align with the surrounding brackets.\n FINAL WARNING: Only output XML and code. If you add conversational text outside of or , the system will ROLLBACK.",
- "FRE.md": "You are an elite PYOB developer fixing runtime crashes.\n{memory_section}The application crashed during a test run.\n\n### Crash Logs & Traceback:\n{logs}\n\nThe traceback indicates the error occurred in `{rel_path}`.\n\n### Current Code of `{rel_path}`:\n```python\n{code}\n```\n\n### Instructions:\n1. Identify the EXACT root cause of the crash.\n2. Fix the error using surgical XML edits.\n3. Respond EXCLUSIVELY with a block followed by ONE OR MORE blocks.\n\n### REQUIRED XML FORMAT:\n\nExplanation of root cause...\nImports Needed: [List new imports required or 'None']\n\n\n\nExact lines to replace\n\n\nNew replacement lines\n\n",
- "PF.md": "You are the PYOB Product Architect. Review the source code and suggest ONE highly useful, INTERACTIVE feature.\n\n{memory_section}### Source Code:\n```{lang_tag}\n{content}\n```\n\n### CRITICAL RULES:\n1. Suggest an INTERACTIVE feature (UI elements, buttons, menus).\n2. **ARCHITECTURAL SPLIT (MANDATORY)**: If the source code is over 800 lines, you ARE NOT ALLOWED to propose a new feature. You MUST propose an 'Architectural Split'. Identify a logical module (like a Mixin) to move to a NEW file.\n3. **SINGLE FILE LIMIT**: If you are proposing an Architectural Split, you are ONLY allowed to create ONE new file per iteration. Do not attempt to split multiple modules at once. Focus on the largest logical block first.\n4. **NEW FILE FORMAT**: If proposing a split, your block MUST use this format: [Full Code for New File]. Your must then explain how to update the original file to import this new module.\n5. MULTI-FILE ORCHESTRATION: Explicitly list filenames of other files that will need updates in your .\n6. The block MUST contain ONLY the new logic or the block.\n\n### REQUIRED XML FORMAT:\n\n...\n\n\n# New logic OR tag here\n\n FINAL WARNING: Only output XML and code. If you add conversational text outside of or , the system will ROLLBACK.",
- "IF.md": "You are an elite PYOB Implementation Engineer. Implement the APPROVED feature into `{rel_path}`.\n\n{memory_section}### Feature Proposal Details:\n{feature_content}\n\n### Current Source Code:\n```{lang_tag}\n{source_code}\n```\n\n### CRITICAL INSTRUCTIONS:\n1. **SURGICAL EDITS ONLY**: Every block must be EXACTLY 2-5 lines.\n2. **MISSING IMPORTS**: If your feature introduces a new class/function, add a separate block to import it.\n3. **BLOCK INTEGRITY (CRITICAL)**: Python relies on indentation. If you add an `if`, `try`, or `def` statement, you MUST indent the code beneath it. If you remove one, you MUST dedent the code. Do not leave orphaned indents.\n4. **ABSOLUTE SPACES**: The spaces in your block must match the absolute margin of the source code.\n5. **VARIABLE SCOPE**: Ensure variables are accessible (use `self.` for class states).\n6. **DELETING CODE (CRITICAL)**: If your goal is to remove a block of code (e.g., when moving logic to a new file), DO NOT leave an empty block. Instead, provide a block containing a comment such as `# [Logic moved to new module]` to ensure the surrounding code remains syntactically valid.\n7. **IMPORT PATHS (MANDATORY)**: Never use the prefix `src.` in any import statement. The root of the package is `pyob`. (Example: Use `from pyob.entrance import Controller`, NOT `from src.pyob.entrance import Controller`).\n8. **TYPE HINTS (CRITICAL)**: Never use the `|` operator with quoted class names (Forward References). Use `Any` for objects that create circular dependencies.\n9. **ATOMIC REFACTORING**: Do not move large blocks or classes to new files in a single turn. Focus ONLY on logic improvements or bug fixes within the current file.\n10. **SURGICAL MOVEMENT**: If moving logic, use a 3-step process: A) Add new file/imports. B) Update call sites. C) Remove old code in a subsequent turn.\n11. **NO MASS DELETIONS**: You are strictly forbidden from replacing a large block of code with a smaller one unless you include the surrounding class/method structure in your block. If you cannot fit the structure in 5 lines, use multiple blocks.\n12. **INDENTATION LOCK**: Every block must have the exact same indentation level as the block.\n13. **PRESERVE STRUCTURE**: Never use a block that cuts off a class or method definition. Always include the full line you are matching.\n14. **NO EMOJIS (MANDATORY)**: Never use emojis or non-ASCII characters in logger messages, print statements, or source comments. Use plain ASCII text only. Emojis cause encoding errors and git diff corruption.\n15. **NO INTERNAL TESTS**: Never write test suites (Jest/Pytest/describe blocks) inside a source file logic. Use the dedicated Phase 0 system for tests.\n16. **JAVASCRIPT STANDARDS**: If editing `.js` files, use `const` and `let` (avoid `var`), and always include the `.js` extension in `import` statements. Ensure logic is encapsulated in the correct class or module.\n### REQUIRED XML FORMAT:\n\n1. Lines to change: ...\n2. New imports needed: [List them or state 'None']\n3. Strategy: ...\n\n\n\nExact 2-5 lines\n\n\nNew code\n\n\n FINAL WARNING: Only output XML and code. If you add conversational text outside of or , the system will ROLLBACK.",
- "PCF.md": "You are the PYOB Symbolic Fixer taking over Phase 3 Cascaded Edits.\n{memory_section}We just modified `{trigger_file}`, and it broke a dependency downstream: `{rel_broken_path}`.\n\n### Linter Errors for `{rel_broken_path}`:\n{mypy_errors}\n\n### Source Code of `{rel_broken_path}`:\n```python\n{broken_code}\n```\n\n### Instructions:\n1. Respond EXCLUSIVELY with a block followed by ONE block to fix the broken references.\n2. **DEFEATING MYPY (CRITICAL)**: If the error contains `[union-attr]` or states `Item `None` of ... has no attribute`, adding a type hint will fail. You MUST fix this by either inserting `assert variable is not None` before the operation, or adding `# type: ignore` to the end of the failing line.\n\n### REQUIRED XML FORMAT:\n\nExplanation of cascade fix...\n\n\n\nExact lines to replace\n\n\nNew replacement lines\n\n\n FINAL WARNING: Only output XML and code. If you add conversational text outside of or , the system will ROLLBACK.",
- "PIR.md": "You are an elite PYOB developer performing a post-implementation repair.\nAn automated attempt to implement a feature or bugfix has failed, resulting in the following errors.\n\n### Original Goal / Change Request:\n{context_of_change}\n\n### The Resulting Errors (Linter/Runtime):\n{err_text}\n\n### The Broken Code in `{rel_path}`:\n```\n{code}\n```\n\n### Instructions:\n1. Analyze the 'Original Goal' and the 'Resulting Errors' together.\n2. CRITICAL INDENTATION CHECK: If the error says `Unexpected indentation`, rewrite the block with PERFECT absolute indentation.\n3. CRITICAL IMPORT CHECK: If the error is `F821 Undefined name`, you MUST create an block at the top of the file to add the missing `import` statement.\n4. DEFEATING MYPY: If the error is a `[union-attr]` or `None` type error, simply adding a type hint will fail the linter again. You MUST insert `assert object is not None` right before it is used, or append `# type: ignore` to the failing line.\n5. Create a surgical XML edit to fix the code.\n6. **DELETING CODE**: If you are removing logic, never use an empty block. Always include a placeholder comment to maintain valid Python syntax.\n7. **VISUAL LEAK CHECK**: If the error logs or context suggest that raw code is visible to the user on the screen, this is a CRITICAL UI FAILURE. Locate the stray text and wrap it in the correct tags or delete it immediately.\n\n### REQUIRED XML FORMAT:\n\nRoot Cause: ...\nImports Needed: ...\nIndentation Fix Needed: [Yes/No]\nAction: ...\n\n\n\nExact lines to replace\n\n\nNew replacement lines\n\n\n FINAL WARNING: Only output XML and code. If you add conversational text outside of or , the system will ROLLBACK.",
- }
- for filename, content in templates.items():
- filepath = os.path.join(data_dir, filename) # Use data_dir here
- with open(filepath, "w", encoding="utf-8") as f:
- f.write(content)
-
- def load_prompt(self, filename: str, **kwargs: str) -> str:
- # Use a consistent path resolution
- data_dir = os.path.join(self.target_dir, ".pyob")
- filepath = os.path.join(data_dir, filename)
-
- try:
- with open(filepath, "r", encoding="utf-8") as f:
- template = f.read()
- for key, value in kwargs.items():
- template = template.replace(f"{{{key}}}", str(value))
- return template
- except Exception as e:
- logger.error(f"Failed to load prompt {filename} from {filepath}: {e}")
- return ""
-
- def _get_impactful_history(self) -> str:
- if not os.path.exists(self.history_path):
- return "No prior history."
- with open(self.history_path, "r", encoding="utf-8") as f:
- full_history = f.read()
- entries = re.split(r"## \d{4}-\d{2}-\d{2}", full_history)
- recent_entries = entries[-3:]
- summary = "### Significant Recent Architecture Changes:\n"
- for entry in recent_entries:
- lines = entry.strip().split("\n")
- if lines:
- summary += f"- {lines[0].strip()}\n"
- return summary
-
- def _get_rich_context(self) -> str:
- context = ""
- analysis_path = os.path.join(self.target_dir, "ANALYSIS.md")
- if os.path.exists(analysis_path):
- with open(analysis_path, "r", encoding="utf-8") as f:
- content = f.read()
- header_parts = content.split("## File Directory")
- header = header_parts[0].strip() if header_parts else ""
- context += f"### Project Goal:\n{header}\n\n"
-
- if os.path.exists(self.history_path):
- with open(self.history_path, "r", encoding="utf-8") as f:
- hist = f.read()
- headers = [line for line in hist.split("\n") if line.startswith("## ")]
- context += (
- "### Recent Edit History:\n" + "\n".join(headers[-3:]) + "\n\n"
- )
-
- if self.memory:
- mem_str = self.memory.strip()
- if len(mem_str) > 1500:
- mem_str = (
- mem_str[:500]
- + "\n\n... [OLDER MEMORY TRUNCATED FOR CONTEXT LIMITS] ...\n\n"
- + mem_str[-800:]
- )
-
- context += f"### Logic Memory:\n{mem_str}\n\n"
-
- return context
-
- def update_memory(self) -> None:
- session_context: list[str] = getattr(self, "session_context", [])
- if not session_context:
- return
- logger.info("\nPHASE 5: Updating MEMORY.md with session context...")
- session_summary = "\n".join(f"- {item}" for item in session_context)
- prompt = self.load_prompt(
- "UM.md",
- current_memory=self.memory if self.memory else "No previous memory.",
- session_summary=session_summary,
- )
-
- def validator(text: str) -> bool:
- return bool(text.strip())
-
- llm_response = getattr(self, "get_valid_llm_response")(
- prompt, validator, context="Memory Update"
- )
- clean_memory = re.sub(
- r"^```[a-zA-Z]*\n", "", llm_response.strip(), flags=re.MULTILINE
- )
- clean_memory = re.sub(r"\n```$", "", clean_memory, flags=re.MULTILINE)
- if clean_memory:
- with open(self.memory_path, "w", encoding="utf-8") as f:
- f.write(clean_memory)
- self.memory = clean_memory
-
- def refactor_memory(self) -> None:
- if not self.memory:
- return
- logger.info("\nPHASE 6: Cleanup! Summarizing and refactoring MEMORY.md...")
- prompt = self.load_prompt("RM.md", current_memory=self.memory)
-
- def validator(text: str) -> bool:
- return bool(text.strip())
-
- llm_response = getattr(self, "get_valid_llm_response")(
- prompt, validator, context="Memory Refactor"
- )
- clean_memory = re.sub(
- r"^```[a-zA-Z]*\n", "", llm_response.strip(), flags=re.MULTILINE
- )
- clean_memory = re.sub(r"\n```$", "", clean_memory, flags=re.MULTILINE)
- if clean_memory and len(clean_memory) > 50:
- with open(self.memory_path, "w", encoding="utf-8") as f:
- f.write(clean_memory)
- self.memory = clean_memory
diff --git a/src/pyob/pyob_code_parser.py b/src/pyob/pyob_code_parser.py
deleted file mode 100644
index f2e616b..0000000
--- a/src/pyob/pyob_code_parser.py
+++ /dev/null
@@ -1,141 +0,0 @@
-import ast
-import logging
-import os
-import re
-
-logger = logging.getLogger(__name__)
-
-
-class CodeParser:
- def generate_structure_dropdowns(self, filepath: str, code: str) -> str:
- ext = os.path.splitext(filepath)[1].lower()
- if ext == ".py":
- return self._parse_python(code)
- elif ext in [".js", ".ts", ".jsx", ".tsx"]:
- return self._parse_javascript(code)
- elif ext in [".html", ".htm"]:
- return self._parse_html(code)
- elif ext == ".css":
- return self._parse_css(code)
- return ""
-
- def _parse_python(self, code: str) -> str:
- try:
- tree = ast.parse(code)
- imports, classes, functions, consts = [], [], [], []
- for node in ast.walk(tree):
- if isinstance(node, (ast.Import, ast.ImportFrom)):
- try:
- imports.append(ast.unparse(node))
- except Exception:
- pass
- elif isinstance(node, ast.ClassDef):
- classes.append(f"class {node.name}")
- for child in node.body:
- if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
- args = [
- arg.arg for arg in child.args.args if arg.arg != "self"
- ]
- functions.append(
- f"def {node.name}.{child.name}({', '.join(args)})"
- )
- elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
- if not any(f".{node.name}(" in fn for fn in functions):
- args = [arg.arg for arg in node.args.args]
- if node.args.vararg:
- args.append(f"*{node.args.vararg.arg}")
- if node.args.kwarg:
- args.append(f"**{node.args.kwarg.arg}")
- functions.append(f"def {node.name}({', '.join(args)})")
- elif isinstance(node, ast.Assign):
- for t in node.targets:
- if isinstance(t, ast.Name) and t.id.isupper():
- consts.append(t.id)
-
- return self._format_dropdowns(imports, classes, functions, consts)
-
- except SyntaxError as e:
- logger.warning(
- f"AST parsing failed (SyntaxError: {e}). Falling back to Regex for structure map."
- )
- return self._parse_python_regex_fallback(code)
- except Exception as e:
- logger.error(f"Unexpected AST parse error: {e}")
- return ""
-
- def _parse_python_regex_fallback(self, code: str) -> str:
- """Used when a Python file has syntax errors so the AI isn't blinded."""
- imports = re.findall(r"^(?:import|from)\s+[a-zA-Z0-9_\.]+", code, re.MULTILINE)
- classes = [
- f"class {c}"
- for c in re.findall(r"^class\s+([a-zA-Z0-9_]+)", code, re.MULTILINE)
- ]
- functions = [
- f"def {f}()"
- for f in re.findall(r"^[ \t]*def\s+([a-zA-Z0-9_]+)", code, re.MULTILINE)
- ]
- consts = list(set(re.findall(r"^([A-Z_][A-Z0-9_]+)\s*=", code, re.MULTILINE)))
-
- return self._format_dropdowns(imports, classes, functions, consts)
-
- def _parse_javascript(self, code: str) -> str:
- imports = re.findall(r"(?:import|from|require)\s+['\"].*?['\"]", code)
- classes = re.findall(r"(?:class|interface)\s+([a-zA-Z0-9_$]+)", code)
- types = re.findall(r"type\s+([a-zA-Z0-9_$]+)\s*=", code)
- classes.extend([f"type {t}" for t in types])
-
- fn_patterns = [
- r"function\s+([a-zA-Z0-9_$]+)\s*\(([^)]*)\)",
- r"(?:const|let|var|window\.)\s*([a-zA-Z0-9_$]+)\s*=\s*(?:async\s*)?\(([^)]*)\)\s*=>",
- r"^\s*(?:async\s*)?([a-zA-Z0-9_$]+)\s*\(([^)]*)\)\s*\{",
- ]
- raw_fns = []
- for pattern in fn_patterns:
- raw_fns.extend(re.findall(pattern, code, re.MULTILINE))
-
- clean_fns = []
- seen = set()
- for name, params in raw_fns:
- if name not in seen and name not in [
- "if",
- "for",
- "while",
- "return",
- "catch",
- "switch",
- ]:
- clean_fns.append(f"{name}({params.strip()})")
- seen.add(name)
-
- entities = re.findall(r"(?:const|var|let)\s+([A-Z0-9_]{3,})", code)
- return self._format_dropdowns(
- imports, classes, sorted(clean_fns), sorted(list(set(entities)))
- )
-
- def _parse_html(self, code: str) -> str:
- scripts = re.findall(r" str:
- selectors = re.findall(r"([#\.][a-zA-Z0-9_-]+)\s*\{", code)
- unique_selectors = list(dict.fromkeys(selectors))
- return self._format_dropdowns([], [], unique_selectors[:50], [])
-
- def _format_dropdowns(self, imp: list, cls: list, fn: list, cnst: list) -> str:
- res = ""
- if imp:
- res += f"Imports ({len(imp)})
{'
'.join(sorted(imp))} \n"
- if cnst:
- res += f"Entities ({len(cnst)})
{'
'.join(sorted(cnst))} \n"
- if cls:
- res += f"Classes/Types ({len(cls)})
{'
'.join(sorted(cls))} \n"
- if fn:
- res += f"Logic ({len(fn)})
{'
'.join(sorted(fn))} \n"
- return res
diff --git a/src/pyob/pyob_dashboard.py b/src/pyob/pyob_dashboard.py
deleted file mode 100644
index 07825b7..0000000
--- a/src/pyob/pyob_dashboard.py
+++ /dev/null
@@ -1,510 +0,0 @@
-import json
-import os
-from http.server import BaseHTTPRequestHandler
-from typing import Any
-
-from .dashboard_html import OBSERVER_HTML
-
-
-class ObserverHandler(BaseHTTPRequestHandler):
- # The 'controller' type is 'Any' to avoid circular dependencies with the main application controller.
- controller: Any = None
-
- def _send_json_response(
- self, status_code: int, payload: dict, allow_cors: bool = True
- ):
- self.send_response(status_code)
- self.send_header("Content-type", "application/json")
- if allow_cors:
- self.send_header("Access-Control-Allow-Origin", "*")
- self.end_headers()
- self.wfile.write(json.dumps(payload).encode())
-
- def _send_controller_not_initialized_error(self):
- self.send_response(503) # Service Unavailable
- self.send_header("Content-type", "application/json")
- self.send_header("Access-Control-Allow-Origin", "*")
- self.end_headers()
- self.wfile.write(json.dumps({"error": "Controller not initialized"}).encode())
-
- def do_GET(self):
- if self.path == "/api/status":
- if self.controller is None:
- self._send_controller_not_initialized_error()
- return
- self.send_response(200)
- self.send_header("Content-type", "application/json")
- self.send_header("Access-Control-Allow-Origin", "*")
- self.end_headers()
- status = {
- "iteration": getattr(self.controller, "current_iteration", 1),
- "cascade_queue": getattr(self.controller, "cascade_queue", []),
- "ledger_stats": {
- "definitions": len(self.controller.ledger["definitions"]),
- "references": len(self.controller.ledger["references"]),
- },
- "analysis": self.controller._read_file(self.controller.analysis_path),
- "memory": self.controller._read_file(
- os.path.join(self.controller.target_dir, ".pyob", "MEMORY.md")
- ),
- "history": self.controller._read_file(self.controller.history_path)[
- -5000:
- ],
- "patches_count": len(self.controller.get_pending_patches())
- if hasattr(self.controller, "get_pending_patches")
- else 0,
- }
- self.wfile.write(json.dumps(status).encode())
- # New GET endpoint for pending patches
- elif self.path == "/api/pending_patches":
- if self.controller is None:
- self._send_controller_not_initialized_error()
- return
- try:
- pending_patches = (
- self.controller.get_pending_patches()
- ) # Assumes this method exists in EntranceController
- self.send_response(200)
- self.send_header("Content-type", "application/json")
- self.send_header("Access-Control-Allow-Origin", "*")
- self.end_headers()
- self.wfile.write(json.dumps({"patches": pending_patches}).encode())
- except AttributeError:
- self.send_response(500)
- self.send_header("Content-type", "application/json")
- self.end_headers()
- self.wfile.write(
- json.dumps(
- {
- "error": "Controller method 'get_pending_patches' not found. Ensure entrance.py is updated."
- }
- ).encode()
- )
- except Exception as e:
- self.send_response(500)
- self.send_header("Content-type", "application/json")
- self.end_headers()
- self.wfile.write(
- json.dumps({"error": f"Internal server error: {str(e)}"}).encode()
- )
- elif self.path == "/" or self.path == "/observer.html":
- self.send_response(200)
- self.send_header("Content-type", "text/html")
- self.end_headers()
- self.wfile.write(OBSERVER_HTML.encode())
- else:
- self.send_response(404)
- self.end_headers()
-
- def do_POST(self):
- if self.path == "/api/set_target_file":
- if self.controller is None:
- self.send_response(503)
- self.send_header("Content-type", "application/json")
- self.end_headers()
- self.wfile.write(
- json.dumps({"error": "Controller not initialized"}).encode()
- )
- return
-
- content_length = int(self.headers.get("Content-Length", 0))
- post_data = self.rfile.read(content_length)
- try:
- data = json.loads(post_data.decode("utf-8"))
- target_file = data.get("target_file")
-
- if not target_file:
- self.send_response(400)
- self.send_header("Content-type", "application/json")
- self.end_headers()
- self.wfile.write(
- json.dumps(
- {"error": "Missing 'target_file' in request body"}
- ).encode()
- )
- return
-
- # This method call depends on entrance.py being updated
- self.controller.set_manual_target_file(target_file)
-
- self.send_response(200)
- self.send_header("Content-type", "application/json")
- self.send_header("Access-Control-Allow-Origin", "*")
- self.end_headers()
- self.wfile.write(
- json.dumps(
- {
- "message": "Manual target file set",
- "target_file": target_file,
- }
- ).encode()
- )
-
- except json.JSONDecodeError:
- self.send_response(400)
- self.send_header("Content-type", "application/json")
- self.send_header("Access-Control-Allow-Origin", "*")
- self.end_headers()
- self.wfile.write(json.dumps({"error": "Invalid JSON"}).encode())
- except AttributeError:
- # If controller doesn't have set_manual_target_file yet
- self.send_response(500)
- self.send_header("Content-type", "application/json")
- self.end_headers()
- self.wfile.write(
- json.dumps(
- {
- "error": "Controller method 'set_manual_target_file' not found. Ensure entrance.py is updated."
- }
- ).encode()
- )
- except Exception as e:
- self.send_response(500)
- self.send_header("Content-type", "application/json")
- self.end_headers()
- self.wfile.write(
- json.dumps({"error": f"Internal server error: {str(e)}"}).encode()
- )
- # New POST endpoint for reviewing patches
- elif self.path == "/api/review_patch":
- if self.controller is None:
- self.send_response(503)
- self.send_header("Content-type", "application/json")
- self.end_headers()
- self.wfile.write(
- json.dumps({"error": "Controller not initialized"}).encode()
- )
- return
-
- content_length = int(self.headers.get("Content-Length", 0))
- post_data = self.rfile.read(content_length)
- try:
- data = json.loads(post_data.decode("utf-8"))
- patch_id = data.get("patch_id")
- action = data.get("action") # 'approve' or 'reject'
-
- if not patch_id or not action:
- self.send_response(400)
- self.send_header("Content-type", "application/json")
- self.end_headers()
- self.wfile.write(
- json.dumps(
- {"error": "Missing 'patch_id' or 'action' in request body"}
- ).encode()
- )
- return
- if action not in ["approve", "reject"]:
- self.send_response(400)
- self.send_header("Content-type", "application/json")
- self.end_headers()
- self.wfile.write(
- json.dumps(
- {"error": "Action must be 'approve' or 'reject'"}
- ).encode()
- )
- return
-
- self.controller.process_patch_review(
- patch_id, action
- ) # Assumes this method exists in EntranceController
-
- self.send_response(200)
- self.send_header("Content-type", "application/json")
- self.send_header("Access-Control-Allow-Origin", "*")
- self.end_headers()
- self.wfile.write(
- json.dumps(
- {
- "message": f"Patch {patch_id} {action}d successfully",
- "patch_id": patch_id,
- "action": action,
- }
- ).encode()
- )
-
- except json.JSONDecodeError:
- self.send_response(400)
- self.send_header("Content-type", "application/json")
- self.send_header("Access-Control-Allow-Origin", "*")
- self.end_headers()
- self.wfile.write(json.dumps({"error": "Invalid JSON"}).encode())
- except AttributeError:
- self.send_response(500)
- self.send_header("Content-type", "application/json")
- self.end_headers()
- self.wfile.write(
- json.dumps(
- {
- "error": "Controller method 'process_patch_review' not found. Ensure entrance.py is updated."
- }
- ).encode()
- )
- except Exception as e:
- self.send_response(500)
- self.send_header("Content-type", "application/json")
- self.end_headers()
- self.wfile.write(
- json.dumps({"error": f"Internal server error: {str(e)}"}).encode()
- )
- # NEW POST endpoint for updating Logic Memory
- elif self.path == "/api/update_memory":
- if self.controller is None:
- self.send_response(503)
- self.send_header("Content-type", "application/json")
- self.end_headers()
- self.wfile.write(
- json.dumps({"error": "Controller not initialized"}).encode()
- )
- return
-
- content_length = int(self.headers.get("Content-Length", 0))
- post_data = self.rfile.read(content_length)
- try:
- data = json.loads(post_data.decode("utf-8"))
- new_memory_content = data.get("content")
-
- if new_memory_content is None:
- self.send_response(400)
- self.send_header("Content-type", "application/json")
- self.end_headers()
- self.wfile.write(
- json.dumps(
- {"error": "Missing 'content' in request body"}
- ).encode()
- )
- return
-
- self.controller.update_memory(new_memory_content)
-
- self.send_response(200)
- self.send_header("Content-type", "application/json")
- self.send_header("Access-Control-Allow-Origin", "*")
- self.end_headers()
- self.wfile.write(
- json.dumps(
- {"message": "Logic Memory updated successfully"}
- ).encode()
- )
-
- except json.JSONDecodeError:
- self.send_response(400)
- self.send_header("Content-type", "application/json")
- self.end_headers()
- self.wfile.write(json.dumps({"error": "Invalid JSON"}).encode())
- except AttributeError:
- self.send_response(500)
- self.send_header("Content-type", "application/json")
- self.end_headers()
- self.wfile.write(
- json.dumps(
- {
- "error": "Controller method 'update_memory' not found. Ensure entrance.py is updated."
- }
- ).encode()
- )
- except Exception as e:
- self.send_response(500)
- self.send_header("Content-type", "application/json")
- self.end_headers()
- self.wfile.write(
- json.dumps({"error": f"Internal server error: {str(e)}"}).encode()
- )
- # NEW POST endpoint for moving cascade queue items
- elif self.path == "/api/cascade_queue/move":
- if self.controller is None:
- self.send_response(503)
- self.send_header("Content-type", "application/json")
- self.end_headers()
- self.wfile.write(
- json.dumps({"error": "Controller not initialized"}).encode()
- )
- return
-
- content_length = int(self.headers.get("Content-Length", 0))
- post_data = self.rfile.read(content_length)
- try:
- data = json.loads(post_data.decode("utf-8"))
- item_id = data.get("item_id")
- direction = data.get("direction")
-
- if not item_id or direction not in ["up", "down"]:
- self.send_response(400)
- self.send_header("Content-type", "application/json")
- self.end_headers()
- self.wfile.write(
- json.dumps(
- {
- "error": "Missing 'item_id' or invalid 'direction' in request body"
- }
- ).encode()
- )
- return
-
- self.controller.move_cascade_queue_item(item_id, direction)
-
- self.send_response(200)
- self.send_header("Content-type", "application/json")
- self.send_header("Access-Control-Allow-Origin", "*")
- self.end_headers()
- self.wfile.write(
- json.dumps(
- {"message": f"Item {item_id} moved {direction} successfully"}
- ).encode()
- )
-
- except json.JSONDecodeError:
- self.send_response(400)
- self.send_header("Content-type", "application/json")
- self.end_headers()
- self.wfile.write(json.dumps({"error": "Invalid JSON"}).encode())
- except AttributeError:
- self.send_response(500)
- self.send_header("Content-type", "application/json")
- self.end_headers()
- self.wfile.write(
- json.dumps(
- {
- "error": "Controller method 'move_cascade_queue_item' not found. Ensure entrance.py is updated."
- }
- ).encode()
- )
- except Exception as e:
- self.send_response(500)
- self.send_header("Content-type", "application/json")
- self.end_headers()
- self.wfile.write(
- json.dumps({"error": f"Internal server error: {str(e)}"}).encode()
- )
-
- # NEW POST endpoint for removing cascade queue items
- elif self.path == "/api/cascade_queue/remove":
- if self.controller is None:
- self.send_response(503)
- self.send_header("Content-type", "application/json")
- self.end_headers()
- self.wfile.write(
- json.dumps({"error": "Controller not initialized"}).encode()
- )
- return
-
- content_length = int(self.headers.get("Content-Length", 0))
- post_data = self.rfile.read(content_length)
- try:
- data = json.loads(post_data.decode("utf-8"))
- item_id = data.get("item_id")
-
- if not item_id:
- self.send_response(400)
- self.send_header("Content-type", "application/json")
- self.end_headers()
- self.wfile.write(
- json.dumps(
- {"error": "Missing 'item_id' in request body"}
- ).encode()
- )
- return
-
- self.controller.remove_cascade_queue_item(item_id)
-
- self.send_response(200)
- self.send_header("Content-type", "application/json")
- self.send_header("Access-Control-Allow-Origin", "*")
- self.end_headers()
- self.wfile.write(
- json.dumps(
- {"message": f"Item {item_id} removed successfully"}
- ).encode()
- )
-
- except json.JSONDecodeError:
- self.send_response(400)
- self.send_header("Content-type", "application/json")
- self.end_headers()
- self.wfile.write(json.dumps({"error": "Invalid JSON"}).encode())
- except AttributeError:
- self.send_response(500)
- self.send_header("Content-type", "application/json")
- self.end_headers()
- self.wfile.write(
- json.dumps(
- {
- "error": "Controller method 'remove_cascade_queue_item' not found. Ensure entrance.py is updated."
- }
- ).encode()
- )
- except Exception as e:
- self.send_response(500)
- self.send_header("Content-type", "application/json")
- self.end_headers()
- self.wfile.write(
- json.dumps({"error": f"Internal server error: {str(e)}"}).encode()
- )
- # NEW POST endpoint for adding items to cascade queue
- elif self.path == "/api/cascade_queue/add":
- if self.controller is None:
- self.send_response(503)
- self.send_header("Content-type", "application/json")
- self.end_headers()
- self.wfile.write(
- json.dumps({"error": "Controller not initialized"}).encode()
- )
- return
-
- content_length = int(self.headers.get("Content-Length", 0))
- post_data = self.rfile.read(content_length)
- try:
- data = json.loads(post_data.decode("utf-8"))
- item = data.get("item")
-
- if not item:
- self.send_response(400)
- self.send_header("Content-type", "application/json")
- self.end_headers()
- self.wfile.write(
- json.dumps({"error": "Missing 'item' in request body"}).encode()
- )
- return
-
- self.controller.add_to_cascade_queue(item)
-
- self.send_response(200)
- self.send_header("Content-type", "application/json")
- self.send_header("Access-Control-Allow-Origin", "*")
- self.end_headers()
- self.wfile.write(
- json.dumps(
- {
- "message": f"Item '{item}' added to cascade queue successfully"
- }
- ).encode()
- )
-
- except json.JSONDecodeError:
- self.send_response(400)
- self.send_header("Content-type", "application/json")
- self.end_headers()
- self.wfile.write(json.dumps({"error": "Invalid JSON"}).encode())
- except AttributeError:
- self.send_response(500)
- self.send_header("Content-type", "application/json")
- self.end_headers()
- self.wfile.write(
- json.dumps(
- {
- "error": "Controller method 'add_to_cascade_queue' not found. Ensure entrance.py is updated."
- }
- ).encode()
- )
- except Exception as e:
- self.send_response(500)
- self.send_header("Content-type", "application/json")
- self.end_headers()
- self.wfile.write(
- json.dumps({"error": f"Internal server error: {str(e)}"}).encode()
- )
- else:
- self.send_response(404)
- self.end_headers()
-
- def log_message(self, format: str, *args: object) -> None:
- return
diff --git a/src/pyob/pyob_launcher.py b/src/pyob/pyob_launcher.py
deleted file mode 100644
index 5c02bfe..0000000
--- a/src/pyob/pyob_launcher.py
+++ /dev/null
@@ -1,221 +0,0 @@
-import json
-import os
-import shlex
-import subprocess
-import sys
-from pathlib import Path
-
-CONFIG_FILE = Path.home() / ".pyob_config"
-
-DEFAULT_GEMINI_MODEL = "gemini-2.5-flash"
-DEFAULT_LOCAL_MODEL = "qwen3-coder:30b"
-
-# OBSERVER_PATCH_REVIEW_HTML content has been moved to pyob_dashboard.py
-# or a dedicated UI template file, as it is UI-specific content.
-
-
-def load_config():
- """Load config from file or environment, or prompt user if missing."""
- # 1. Check for Environment Variables (Ensures it works in GitHub Actions/Docker)
- # This takes highest priority as per "Override by PYOB_GEMINI_KEYS" rule.
- env_keys = os.environ.get("PYOB_GEMINI_KEYS")
- if env_keys:
- return {
- "gemini_keys": env_keys,
- "gemini_model": os.environ.get("PYOB_GEMINI_MODEL", DEFAULT_GEMINI_MODEL),
- "local_model": os.environ.get("PYOB_LOCAL_MODEL", DEFAULT_LOCAL_MODEL),
- }
-
- # 1. Check for Environment Variables (Ensures it works in GitHub Actions/Docker)
- # This takes highest priority as per "Override by PYOB_GEMINI_KEYS" rule.
- env_keys = os.environ.get("PYOB_GEMINI_KEYS")
- if env_keys:
- return {
- "gemini_keys": env_keys,
- "gemini_model": os.environ.get("PYOB_GEMINI_MODEL", DEFAULT_GEMINI_MODEL),
- "local_model": os.environ.get("PYOB_LOCAL_MODEL", DEFAULT_LOCAL_MODEL),
- }
-
- # 2. Try loading from the local configuration file
- if CONFIG_FILE.exists():
- try:
- with open(CONFIG_FILE, "r") as f:
- config_data = json.load(f)
- if isinstance(config_data, dict):
- return config_data
- else:
- raise ValueError("Configuration file content is not a dictionary.")
- except (json.JSONDecodeError, OSError, ValueError) as e:
- print(
- f"Warning: Configuration file {CONFIG_FILE} is invalid or inaccessible ({e}). Re-creating."
- )
- CONFIG_FILE.unlink(missing_ok=True) # Delete invalid file
-
- # 2. Check for Environment Variables (Ensures it works in GitHub Actions/Docker)
- env_keys = os.environ.get("PYOB_GEMINI_KEYS")
- if env_keys:
- return {
- "gemini_keys": env_keys,
- "gemini_model": os.environ.get("PYOB_GEMINI_MODEL", DEFAULT_GEMINI_MODEL),
- "local_model": os.environ.get("PYOB_LOCAL_MODEL", DEFAULT_LOCAL_MODEL),
- }
-
- # 3. Safety Check for Headless Environments
- if not sys.stdin.isatty():
- print("Error: No API keys found in environment and stdin is not a TTY.")
- print(" In GitHub Actions, please set the PYOB_GEMINI_KEYS secret.")
- sys.exit(1)
-
- # 4. Standard Interactive Setup (reached on local iMac first-run)
- print("PYOB First-Time Setup")
- print("═" * 40)
- print("\nStep 1: Gemini API Keys")
- print("Enter up to 10 keys separated by commas:")
- keys = input("Keys: ").strip()
- if not keys:
- print("Error: Gemini API keys cannot be empty during interactive setup.")
- sys.exit(1)
-
- print("\nStep 2: Model Configuration")
- print("WARNING: PYOB is optimized for 'gemini-2.0-flash' and 'qwen3-coder:30b'.")
- print(" Changing these may result in parsing errors or logic loops.")
-
- g_model = (
- input(f"\nEnter Gemini Model [default: {DEFAULT_GEMINI_MODEL}]: ").strip()
- or DEFAULT_GEMINI_MODEL
- )
- l_model = (
- input(f"Enter Local Ollama Model [default: {DEFAULT_LOCAL_MODEL}]: ").strip()
- or DEFAULT_LOCAL_MODEL
- )
-
- config = {"gemini_keys": keys, "gemini_model": g_model, "local_model": l_model}
-
- with open(CONFIG_FILE, "w") as f:
- json.dump(config, f, indent=4)
-
- print(f"\nConfiguration saved to {CONFIG_FILE}")
- print(" (To change these later, simply delete that file and restart PYOB.)\n")
- return config
-
-
-def ensure_terminal():
- if ".app/Contents/MacOS" in sys.executable and not os.isatty(sys.stdin.fileno()):
- script_path = shlex.quote(sys.argv[0])
- args = " ".join(shlex.quote(arg) for arg in sys.argv[1:])
- full_command = f"{sys.executable} {script_path} {args}".strip()
- cmd = f'tell application "Terminal" to do script "{full_command}"'
- subprocess.run(["osascript", "-e", cmd])
- sys.exit(0)
-
-
-def main():
- if sys.platform == "darwin":
- ensure_terminal()
-
- print("═" * 70)
- print(" PYOB Launcher")
- print("═" * 70)
-
- config = load_config()
-
- # Prioritize environment variables if set (e.g. by Docker/Actions)
- os.environ.setdefault("PYOB_GEMINI_KEYS", config.get("gemini_keys", ""))
- os.environ.setdefault(
- "PYOB_GEMINI_MODEL", config.get("gemini_model", DEFAULT_GEMINI_MODEL)
- )
- os.environ.setdefault(
- "PYOB_LOCAL_MODEL", config.get("local_model", DEFAULT_LOCAL_MODEL)
- )
- # Determine if dashboard is active, e.g., via environment variable
- dashboard_active = (
- os.environ.get("PYOB_DASHBOARD_ACTIVE", "false").lower() == "true"
- )
-
- from pyob.entrance import EntranceController
-
- if len(sys.argv) > 1:
- arg = sys.argv[1]
- # Filter out internal macOS process paths
- if ".app/Contents/MacOS" in arg or arg == sys.executable:
- if sys.stdin.isatty():
- target_dir = input(
- "\nEnter the FULL PATH to your target project directory\n"
- "(or just press Enter to use the current folder): "
- ).strip()
- else:
- target_dir = "."
- else:
- target_dir = arg
- else:
- if sys.stdin.isatty():
- target_dir = input(
- "\nEnter the FULL PATH to your target project directory\n"
- "(or just press Enter to use the current folder): "
- ).strip()
- else:
- target_dir = "."
-
- if not target_dir:
- target_dir = "."
-
- target_dir = os.path.abspath(target_dir)
-
- if not os.path.isdir(target_dir):
- print(f"Error: Directory does not exist → {target_dir}")
- if sys.stdin.isatty():
- input("\nPress Enter to exit...")
- sys.exit(1)
-
- # --- NEW: CLOUD GIT CONFIGURATION FIX ---
- if os.environ.get("GITHUB_ACTIONS") == "true":
- # 1. Fix the "dubious ownership" block for Docker volumes
- subprocess.run(
- ["git", "config", "--global", "--add", "safe.directory", "*"],
- capture_output=True,
- )
-
- # 2. Auto-set Bot Identity (Prevents "Author unknown" errors for Marketplace users)
- check_name = subprocess.run(
- ["git", "config", "user.name"], capture_output=True, text=True
- )
- if not check_name.stdout.strip():
- subprocess.run(
- ["git", "config", "--global", "user.name", "pyob-bot"],
- capture_output=True,
- )
- subprocess.run(
- [
- "git",
- "config",
- "--global",
- "user.email",
- "pyob-bot@users.noreply.github.com",
- ],
- capture_output=True,
- )
- # ----------------------------------------
-
- print(f"\nStarting PYOB on: {target_dir}")
- print(f"Gemini Model: {os.environ['PYOB_GEMINI_MODEL']}")
- print(f"Local Model: {os.environ['PYOB_LOCAL_MODEL']}")
- print(" (Terminal will stay open — press Ctrl+C to stop)\n")
-
- try:
- # Pass dashboard_active status to the EntranceController
- controller = EntranceController(target_dir, dashboard_active=dashboard_active)
- controller.run_master_loop()
- except KeyboardInterrupt:
- print("\n\nPYOB stopped by user.")
- except Exception as e:
- print(f"\nUnexpected error: {e}")
- import traceback
-
- traceback.print_exc()
- finally:
- if sys.stdin.isatty():
- input("\nPress Enter to close this window...")
-
-
-if __name__ == "__main__":
- main()
diff --git a/src/pyob/reviewer_mixins.py b/src/pyob/reviewer_mixins.py
deleted file mode 100644
index 3475f24..0000000
--- a/src/pyob/reviewer_mixins.py
+++ /dev/null
@@ -1,446 +0,0 @@
-import os
-import re
-import shutil
-import subprocess
-import sys
-
-from .core_utils import (
- IGNORE_DIRS,
- IGNORE_FILES,
- logger,
-)
-
-
-class ValidationMixin:
- target_dir: str
- session_context: list[str]
- memory: str
-
- def run_linter_fix_loop(self, context_of_change: str = "") -> bool:
- logger.info("\nValidating codebase syntax (Python, JS, CSS)...")
- success: bool = True
- try:
- subprocess.run(["ruff", "format", self.target_dir], capture_output=True)
-
- subprocess.run(
- ["ruff", "check", self.target_dir, "--fix"], capture_output=True
- )
-
- res = subprocess.run(
- ["ruff", "check", self.target_dir], capture_output=True, text=True
- )
- if res.returncode != 0:
- logger.warning(f"Ruff found logic errors:\n{res.stdout.strip()}")
- py_fixed = False
- for attempt in range(3):
- file_errors: dict[str, list[str]] = {}
- for line in res.stdout.splitlines():
- if ".py:" in line:
- filepath = line.split(":")[0].strip()
- if os.path.exists(filepath):
- file_errors.setdefault(filepath, []).append(line)
- if not file_errors:
- break
- for fpath, errs in file_errors.items():
- self._apply_linter_fixes(
- fpath, "\n".join(errs), context_of_change
- )
- recheck = subprocess.run(
- ["ruff", "check", self.target_dir],
- capture_output=True,
- text=True,
- )
- if recheck.returncode == 0:
- logger.info("Python Auto-fix successful!")
- py_fixed = True
- break
- if not py_fixed:
- logger.error("Python errors remain unfixable.")
- success = False
- except FileNotFoundError:
- pass
- try:
- js_files = []
- for root, dirs, files in os.walk(self.target_dir):
- dirs[:] = [d for d in dirs if d not in IGNORE_DIRS]
- for file in files:
- if file.endswith(".js") and file not in IGNORE_FILES:
- js_files.append(os.path.join(root, file))
- for js_file in js_files:
- res = subprocess.run(
- ["node", "--check", js_file], capture_output=True, text=True
- )
- if res.returncode != 0:
- rel_name = os.path.basename(js_file)
- err_msg = res.stderr.strip()
- logger.warning(f"JS Syntax Error in {rel_name}:\n{err_msg}")
- js_fixed = False
- for attempt in range(3):
- logger.info(
- f"Asking AI to fix JS syntax (Attempt {attempt + 1}/3)..."
- )
- self._apply_linter_fixes(js_file, err_msg, context_of_change)
- recheck = subprocess.run(
- ["node", "--check", js_file], capture_output=True, text=True
- )
- if recheck.returncode == 0:
- logger.info(f"JS Auto-fix successful for {rel_name}!")
- js_fixed = True
- break
- if not js_fixed:
- logger.error(f"JS syntax in {rel_name} remains broken.")
- success = False
- break
- except FileNotFoundError:
- logger.info("Node.js not installed. Skipping JS syntax validation.")
- for root, dirs, files in os.walk(self.target_dir):
- dirs[:] = [d for d in dirs if d not in IGNORE_DIRS]
- for file in files:
- if file.endswith(".css") and file not in IGNORE_FILES:
- path = os.path.join(root, file)
- try:
- with open(path, "r", encoding="utf-8") as f:
- css_content = f.read()
- if css_content.count("{") != css_content.count("}"):
- logger.error(
- f"CSS Syntax Error in {file}: Unbalanced braces."
- )
- success = False
- except Exception:
- pass
- return success
-
- def _apply_linter_fixes(
- self, filepath: str, err_text: str, context_of_change: str = ""
- ):
- with open(filepath, "r", encoding="utf-8") as f:
- code = f.read()
- rel_path = os.path.relpath(filepath, self.target_dir)
- if context_of_change:
- logger.info(f"Applying CONTEXT-AWARE fix for `{rel_path}`...")
- prompt = getattr(self, "load_prompt")(
- "PIR.md",
- context_of_change=context_of_change,
- rel_path=rel_path,
- err_text=err_text,
- code=code,
- )
- else:
- logger.info(f"Applying standard linter fix for `{rel_path}`...")
- prompt = getattr(self, "load_prompt")(
- "ALF.md", rel_path=rel_path, err_text=err_text, code=code
- )
- new_code, _, _ = getattr(self, "get_valid_edit")(
- prompt, code, require_edit=True, target_filepath=filepath
- )
- if new_code != code:
- with open(filepath, "w", encoding="utf-8") as f:
- f.write(new_code)
- self.session_context.append(
- f"Auto-fixed syntax/linting errors in `{rel_path}`."
- )
-
- def run_and_verify_app(self, context_of_change: str = "") -> bool:
- check_script = os.path.join(self.target_dir, "check.sh")
-
- if os.path.exists(check_script):
- logger.info("PHASE 3.5: Running full validation suite (check.sh)...")
- try:
- os.chmod(check_script, 0o755)
-
- subprocess.run(
- [check_script, "--fix"],
- capture_output=True,
- text=True,
- cwd=self.target_dir,
- )
-
- res = subprocess.run(
- [check_script], capture_output=True, text=True, cwd=self.target_dir
- )
-
- if res.returncode != 0:
- logger.warning(
- f"Validation suite failed after auto-fix!\n{res.stdout.strip()}"
- )
- self._fix_runtime_errors(
- res.stdout + "\n" + res.stderr,
- "Validation Suite",
- context_of_change,
- )
- return False
- except Exception as e:
- logger.error(f"Failed to execute validation script: {e}")
- return False
- else:
- logger.warning("No check.sh found in target project. Skipping PHASE 3.5.")
-
- entry_file = getattr(self, "_find_entry_file")()
- if not entry_file:
- logger.warning("No entry point detected. Skipping runtime smoke test.")
- return True
-
- venv_python = os.path.join(self.target_dir, "build_env", "bin", "python3")
- if not os.path.exists(venv_python):
- venv_python = os.path.join(self.target_dir, "venv", "bin", "python3")
-
- python_cmd = venv_python if os.path.exists(venv_python) else sys.executable
-
- for attempt in range(3):
- logger.info(
- f"\nPHASE 4: Runtime Verification. Launching {os.path.basename(entry_file)} (Attempt {attempt + 1}/3)..."
- )
-
- is_html = entry_file.endswith((".html", ".htm"))
-
- process = subprocess.Popen(
- [python_cmd, entry_file],
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
- text=True,
- cwd=self.target_dir,
- )
-
- stdout, stderr = "", ""
- try:
- stdout, stderr = process.communicate(timeout=10)
- except subprocess.TimeoutExpired:
- process.terminate()
- try:
- stdout, stderr = process.communicate(timeout=2)
- except subprocess.TimeoutExpired:
- process.kill()
- stdout, stderr = process.communicate()
-
- error_keywords = [
- "Traceback",
- "Exception:",
- "Error:",
- "NameError:",
- "AttributeError:",
- ]
-
- if is_html:
- logger.info("HTML Entry detected. Verification assumed successful.")
- return True
-
- has_crash = any(kw in stderr or kw in stdout for kw in error_keywords) or (
- process.returncode != 0 and process.returncode not in (0, 15, -15, None)
- )
-
- if not has_crash:
- logger.info("App ran successfully for 10 seconds.")
- return True
-
- logger.warning(f"App crashed!\n{stderr}")
- self._fix_runtime_errors(
- stderr + "\n" + stdout, entry_file, context_of_change
- )
-
- logger.error("Exhausted runtime auto-fix attempts.")
- return False
-
- def _fix_runtime_errors(
- self, logs: str, entry_file: str, context_of_change: str = ""
- ):
- """Detects crashes. Handles missing packages automatically, otherwise asks AI."""
- package_match = re.search(r"ModuleNotFoundError: No module named '(.*?)'", logs)
- if not package_match:
- package_match = re.search(r"ImportError: No module named '(.*?)'", logs)
-
- if package_match:
- pkg = package_match.group(1)
- logger.info(
- f"Auto-detected missing dependency: {pkg}. Attempting pip install..."
- )
- venv_python = os.path.join(self.target_dir, "build_env", "bin", "python3")
- if not os.path.exists(venv_python):
- venv_python = os.path.join(self.target_dir, "venv", "bin", "python3")
-
- if os.path.exists(venv_python):
- python_cmd = venv_python
- elif getattr(sys, "frozen", False):
- python_cmd = (
- shutil.which("python3") or shutil.which("python") or "python3"
- )
- else:
- python_cmd = sys.executable
- try:
- subprocess.run(
- [
- python_cmd,
- "-m",
- "pip",
- "install",
- pkg,
- "--break-system-packages",
- ],
- check=True,
- )
- subprocess.run(
- [
- python_cmd,
- "-m",
- "pip",
- "install",
- f"types-{pkg}",
- "--break-system-packages",
- ],
- capture_output=True,
- )
- logger.info(
- f"Successfully installed {pkg}. System will now retry launch."
- )
-
- # --- AUTO-DEPENDENCY LOCKING ---
- try:
- req_path = os.path.join(
- getattr(self, "target_dir"), "requirements.txt"
- )
- with open(req_path, "w", encoding="utf-8") as f_req:
- subprocess.run(
- [python_cmd, "-m", "pip", "freeze"],
- stdout=f_req,
- check=True,
- )
- logger.info("Auto-locked dependencies in requirements.txt")
- except Exception as e:
- logger.warning(f"Failed to lock dependencies: {e}")
- # -------------------------------
-
- return
- except subprocess.CalledProcessError as e:
- logger.error(f"Failed to install {pkg} automatically: {e}")
-
- tb_files = re.findall(r'File "([^"]+)"', logs)
- target_file = entry_file
- for f in reversed(tb_files):
- abs_f = os.path.abspath(f)
- if (
- abs_f.startswith(self.target_dir)
- and not any(ign in abs_f for ign in IGNORE_DIRS)
- and os.path.exists(abs_f)
- ):
- target_file = abs_f
- break
- rel_path = os.path.relpath(target_file, self.target_dir)
- with open(target_file, "r", encoding="utf-8") as f_obj:
- code = f_obj.read()
- if context_of_change:
- logger.info(
- f"Applying CONTEXT-AWARE fix for runtime crash in `{rel_path}`..."
- )
- prompt = getattr(self, "load_prompt")(
- "PIR.md",
- context_of_change=context_of_change,
- rel_path=rel_path,
- err_text=logs[-2000:],
- code=code,
- )
- else:
- logger.info(f"Applying standard fix for runtime crash in `{rel_path}`...")
- memory_section = (
- f"### Project Memory / Context:\n{self.memory}\n\n"
- if self.memory
- else ""
- )
- prompt = getattr(self, "load_prompt")(
- "FRE.md",
- memory_section=memory_section,
- logs=logs[-2000:],
- rel_path=rel_path,
- code=code,
- )
- new_code, explanation, _ = getattr(self, "get_valid_edit")(
- prompt, code, require_edit=True, target_filepath=target_file
- )
- if new_code != code:
- with open(target_file, "w", encoding="utf-8") as f_out:
- f_out.write(new_code)
- logger.info(f"AI Auto-patched runtime crash in `{rel_path}`")
- self.session_context.append(
- f"Auto-fixed runtime crash in `{rel_path}`: {explanation}"
- )
- self.run_linter_fix_loop()
-
- def check_downstream_breakages(self, target_path: str, rel_path: str) -> bool:
- logger.info(
- f"\nPHASE 3: Simulating workspace to check for downstream breakages caused by {rel_path} edits..."
- )
- try:
- excludes = (
- set(IGNORE_DIRS) | set(IGNORE_FILES) | {os.path.basename(__file__)}
- )
- exclude_regex = (
- r"(^|/|\\)(" + "|".join(re.escape(x) for x in excludes) + r")(/|\\|$)"
- )
- result = subprocess.run(
- [
- "mypy",
- self.target_dir,
- "--exclude",
- exclude_regex,
- "--ignore-missing-imports",
- ],
- capture_output=True,
- text=True,
- )
- if "error:" in result.stdout:
- logger.warning(
- f"Downstream Breakage Detected!\n{result.stdout.strip()}"
- )
- return self.propose_cascade_fix(result.stdout.strip(), rel_path)
- logger.info("No downstream breakages detected.")
- return True
- except Exception as e:
- logger.error(f"Error during Phase 3 assessment: {e}")
- return True
-
- def propose_cascade_fix(self, mypy_errors: str, trigger_file: str) -> bool:
- problem_file = None
- for line in mypy_errors.splitlines():
- if ".py:" in line:
- candidate = line.split(":")[0].strip()
- if (
- os.path.exists(candidate)
- and os.path.basename(candidate) not in IGNORE_FILES
- and not any(ign in candidate for ign in IGNORE_DIRS)
- ):
- problem_file = candidate
- if trigger_file not in candidate:
- break
- if not problem_file:
- return False
- rel_broken_path = os.path.relpath(problem_file, self.target_dir)
- with open(problem_file, "r", encoding="utf-8") as f:
- broken_code = f.read()
- memory_section = (
- f"### Project Context:\n{self.memory}\n\n" if self.memory else ""
- )
- prompt = getattr(self, "load_prompt")(
- "PCF.md",
- memory_section=memory_section,
- trigger_file=trigger_file,
- rel_broken_path=rel_broken_path,
- mypy_errors=mypy_errors,
- broken_code=broken_code,
- )
- new_code, _, _ = getattr(self, "get_valid_edit")(
- prompt, broken_code, require_edit=True, target_filepath=problem_file
- )
- if new_code != broken_code:
- with open(problem_file, "w", encoding="utf-8") as f:
- f.write(new_code)
- logger.info(
- f"Auto-patched cascading fix directly into `{os.path.basename(problem_file)}`"
- )
- self.session_context.append(
- f"Auto-applied downstream cascade fix in `{os.path.basename(problem_file)}`"
- )
- self.run_linter_fix_loop()
- final_check = subprocess.run(
- ["mypy", problem_file, "--ignore-missing-imports"], capture_output=True
- )
- return final_check.returncode == 0
- logger.error(f"Failed to auto-patch `{rel_broken_path}`.")
- return False
diff --git a/src/pyob/scanner_mixins.py b/src/pyob/scanner_mixins.py
deleted file mode 100644
index 2db6ba0..0000000
--- a/src/pyob/scanner_mixins.py
+++ /dev/null
@@ -1,21 +0,0 @@
-import os
-
-from .core_utils import IGNORE_DIRS, IGNORE_FILES, SUPPORTED_EXTENSIONS
-
-
-class ScannerMixin:
- # Type hint so Mypy knows this mixin expects a target_dir attribute
- target_dir: str
-
- def scan_directory(self) -> list[str]:
- file_list = []
- for root, dirs, files in os.walk(self.target_dir):
- dirs[:] = [d for d in dirs if d not in IGNORE_DIRS]
- for file in files:
- if file in IGNORE_FILES:
- continue
- if file.endswith(".spec") or file.endswith(".dmg"):
- continue
- if any(file.endswith(ext) for ext in SUPPORTED_EXTENSIONS):
- file_list.append(os.path.join(root, file))
- return file_list
diff --git a/src/pyob/stats_updater.py b/src/pyob/stats_updater.py
deleted file mode 100644
index e326836..0000000
--- a/src/pyob/stats_updater.py
+++ /dev/null
@@ -1,71 +0,0 @@
-import json
-
-from dashboard_server import fetch_api
-
-
-class StatsUpdater:
- async def update_stats(self):
- try:
- response = await fetch_api("/api/status")
- data = await response.json()
- return data
- except Exception as e:
- print(f"Error updating stats: {e}")
- return None
-
- async def update_pending_patches(self):
- try:
- response = await fetch_api("/api/pending_patches")
- data = await response.json()
- return data
- except Exception as e:
- print(f"Failed to fetch pending patches: {e}")
- return None
-
- async def review_patch(self, patch_id, action):
- try:
- await fetch_api(
- "/api/review_patch",
- method="POST",
- data=json.dumps({"patch_id": patch_id, "action": action}),
- )
- except Exception as e:
- print(f"Failed to {action} patch {patch_id}: {e}")
-
- async def save_memory(self, memory_content):
- try:
- await fetch_api(
- "/api/update_memory",
- method="POST",
- data=json.dumps({"content": memory_content}),
- )
- except Exception as e:
- print(f"Failed to save Logic Memory: {e}")
-
- async def add_cascade_item(self, item):
- try:
- await fetch_api(
- "/api/cascade_queue/add", method="POST", data=json.dumps({"item": item})
- )
- except Exception as e:
- print(f"Failed to add item to cascade queue: {e}")
-
- async def move_queue_item(self, item_id, direction):
- try:
- await fetch_api(
- "/api/cascade_queue/move",
- method="POST",
- data=json.dumps({"item_id": item_id, "direction": direction}),
- )
- except Exception as e:
- print(f"Failed to move item {item_id} {direction}: {e}")
-
- async def remove_queue_item(self, item_id):
- try:
- await fetch_api(
- "/api/cascade_queue/remove",
- method="POST",
- data=json.dumps({"item_id": item_id}),
- )
- except Exception as e:
- print(f"Failed to remove item {item_id}: {e}")
diff --git a/src/pyob/targeted_reviewer.py b/src/pyob/targeted_reviewer.py
deleted file mode 100644
index 95f46d9..0000000
--- a/src/pyob/targeted_reviewer.py
+++ /dev/null
@@ -1,26 +0,0 @@
-import os
-
-from .autoreviewer import AutoReviewer
-from .xml_mixin import ApplyXMLMixin
-
-
-class TargetedReviewer(AutoReviewer, ApplyXMLMixin):
- """
- Specialized reviewer that targets a single specific file.
- Automatically handles both absolute and relative path inputs for cross-platform stability.
- """
-
- def __init__(self, target_dir: str, target_file: str):
- super().__init__(target_dir)
-
- if os.path.isabs(target_file):
- self.forced_target_file = os.path.relpath(target_file, self.target_dir)
- else:
- self.forced_target_file = target_file
-
- def scan_directory(self) -> list[str]:
- """Returns only the specific targeted file for the pipeline to process."""
- full_target_path = os.path.join(self.target_dir, self.forced_target_file)
- if os.path.exists(full_target_path):
- return [full_target_path]
- return []
diff --git a/src/pyob/xml_mixin.py b/src/pyob/xml_mixin.py
deleted file mode 100644
index 73e3af6..0000000
--- a/src/pyob/xml_mixin.py
+++ /dev/null
@@ -1,240 +0,0 @@
-import ast
-import re
-
-from .core_utils import logger
-
-
-class ApplyXMLMixin:
- def ensure_imports_retained(
- self, orig_code: str, new_code: str, filepath: str
- ) -> str:
- try:
- orig_tree = ast.parse(orig_code)
- new_tree = ast.parse(new_code)
- except Exception:
- return new_code
- orig_imports = []
- for node in orig_tree.body:
- if isinstance(node, (ast.Import, ast.ImportFrom)):
- start_line = node.lineno - 1
- end_line = getattr(node, "end_lineno", node.lineno)
- import_text = "\n".join(orig_code.splitlines()[start_line:end_line])
- orig_imports.append((node, import_text))
- missing_imports = []
- for orig_node, import_text in orig_imports:
- found = False
- for new_node in new_tree.body:
- if isinstance(new_node, type(orig_node)):
- if isinstance(orig_node, ast.Import) and isinstance(
- new_node, ast.Import
- ):
- if {alias.name for alias in orig_node.names}.issubset(
- {alias.name for alias in new_node.names}
- ):
- found = True
- break
- elif isinstance(orig_node, ast.ImportFrom) and isinstance(
- new_node, ast.ImportFrom
- ):
- if orig_node.module == new_node.module and {
- alias.name for alias in orig_node.names
- }.issubset({alias.name for alias in new_node.names}):
- found = True
- break
- if not found:
- missing_imports.append(import_text)
- if missing_imports:
- return "\n".join(missing_imports) + "\n\n" + new_code
- return new_code
-
- def apply_xml_edits(
- self, source_code: str, llm_response: str
- ) -> tuple[str, str, bool]:
- source_code = source_code.replace("\r\n", "\n")
- llm_response = llm_response.replace("\r\n", "\n")
-
- explanation = self._extract_explanation(llm_response)
- matches = self._extract_edit_blocks(llm_response)
-
- if not matches:
- return source_code, explanation, True
-
- new_code = source_code
- all_edits_succeeded = True
-
- for m in matches:
- raw_search = re.sub(
- r"^```[\w]*\n|\n```$", "", m.group(1), flags=re.MULTILINE
- )
- raw_replace = re.sub(
- r"^```[\w]*\n|\n```$", "", m.group(2), flags=re.MULTILINE
- )
-
- raw_replace = self._fix_replace_indentation(raw_search, raw_replace)
-
- new_code, success = self._apply_single_block(
- new_code, raw_search, raw_replace
- )
- if not success:
- all_edits_succeeded = False
-
- return new_code, explanation, all_edits_succeeded
-
- # ==========================================
- # PRIVATE HELPER METHODS FOR XML PATCHING
- # ==========================================
-
- def _extract_explanation(self, llm_response: str) -> str:
- thought_match = re.search(
- r"(.*?)", llm_response, re.DOTALL | re.IGNORECASE
- )
- return (
- thought_match.group(1).strip()
- if thought_match
- else "No explanation provided."
- )
-
- def _extract_edit_blocks(self, llm_response: str) -> list[re.Match]:
- pattern = re.compile(
- r"\s*\s*\n?(.*?)\n?\s*\s*\s*\n?(.*?)\n?\s*\s*",
- re.DOTALL | re.IGNORECASE,
- )
- return list(pattern.finditer(llm_response))
-
- def _fix_replace_indentation(self, search: str, replace: str) -> str:
- search_lines = search.split("\n")
- replace_lines = replace.split("\n")
-
- search_indent = ""
- for line in search_lines:
- if line.strip():
- search_indent = line[: len(line) - len(line.lstrip(" \t"))]
- break
-
- replace_base_indent = ""
- for line in replace_lines:
- if line.strip():
- replace_base_indent = line[: len(line) - len(line.lstrip(" \t"))]
- break
-
- fixed_replace_lines = []
- for line in replace_lines:
- if line.strip():
- if line.startswith(replace_base_indent):
- clean_line = line[len(replace_base_indent) :]
- else:
- clean_line = line.lstrip(" \t")
- fixed_replace_lines.append(search_indent + clean_line)
- else:
- fixed_replace_lines.append("")
- return "\n".join(fixed_replace_lines)
-
- def _apply_single_block(
- self, source: str, search: str, replace: str
- ) -> tuple[str, bool]:
- # Strategy 1: Exact Match
- if search in source:
- return source.replace(search, replace, 1), True
-
- clean_search = search.strip("\n")
- clean_replace = replace.strip("\n")
-
- # Strategy 2: Clean Exact Match
- if clean_search and clean_search in source:
- return source.replace(clean_search, clean_replace, 1), True
-
- # Strategy 3: Normalized Match
- source, success = self._attempt_normalized_match(source, search, replace)
- if success:
- return source, True
-
- # Strategy 4: Regex Fuzzy Match
- source, success = self._attempt_regex_fuzzy_match(source, clean_search, replace)
- if success:
- return source, True
-
- # Strategy 5: Line-by-Line Robust Match
- source, success = self._attempt_line_by_line_match(source, search, replace)
- if success:
- return source, True
-
- return source, False
-
- def _attempt_normalized_match(
- self, source: str, search: str, replace: str
- ) -> tuple[str, bool]:
- def normalize(t: str) -> str:
- t = re.sub(r"#.*", "", t)
- return re.sub(r"\s+", " ", t).strip()
-
- norm_search = normalize(search)
- if not norm_search:
- return source, False
-
- search_lines = search.split("\n")
- lines = source.splitlines()
- for i in range(len(lines)):
- test_block = normalize("\n".join(lines[i : i + len(search_lines)]))
- if norm_search in test_block:
- lines[i : i + len(search_lines)] = replace.splitlines()
- logger.info(f"Normalization match succeeded at line {i + 1}.")
- return "\n".join(lines), True
- return source, False
-
- def _attempt_regex_fuzzy_match(
- self, source: str, clean_search: str, replace: str
- ) -> tuple[str, bool]:
- try:
- search_lines_cleaned = [
- line.strip() for line in clean_search.split("\n") if line.strip()
- ]
- if not search_lines_cleaned:
- return source, False
-
- regex_parts = [
- r"^[ \t]*" + re.escape(line) + r"[ \t]*\n+"
- for line in search_lines_cleaned[:-1]
- ]
- regex_parts.append(
- r"^[ \t]*" + re.escape(search_lines_cleaned[-1]) + r"[ \t]*\n?"
- )
- pattern_str = r"".join(regex_parts)
- fuzzy_match = re.search(pattern_str, source, re.MULTILINE)
- if fuzzy_match:
- new_code = (
- source[: fuzzy_match.start()]
- + replace
- + "\n"
- + source[fuzzy_match.end() :]
- )
- return new_code, True
- except Exception:
- pass
- return source, False
-
- def _attempt_line_by_line_match(
- self, source: str, search: str, replace: str
- ) -> tuple[str, bool]:
- search_lines = search.split("\n")
- search_lines_stripped = [line.strip() for line in search_lines if line.strip()]
- if not search_lines_stripped:
- return source, False
-
- replace_lines = replace.split("\n")
- code_lines = source.splitlines()
- for i in range(len(code_lines) - len(search_lines_stripped) + 1):
- match = True
- for j, sline in enumerate(search_lines_stripped):
- if sline not in code_lines[i + j].strip():
- match = False
- break
- if match:
- new_code_lines = (
- code_lines[:i] + replace_lines + code_lines[i + len(search_lines) :]
- )
- new_code = "\n".join(new_code_lines)
- if not new_code.endswith("\n") and source.endswith("\n"):
- new_code += "\n"
- logger.info(f"Robust fuzzy match succeeded at line {i + 1}.")
- return new_code, True
- return source, False
From 4eae350ab82d84a1f2c5e7cd4e35fe9b89df2eac Mon Sep 17 00:00:00 2001
From: Vic <125237471+vicsanity623@users.noreply.github.com>
Date: Mon, 23 Mar 2026 05:48:24 -0700
Subject: [PATCH 2/3] Create __init__.py
---
src/pyob/__init__.py | 1 +
1 file changed, 1 insertion(+)
create mode 100644 src/pyob/__init__.py
diff --git a/src/pyob/__init__.py b/src/pyob/__init__.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/src/pyob/__init__.py
@@ -0,0 +1 @@
+
From 5ccaeb9cf3cef39276b68af096685433ab6b187d Mon Sep 17 00:00:00 2001
From: Vic <125237471+vicsanity623@users.noreply.github.com>
Date: Mon, 23 Mar 2026 05:49:39 -0700
Subject: [PATCH 3/3] Add files via upload
---
src/pyob/autoreviewer.py | 454 +++++++++++++++++++++++++
src/pyob/cascade_queue_handler.py | 58 ++++
src/pyob/core_utils.py | 504 +++++++++++++++++++++++++++
src/pyob/dashboard_html.py | 438 ++++++++++++++++++++++++
src/pyob/dashboard_server.py | 234 +++++++++++++
src/pyob/data_parser.py | 38 +++
src/pyob/entrance.py | 547 ++++++++++++++++++++++++++++++
src/pyob/entrance_mixins.py | 274 +++++++++++++++
src/pyob/evolution_mixins.py | 321 ++++++++++++++++++
src/pyob/feature_mixins.py | 340 +++++++++++++++++++
src/pyob/get_valid_edit.py | 270 +++++++++++++++
src/pyob/models.py | 386 +++++++++++++++++++++
src/pyob/prompts_and_memory.py | 153 +++++++++
src/pyob/pyob_code_parser.py | 141 ++++++++
src/pyob/pyob_dashboard.py | 510 ++++++++++++++++++++++++++++
src/pyob/pyob_launcher.py | 221 ++++++++++++
src/pyob/reviewer_mixins.py | 446 ++++++++++++++++++++++++
src/pyob/scanner_mixins.py | 21 ++
src/pyob/stats_updater.py | 71 ++++
src/pyob/targeted_reviewer.py | 26 ++
src/pyob/xml_mixin.py | 240 +++++++++++++
21 files changed, 5693 insertions(+)
create mode 100644 src/pyob/autoreviewer.py
create mode 100644 src/pyob/cascade_queue_handler.py
create mode 100644 src/pyob/core_utils.py
create mode 100644 src/pyob/dashboard_html.py
create mode 100644 src/pyob/dashboard_server.py
create mode 100644 src/pyob/data_parser.py
create mode 100644 src/pyob/entrance.py
create mode 100644 src/pyob/entrance_mixins.py
create mode 100644 src/pyob/evolution_mixins.py
create mode 100644 src/pyob/feature_mixins.py
create mode 100644 src/pyob/get_valid_edit.py
create mode 100644 src/pyob/models.py
create mode 100644 src/pyob/prompts_and_memory.py
create mode 100644 src/pyob/pyob_code_parser.py
create mode 100644 src/pyob/pyob_dashboard.py
create mode 100644 src/pyob/pyob_launcher.py
create mode 100644 src/pyob/reviewer_mixins.py
create mode 100644 src/pyob/scanner_mixins.py
create mode 100644 src/pyob/stats_updater.py
create mode 100644 src/pyob/targeted_reviewer.py
create mode 100644 src/pyob/xml_mixin.py
diff --git a/src/pyob/autoreviewer.py b/src/pyob/autoreviewer.py
new file mode 100644
index 0000000..569676c
--- /dev/null
+++ b/src/pyob/autoreviewer.py
@@ -0,0 +1,454 @@
+import ast
+import os
+import random
+import subprocess
+import sys
+import time
+import uuid
+
+import requests
+
+from .core_utils import (
+ ANALYSIS_FILE,
+ FAILED_FEATURE_FILE_NAME,
+ FAILED_PR_FILE_NAME,
+ FEATURE_FILE_NAME,
+ GEMINI_API_KEYS,
+ HISTORY_FILE,
+ MEMORY_FILE_NAME,
+ PR_FILE_NAME,
+ SYMBOLS_FILE,
+ CoreUtilsMixin,
+ logger,
+)
+from .feature_mixins import FeatureOperationsMixin
+from .get_valid_edit import GetValidEditMixin
+from .prompts_and_memory import PromptsAndMemoryMixin
+from .reviewer_mixins import ValidationMixin
+from .scanner_mixins import ScannerMixin
+
+
+class AutoReviewer(
+ CoreUtilsMixin,
+ PromptsAndMemoryMixin,
+ ValidationMixin,
+ FeatureOperationsMixin,
+ ScannerMixin,
+ GetValidEditMixin,
+):
+ _shared_cooldowns: dict[str, float] | None = None
+ DASHBOARD_BASE_URL: str = os.environ.get(
+ "PYOB_DASHBOARD_URL", "http://localhost:8000"
+ )
+
+ def __init__(self, target_dir: str):
+ self.target_dir = os.path.abspath(target_dir)
+ self.pyob_dir = os.path.join(self.target_dir, ".pyob")
+ os.makedirs(self.pyob_dir, exist_ok=True)
+ self.pr_file = os.path.join(self.pyob_dir, PR_FILE_NAME)
+ self.feature_file = os.path.join(self.pyob_dir, FEATURE_FILE_NAME)
+ self.failed_pr_file = os.path.join(self.pyob_dir, FAILED_PR_FILE_NAME)
+ self.failed_feature_file = os.path.join(self.pyob_dir, FAILED_FEATURE_FILE_NAME)
+ self.memory_path = os.path.join(self.pyob_dir, MEMORY_FILE_NAME)
+ self.analysis_path = os.path.join(self.pyob_dir, ANALYSIS_FILE)
+ self.history_path = os.path.join(self.pyob_dir, HISTORY_FILE)
+ self.symbols_path = os.path.join(self.pyob_dir, SYMBOLS_FILE)
+ self.memory = self.load_memory()
+ self.session_context: list[str] = []
+ self.manual_target_file: str | None = None
+ self._ensure_prompt_files()
+ if AutoReviewer._shared_cooldowns is None:
+ AutoReviewer._shared_cooldowns = {
+ key: 0.0 for key in GEMINI_API_KEYS if key.strip()
+ }
+
+ self.key_cooldowns = AutoReviewer._shared_cooldowns
+
+ def get_language_info(self, filepath: str) -> tuple[str, str]:
+ ext = os.path.splitext(filepath)[1].lower()
+ mapping = {
+ ".py": ("Python", "python"),
+ ".js": ("JavaScript", "javascript"),
+ ".ts": ("TypeScript", "typescript"),
+ ".html": ("HTML", "html"),
+ ".css": ("CSS", "css"),
+ ".json": ("JSON", "json"),
+ ".sh": ("Bash", "bash"),
+ ".md": ("Markdown", "markdown"),
+ }
+ return mapping.get(ext, ("Code", ""))
+
+ def scan_for_lazy_code(self, filepath: str, content: str) -> list[str]:
+ issues = []
+ lines = content.splitlines()
+
+ if len(lines) > 800:
+ issues.append(
+ f"Architectural Bloat: File has {len(lines)} lines. This exceeds the 800-line modularity threshold. Priority: HIGH. Action: Split into smaller modules."
+ )
+
+ try:
+ tree = ast.parse(content)
+ except SyntaxError as e:
+ return [f"SyntaxError during AST parse: {e}"]
+ for node in ast.walk(tree):
+ if isinstance(node, ast.Name) and node.id == "Any":
+ issues.append("Found use of 'Any' type hint.")
+ elif isinstance(node, ast.Attribute) and node.attr == "Any":
+ issues.append("Found use of 'typing.Any'.")
+ return issues
+
+ def _generate_unique_session_id(self) -> str:
+ """Generates a unique session ID for dashboard interactions."""
+ return str(uuid.uuid4())
+
+ def _get_dashboard_decision(self, allow_delete: bool) -> str:
+ """
+ Initiates an interactive web-based review process for pending proposals
+ and waits for the user's decision from the dashboard.
+ The user makes the decision directly on the dashboard UI.
+ """
+ is_cloud = (
+ os.environ.get("GITHUB_ACTIONS") == "true"
+ or os.environ.get("CI") == "true"
+ or "GITHUB_RUN_ID" in os.environ
+ )
+ if is_cloud or not sys.stdin.isatty():
+ logger.info(
+ "Headless environment detected: Auto-approving dashboard proposal."
+ )
+ return "PROCEED"
+
+ session_id = self._generate_unique_session_id()
+ dashboard_url = f"{self.DASHBOARD_BASE_URL}/review/{session_id}"
+ decision_api_url = f"{self.DASHBOARD_BASE_URL}/api/decision/{session_id}"
+
+ logger.info("==================================================")
+ logger.info(" ACTION REQUIRED: Interactive Proposal Review")
+ logger.info("==================================================")
+ logger.info(
+ "Pending proposals require your review. Please open your web browser to:"
+ )
+ logger.info(f" -> {dashboard_url}")
+ logger.info(
+ "Waiting for your decision from the dashboard (PROCEED, SKIP, or DELETE)..."
+ )
+
+ decision = None
+ poll_interval_seconds = 2
+ max_retries = 3
+
+ retries = 0
+ while decision is None:
+ try:
+ response = requests.get(decision_api_url, timeout=5)
+ response.raise_for_status()
+ data = response.json()
+ if data.get("decision"):
+ decision = data["decision"].upper()
+ if decision not in ["PROCEED", "SKIP"] and (
+ not allow_delete or decision != "DELETE"
+ ):
+ logger.warning(
+ f"Invalid or disallowed decision '{decision}' received from dashboard. Defaulting to SKIP."
+ )
+ decision = "SKIP"
+ else:
+ time.sleep(poll_interval_seconds)
+ retries = 0
+ except requests.exceptions.ConnectionError as e:
+ retries += 1
+ logger.error(
+ f"Could not connect to dashboard server at {self.DASHBOARD_BASE_URL}. (Attempt {retries}/{max_retries}) Error: {e}"
+ )
+ if retries >= max_retries:
+ logger.info(
+ "Max connection retries reached. Falling back to CLI input for decision."
+ )
+ break
+ time.sleep(poll_interval_seconds * 2)
+ except requests.exceptions.Timeout:
+ logger.debug("Dashboard decision poll timed out, retrying...")
+ time.sleep(poll_interval_seconds)
+ except requests.exceptions.RequestException as e:
+ logger.error(
+ f"An HTTP request error occurred while polling dashboard: {e}"
+ )
+ time.sleep(poll_interval_seconds)
+ except Exception as e:
+ logger.error(
+ f"An unexpected error occurred while polling dashboard: {e}"
+ )
+ time.sleep(poll_interval_seconds)
+
+ if decision is None:
+ prompt_options = "'PROCEED' to apply, 'SKIP' to ignore"
+ if allow_delete:
+ prompt_options += ", 'DELETE' to discard"
+ try:
+ user_decision = input(f"Enter {prompt_options}: ").strip().upper()
+ if user_decision not in ["PROCEED", "SKIP"] and (
+ not allow_delete or user_decision != "DELETE"
+ ):
+ logger.warning(
+ f"Invalid input '{user_decision}'. Defaulting to SKIP."
+ )
+ decision = "SKIP"
+ else:
+ decision = user_decision
+ except EOFError:
+ logger.warning(
+ "EOFError caught during input. Auto-approving to prevent crash."
+ )
+ decision = "PROCEED"
+
+ logger.info(f"Dashboard decision received: {decision}")
+ return decision
+ is_cloud = (
+ os.environ.get("GITHUB_ACTIONS") == "true"
+ or os.environ.get("CI") == "true"
+ or "GITHUB_RUN_ID" in os.environ
+ )
+ if is_cloud or not sys.stdin.isatty():
+ logger.info(
+ "Headless environment detected: Auto-approving dashboard proposal."
+ )
+ return "PROCEED"
+
+ session_id = self._generate_unique_session_id()
+ dashboard_url = f"{self.DASHBOARD_BASE_URL}/review/{session_id}"
+
+ logger.info("==================================================")
+ logger.info(" ACTION REQUIRED: Interactive Proposal Review")
+ logger.info("==================================================")
+ logger.info(
+ "Pending proposals require your review. Please open your web browser to:"
+ )
+ logger.info(f" -> {dashboard_url}")
+ logger.info(
+ f"Please open your web browser to the URL above for context. Decision will be taken via CLI (PROCEED, SKIP, or {'DELETE' if allow_delete else 'CANCEL'})..."
+ )
+
+ prompt_options = "'PROCEED' to apply, 'SKIP' to ignore"
+ if allow_delete:
+ prompt_options += ", 'DELETE' to discard"
+
+ try:
+ user_decision = (
+ input(f"Simulating dashboard decision (enter {prompt_options}): ")
+ .strip()
+ .upper()
+ )
+
+ if user_decision not in ["PROCEED", "SKIP", "DELETE"]:
+ logger.warning(f"Invalid input '{user_decision}'. Defaulting to SKIP.")
+ user_decision = "SKIP"
+
+ except EOFError:
+ logger.warning(
+ "EOFError caught during input. Auto-approving to prevent crash."
+ )
+ user_decision = "PROCEED"
+
+ logger.info(f"Dashboard decision received: {user_decision}")
+ return user_decision
+
+ def set_manual_target_file(self, filepath: str | None):
+ if filepath:
+ if not os.path.exists(filepath):
+ logger.warning(
+ f"Manual target file '{filepath}' does not exist. Ignoring."
+ )
+ self.manual_target_file = None
+ else:
+ self.manual_target_file = os.path.abspath(filepath)
+ logger.info(f"Manual target file set to: {self.manual_target_file}")
+ else:
+ self.manual_target_file = None
+ logger.info("Manual target file cleared. Reverting to directory scan.")
+
+ def run_linters(self, filepath: str) -> tuple[str, str]:
+
+ ruff_out, mypy_out = "", ""
+ try:
+ ruff_out = subprocess.run(
+ ["ruff", "check", filepath], capture_output=True, text=True
+ ).stdout.strip()
+ except FileNotFoundError:
+ pass
+ try:
+ res = subprocess.run(["mypy", filepath], capture_output=True, text=True)
+ mypy_out = res.stdout.strip()
+ except FileNotFoundError:
+ pass
+ return ruff_out, mypy_out
+
+ def build_patch_prompt(
+ self,
+ lang_name: str,
+ lang_tag: str,
+ content: str,
+ ruff_out: str,
+ mypy_out: str,
+ custom_issues: list[str],
+ ) -> str:
+ memory_section = self._get_rich_context()
+ ruff_section = f"### Ruff Errors:\n{ruff_out}\n\n" if ruff_out else ""
+ mypy_section = f"### Mypy Errors:\n{mypy_out}\n\n" if mypy_out else ""
+ custom_issues_section = (
+ "### Code Quality Issues:\n"
+ + "\n".join(f"- {i}" for i in custom_issues)
+ + "\n\n"
+ if custom_issues
+ else ""
+ )
+ return str(
+ self.load_prompt(
+ "PP.md",
+ lang_name=lang_name,
+ lang_tag=lang_tag,
+ content=content,
+ memory_section=memory_section,
+ ruff_section=ruff_section,
+ mypy_section=mypy_section,
+ custom_issues_section=custom_issues_section,
+ )
+ )
+
+ def _handle_pending_proposals(
+ self, prompt_message: str, allow_delete: bool
+ ) -> bool:
+ """
+ Handles user approval for pending PR/feature files, applies them,
+ manages rollback on failure, or deletes them.
+ Returns True if proposals were successfully applied or deleted,
+ False if skipped or failed to apply.
+ """
+ if not (os.path.exists(self.pr_file) or os.path.exists(self.feature_file)):
+ return False
+
+ user_input = self._get_dashboard_decision(allow_delete)
+
+ if user_input == "PROCEED":
+ backup_state = self.backup_workspace()
+ success = True
+ if os.path.exists(self.pr_file):
+ with open(self.pr_file, "r", encoding="utf-8") as f:
+ if not self.implement_pr(f.read()):
+ success = False
+ if success and os.path.exists(self.feature_file):
+ with open(self.feature_file, "r", encoding="utf-8") as f:
+ if not self.implement_feature(f.read()):
+ success = False
+ if not success:
+ self.restore_workspace(backup_state)
+ logger.warning("Rollback performed due to unfixable errors.")
+ self.session_context.append(
+ "CRITICAL: The last refactor/feature attempt FAILED and was ROLLED BACK. "
+ "The files on disk have NOT changed. Check FAILED_FEATURE.md for error logs."
+ )
+
+ failure_report = f"\n\n### FAILURE ATTEMPT LOGS ({time.strftime('%Y-%m-%d %H:%M:%S')})\n"
+ failure_report += self.session_context[-1]
+ if len(self.session_context) > 1:
+ failure_report += "\n" + "\n".join(self.session_context[-3:-1])
+
+ if os.path.exists(self.pr_file):
+ with open(self.pr_file, "r", encoding="utf-8") as f:
+ content = f.read()
+ with open(self.failed_pr_file, "w") as f:
+ f.write(content + failure_report)
+ os.remove(self.pr_file)
+
+ if os.path.exists(self.feature_file):
+ with open(self.feature_file, "r", encoding="utf-8") as f:
+ content = f.read()
+ with open(self.failed_feature_file, "w") as f:
+ f.write(content + failure_report)
+ os.remove(self.feature_file)
+ return False
+ return True
+ elif allow_delete and user_input == "DELETE":
+ if os.path.exists(self.pr_file):
+ os.remove(self.pr_file)
+ if os.path.exists(self.feature_file):
+ os.remove(self.feature_file)
+ logger.info("Deleted pending proposal files. Starting fresh scan...")
+ return True
+ else:
+ logger.info(
+ "Changes not applied manually. They will remain for the next loop iteration."
+ )
+ return False
+
+ def run_pipeline(self, current_iteration: int):
+ changes_made = False
+ try:
+ if os.path.exists(self.pr_file) or os.path.exists(self.feature_file):
+ logger.info("==================================================")
+ logger.info(
+ f"Found pending {PR_FILE_NAME} and/or {FEATURE_FILE_NAME} from a previous run."
+ )
+ proposals_handled = self._handle_pending_proposals(
+ "Hit ENTER to PROCEED, type 'SKIP' to ignore",
+ allow_delete=True,
+ )
+ if not proposals_handled:
+ logger.info(
+ "Pending proposals were not applied or deleted. Halting current pipeline iteration to await user action."
+ )
+ return
+ changes_made = True
+
+ if not changes_made:
+ logger.info("==================================================")
+ logger.info("PHASE 1: Initial Assessment & Codebase Scan")
+ logger.info("==================================================")
+ if self.manual_target_file:
+ if os.path.exists(self.manual_target_file):
+ all_files = [self.manual_target_file]
+ logger.info(
+ f"Manual target file override active: {self.manual_target_file}"
+ )
+ else:
+ logger.warning(
+ f"Manual target file '{self.manual_target_file}' not found. Reverting to full scan."
+ )
+ self.manual_target_file = None
+ all_files = self.scan_directory()
+ else:
+ all_files = self.scan_directory()
+ if not all_files:
+ return logger.warning("No supported source files found.")
+ for idx, filepath in enumerate(all_files, start=1):
+ self.analyze_file(filepath, idx, len(all_files))
+ logger.info("==================================================")
+ logger.info(" Phase 1 Complete.")
+ logger.info("==================================================")
+ if os.path.exists(self.pr_file):
+ logger.info(
+ "Skipping Phase 2 (Feature Proposal) because Phase 1 found bugs."
+ )
+ logger.info("Applying fixes first to prevent code collisions...")
+ elif all_files:
+ logger.info("Moving to Phase 2: Generating Feature Proposal...")
+ self.propose_feature(random.choice(all_files))
+ if os.path.exists(self.pr_file) or os.path.exists(self.feature_file):
+ print("\n" + "=" * 50)
+ print(" ACTION REQUIRED: Proposals Generated")
+ self._handle_pending_proposals(
+ "Hit ENTER to PROCEED, or type 'SKIP' to cancel",
+ allow_delete=False,
+ )
+ else:
+ logger.info("\nNo issues found, no features proposed.")
+ finally:
+ self.update_memory()
+ if current_iteration % 2 == 0:
+ self.refactor_memory()
+ logger.info("Pipeline iteration complete.")
+
+
+if __name__ == "__main__":
+ print("Please run `python entrance.py` instead to use the targeted memory flow.")
+ sys.exit(0)
diff --git a/src/pyob/cascade_queue_handler.py b/src/pyob/cascade_queue_handler.py
new file mode 100644
index 0000000..3cffc5f
--- /dev/null
+++ b/src/pyob/cascade_queue_handler.py
@@ -0,0 +1,58 @@
+import json
+from typing import Protocol
+
+
+class CascadeControllerProtocol(Protocol):
+ def add_to_cascade_queue(self, item: str) -> None: ...
+ def remove_cascade_queue_item(self, item_id: str) -> None: ...
+ def move_cascade_queue_item(self, item_id: str, direction: str) -> None: ...
+
+
+class CascadeQueueHandler:
+ def __init__(self, controller: CascadeControllerProtocol):
+ self.controller = controller
+
+ def handle_add_to_cascade_queue(self, item: str):
+ try:
+ self.controller.add_to_cascade_queue(item)
+ return json.dumps(
+ {"message": f"Item '{item}' added to cascade queue successfully"}
+ ).encode()
+ except AttributeError:
+ return json.dumps(
+ {
+ "error": "Controller method 'add_to_cascade_queue' not found. Ensure entrance.py is updated."
+ }
+ ).encode()
+ except Exception as e:
+ return json.dumps({"error": f"Internal server error: {str(e)}"}).encode()
+
+ def handle_remove_from_cascade_queue(self, item_id: str):
+ try:
+ self.controller.remove_cascade_queue_item(item_id)
+ return json.dumps(
+ {"message": f"Item {item_id} removed successfully"}
+ ).encode()
+ except AttributeError:
+ return json.dumps(
+ {
+ "error": "Controller method 'remove_cascade_queue_item' not found. Ensure entrance.py is updated."
+ }
+ ).encode()
+ except Exception as e:
+ return json.dumps({"error": f"Internal server error: {str(e)}"}).encode()
+
+ def handle_move_cascade_queue_item(self, item_id: str, direction: str):
+ try:
+ self.controller.move_cascade_queue_item(item_id, direction)
+ return json.dumps(
+ {"message": f"Item {item_id} moved {direction} successfully"}
+ ).encode()
+ except AttributeError:
+ return json.dumps(
+ {
+ "error": "Controller method 'move_cascade_queue_item' not found. Ensure entrance.py is updated."
+ }
+ ).encode()
+ except Exception as e:
+ return json.dumps({"error": f"Internal server error: {str(e)}"}).encode()
diff --git a/src/pyob/core_utils.py b/src/pyob/core_utils.py
new file mode 100644
index 0000000..4613877
--- /dev/null
+++ b/src/pyob/core_utils.py
@@ -0,0 +1,504 @@
+import json
+import logging
+import os
+import re
+import select
+import shutil
+import subprocess
+import sys
+import textwrap
+import time
+from typing import Callable, Optional
+
+from .models import (
+ get_valid_llm_response_engine,
+ stream_gemini,
+ stream_github_models,
+ stream_ollama,
+ stream_single_llm,
+)
+
+env_keys = os.environ.get("PYOB_GEMINI_KEYS", "")
+GEMINI_API_KEYS = [k.strip() for k in env_keys.split(",") if k.strip()]
+GEMINI_MODEL = os.environ.get("PYOB_GEMINI_MODEL", "gemini-2.5-flash")
+LOCAL_MODEL = os.environ.get("PYOB_LOCAL_MODEL", "qwen3-coder:30b")
+PR_FILE_NAME = "PEER_REVIEW.md"
+FEATURE_FILE_NAME = "FEATURE.md"
+FAILED_PR_FILE_NAME = "FAILED_PEER_REVIEW.md"
+FAILED_FEATURE_FILE_NAME = "FAILED_FEATURE.md"
+MEMORY_FILE_NAME = "MEMORY.md"
+ANALYSIS_FILE = "ANALYSIS.md"
+HISTORY_FILE = "HISTORY.md"
+SYMBOLS_FILE = "SYMBOLS.json"
+PYOB_DATA_DIR = ".pyob"
+
+IGNORE_DIRS = {
+ ".git",
+ ".github",
+ # ".pyob",
+ "autovenv",
+ "build_env",
+ "pyob.egg-info",
+ "TapEvent",
+ "build",
+ "dist",
+ "docs",
+ "venv",
+ ".venv",
+ "code",
+ ".mypy_cache",
+ ".ruff_cache",
+ ".pytest_cache",
+ "patch_test",
+ "env",
+ "__pycache__",
+ "node_modules",
+ ".vscode",
+ ".idea",
+}
+
+IGNORE_FILES = {
+ "package-lock.json",
+ "LICENSE",
+ "manifest.json",
+ "action.yml",
+ "Dockerfile",
+ "build_pyinstaller_multiOS.py",
+ "check.sh",
+ ".pyob_config",
+ ".DS_Store",
+ ".gitignore",
+ "pyob.icns",
+ "pyob.ico",
+ "pyob.png",
+ "ROADMAP.md",
+ "README.md",
+ "DOCUMENTATION.md",
+ "observer.html",
+}
+SUPPORTED_EXTENSIONS = {".py", ".js", ".ts", ".html", ".css", ".json", ".sh"}
+
+
+class CyberpunkFormatter(logging.Formatter):
+ GREEN = "\033[92m"
+ YELLOW = "\033[93m"
+ RED = "\033[91m"
+ BLUE = "\033[94m"
+ RESET = "\033[0m"
+
+ def format(self, record: logging.LogRecord) -> str:
+ cols, _ = shutil.get_terminal_size((80, 20))
+ color = self.RESET
+ if record.levelno == logging.INFO:
+ color = self.GREEN
+ elif record.levelno == logging.WARNING:
+ color = self.YELLOW
+ elif record.levelno >= logging.ERROR:
+ color = self.RED
+
+ prefix = f"{time.strftime('%H:%M:%S')} | "
+ available_width = max(cols - len(prefix) - 1, 20)
+ message = record.getMessage()
+ wrapped_lines = textwrap.wrap(message, width=available_width)
+
+ formatted_msg = ""
+ for i, line in enumerate(wrapped_lines):
+ if i == 0:
+ formatted_msg += (
+ f"{self.BLUE}{prefix}{self.RESET}{color}{line}{self.RESET}"
+ )
+ else:
+ formatted_msg += f"\n{' ' * len(prefix)}{color}{line}{self.RESET}"
+ return formatted_msg
+
+
+logger = logging.getLogger("PyOuroBoros")
+logger.setLevel(logging.INFO)
+handler = logging.StreamHandler(sys.stdout)
+handler.setFormatter(CyberpunkFormatter())
+logger.addHandler(handler)
+logger.propagate = False
+
+
+class CoreUtilsMixin:
+ target_dir: str
+ memory_path: str
+ key_cooldowns: dict[str, float]
+
+ def generate_pr_summary(self, rel_path: str, diff_text: str) -> dict:
+ """Analyzes a git diff and returns a professional title and body for the PR."""
+ prompt = f"""
+ Analyze the following git diff for file `{rel_path}` and write a professional, high-quality PR title and description.
+ RULES:
+ 1. PR Title: Start with a category (e.g., "Refactor:", "Feature:", "Fix:", "Security:") followed by a concise summary.
+ 2. PR Body: Use professional markdown. Include sections for 'Summary of Changes' and 'Technical Impact'.
+ 3. NO TIMESTAMPS: Do not mention the time or date.
+ GIT DIFF:
+ {diff_text}
+ OUTPUT FORMAT (STRICT JSON):
+ {{"title": "...", "body": "..."}}
+ """
+
+ try:
+ response = self.get_valid_llm_response(
+ prompt,
+ lambda t: '"title":' in t and '"body":' in t,
+ context="PR Architect",
+ )
+
+ json_match = re.search(r"(\{.*\})", response, re.DOTALL)
+ if json_match:
+ clean_json = json_match.group(1)
+ else:
+ clean_json = re.sub(
+ r"^```json\s*|\s*```$", "", response.strip(), flags=re.MULTILINE
+ )
+
+ data = json.loads(clean_json, strict=False)
+
+ if isinstance(data, dict):
+ return data
+
+ raise ValueError("LLM response was not a valid dictionary object")
+
+ except Exception as e:
+ logger.warning(f"Librarian failed to generate AI summary: {e}")
+ return {
+ "title": f"Evolution: Refactor of `{rel_path}`",
+ "body": f"Automated self-evolution update for `{rel_path}`. Verified stable via runtime testing.",
+ }
+
+ def stream_gemini(
+ self, prompt: str, api_key: str, on_chunk: Callable[[], None]
+ ) -> str:
+ return stream_gemini(prompt, api_key, on_chunk)
+
+ def stream_ollama(self, prompt: str, on_chunk: Callable[[], None]) -> str:
+ return str(stream_ollama(prompt, on_chunk))
+
+ def stream_github_models(
+ self, prompt: str, on_chunk: Callable[[], None], model_name: str = "Llama-3"
+ ) -> str:
+ return str(stream_github_models(prompt, on_chunk, model_name))
+
+ def _stream_single_llm(
+ self,
+ prompt: str,
+ key: Optional[str] = None,
+ context: str = "",
+ gh_model: str = "Llama-3",
+ ) -> str:
+ return str(stream_single_llm(prompt, key, context, gh_model))
+
+ def get_user_approval(self, prompt_text: str, timeout: int = 220) -> str:
+ if (
+ not sys.stdin.isatty()
+ or os.environ.get("GITHUB_ACTIONS") == "true"
+ or os.environ.get("CI") == "true"
+ or "GITHUB_RUN_ID" in os.environ
+ ):
+ logger.info(" Headless environment detected: Auto-approving action.")
+ return "PROCEED"
+ print(f"\n{prompt_text}")
+ start_time = time.time()
+ input_str = ""
+ if sys.platform == "win32":
+ import msvcrt
+
+ prev_line_len = 0
+ while True:
+ remaining = int(timeout - (time.time() - start_time))
+ if remaining <= 0:
+ return "PROCEED"
+ current_display_str = f" {remaining}s remaining | You: {input_str}"
+ padding_needed = max(0, prev_line_len - len(current_display_str))
+ sys.stdout.write(f"\r{current_display_str}{' ' * padding_needed}")
+ prev_line_len = len(current_display_str) + padding_needed
+ sys.stdout.flush()
+ if msvcrt.kbhit():
+ char = msvcrt.getwch()
+ if char in ("\r", "\n"):
+ print()
+ val = input_str.strip().upper()
+ return val if val else "PROCEED"
+ elif char == "\x08":
+ input_str = input_str[:-1]
+ else:
+ input_str += char
+ time.sleep(0.1)
+ else:
+ import termios
+ import tty
+
+ fd = sys.stdin.fileno()
+ old_settings = termios.tcgetattr(fd)
+ try:
+ tty.setcbreak(fd)
+ while True:
+ remaining = int(timeout - (time.time() - start_time))
+ if remaining <= 0:
+ return "PROCEED"
+ sys.stdout.write(
+ f"\r {remaining}s remaining | You: {input_str}\033[K"
+ )
+ sys.stdout.flush()
+ i, o, e = select.select([sys.stdin], [], [], 0.1)
+ if i:
+ char = sys.stdin.read(1)
+ if char in ("\n", "\r"):
+ print()
+ val = input_str.strip().upper()
+ return val if val else "PROCEED"
+ elif char in ("\x08", "\x7f"):
+ input_str = input_str[:-1]
+ else:
+ input_str += char
+ finally:
+ termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
+
+ def _open_editor_for_content(
+ self,
+ initial_content: str,
+ file_suffix: str = ".txt",
+ log_message: str = "Opening editor",
+ error_message: str = "Using original content.",
+ ) -> str:
+ import tempfile
+
+ editor = os.environ.get("EDITOR", "nano")
+ with tempfile.NamedTemporaryFile(
+ mode="w+", delete=False, encoding="utf-8", suffix=file_suffix
+ ) as tmp_file:
+ tmp_file.write(initial_content)
+ tmp_file_path = tmp_file.name
+ logger.info(f"{log_message}: {editor} {tmp_file_path}")
+ try:
+ subprocess.run([editor, tmp_file_path], check=True)
+ with open(tmp_file_path, "r", encoding="utf-8") as f:
+ edited_content = f.read()
+ return edited_content
+ except FileNotFoundError:
+ logger.error(f"Editor '{editor}' not found. {error_message}")
+ return initial_content
+ except subprocess.CalledProcessError:
+ logger.error(f"Editor '{editor}' exited with an error. {error_message}")
+ return initial_content
+ except Exception:
+ logger.error(f"An unexpected error occurred with editor. {error_message}")
+ return initial_content
+ finally:
+ if os.path.exists(tmp_file_path):
+ os.remove(tmp_file_path)
+
+ def _launch_external_code_editor(
+ self, initial_content: str, file_suffix: str = ".py"
+ ) -> str:
+ return self._open_editor_for_content(
+ initial_content,
+ file_suffix,
+ log_message="Opening prompt augmentation editor",
+ error_message="Using original content.",
+ )
+
+ def _edit_prompt_with_external_editor(self, initial_prompt: str) -> str:
+ return self._open_editor_for_content(
+ initial_prompt,
+ log_message="Opening prompt in editor",
+ error_message="Using original prompt.",
+ )
+
+ def backup_workspace(self) -> dict[str, str]:
+ state: dict[str, str] = {}
+ for root, dirs, files in os.walk(self.target_dir):
+ dirs[:] = [d for d in dirs if d not in IGNORE_DIRS]
+
+ for file in files:
+ if file in IGNORE_FILES:
+ continue
+
+ if any(file.endswith(ext) for ext in SUPPORTED_EXTENSIONS):
+ path = os.path.join(root, file)
+ try:
+ with open(path, "r", encoding="utf-8") as f:
+ state[path] = f.read()
+ except Exception:
+ pass
+ return state
+
+ def restore_workspace(self, state: dict[str, str]):
+ for path, content in state.items():
+ try:
+ with open(path, "w", encoding="utf-8") as f:
+ f.write(content)
+ except Exception as e:
+ logger.error(f"Failed to restore {path}: {e}")
+ logger.warning("Workspace restored to safety due to unfixable AI errors.")
+
+ def load_memory(self) -> str:
+ """Loads persistent memory and injects repo-level human directives."""
+ memory_content = ""
+
+ if os.path.exists(self.memory_path):
+ try:
+ with open(self.memory_path, "r", encoding="utf-8") as f:
+ memory_content = f.read().strip()
+ except Exception:
+ pass
+
+ directives_path = os.path.join(self.target_dir, "DIRECTIVES.md")
+ if os.path.exists(directives_path):
+ try:
+ with open(directives_path, "r", encoding="utf-8") as f:
+ human_orders = f.read().strip()
+ if human_orders:
+ memory_content = (
+ f"# HUMAN DIRECTIVES (PRIORITY)\n"
+ f"{human_orders}\n\n"
+ f"---\n\n"
+ f"{memory_content}"
+ )
+ except Exception as e:
+ logger.warning(f"Librarian could not read DIRECTIVES.md: {e}")
+
+ return memory_content
+
+ def get_valid_llm_response(
+ self, prompt: str, validator: Callable[[str], bool], context: str = ""
+ ) -> str:
+ """Wrapper that ensures key rotation is used for all requests."""
+ return str(
+ get_valid_llm_response_engine(
+ prompt, validator, self.key_cooldowns, context
+ )
+ )
+
+ def _find_entry_file(self) -> str | None:
+ priority_files = [
+ "entrance.py",
+ "main.py",
+ "app.py",
+ "gui.py",
+ "pyob_launcher.py",
+ "package.json",
+ "server.js",
+ "app.js",
+ "index.html",
+ ]
+
+ for f_name in priority_files:
+ target = os.path.join(self.target_dir, f_name)
+ if os.path.exists(target):
+ if (
+ f_name == "package.json"
+ or f_name.endswith(".html")
+ or f_name.endswith(".htm")
+ ):
+ return target
+
+ try:
+ with open(target, "r", encoding="utf-8", errors="ignore") as f:
+ content = f.read()
+ if target.endswith(".py"):
+ if (
+ 'if __name__ == "__main__":' in content
+ or "if __name__ == '__main__':" in content
+ ):
+ return target
+ if target.endswith(".js") and len(content.strip()) > 10:
+ return target
+ except Exception:
+ continue
+
+ html_fallback = None
+ for root, dirs, files in os.walk(self.target_dir):
+ dirs[:] = [
+ d for d in dirs if d not in IGNORE_DIRS and not d.startswith(".")
+ ]
+
+ for file in files:
+ if file in IGNORE_FILES:
+ continue
+
+ file_path = os.path.join(root, file)
+
+ if file.endswith(".py"):
+ try:
+ with open(
+ file_path, "r", encoding="utf-8", errors="ignore"
+ ) as f_obj:
+ content = f_obj.read()
+ if (
+ 'if __name__ == "__main__":' in content
+ or "if __name__ == '__main__':" in content
+ ):
+ return file_path
+ except Exception:
+ continue
+
+ if file.endswith(".html") and not html_fallback:
+ html_fallback = file_path
+
+ python_entry_points = []
+ other_script_entry_points = []
+ html_entry_points = []
+
+ for root, dirs, files in os.walk(self.target_dir):
+ dirs[:] = [
+ d for d in dirs if d not in IGNORE_DIRS and not d.startswith(".")
+ ]
+
+ for file in files:
+ if file in IGNORE_FILES:
+ continue
+
+ file_path = os.path.join(root, file)
+
+ if file.endswith(".py"):
+ try:
+ with open(
+ file_path, "r", encoding="utf-8", errors="ignore"
+ ) as f_obj:
+ content = f_obj.read()
+ if (
+ 'if __name__ == "__main__":' in content
+ or "if __name__ == '__main__':" in content
+ ):
+ python_entry_points.append(file_path)
+ except Exception:
+ continue
+ elif file.endswith((".js", ".ts", ".sh")):
+ other_script_entry_points.append(file_path)
+ elif file.endswith((".html", ".htm")):
+ html_entry_points.append(file_path)
+ elif file == "package.json":
+ html_entry_points.append(
+ file_path
+ ) # Treat package.json as a fallback similar to HTML
+
+ if python_entry_points:
+ # Prioritize common names if multiple python entry points are found
+ for p_file in priority_files:
+ for entry in python_entry_points:
+ if entry.endswith(p_file):
+ return entry
+ return python_entry_points[
+ 0
+ ] # Fallback to first found if no priority match
+
+ if other_script_entry_points:
+ # Prioritize common names if multiple script entry points are found
+ for p_file in priority_files:
+ for entry in other_script_entry_points:
+ if entry.endswith(p_file):
+ return entry
+ return other_script_entry_points[0]
+
+ if html_entry_points:
+ # Prioritize common names if multiple html/package.json entry points are found
+ for p_file in priority_files:
+ for entry in html_entry_points:
+ if entry.endswith(p_file):
+ return entry
+ return html_entry_points[0]
+
+ return None
diff --git a/src/pyob/dashboard_html.py b/src/pyob/dashboard_html.py
new file mode 100644
index 0000000..e576615
--- /dev/null
+++ b/src/pyob/dashboard_html.py
@@ -0,0 +1,438 @@
+OBSERVER_HTML = """
+
+
+
+
+
+ PyOB // ARCHITECT HUD
+
+
+
+
+
+
+
+ PyOB // Evolution Engine
+ READY
+
+
+
+
Iteration--
+
Symbolic Ledger--
+
Pending Cascades--
+
+
+
+
Interactive Visualization
+
+
+
+
Logic Memory (MEMORY.md)
+
+
+
+
+
System Logs (HISTORY.md)
+
No history yet.
+
+
+
Architectural Analysis
+
+
Scanning structure...
+
+
+
Pending Patch Reviews
+
No pending patches.
+
+
+
Manual Override
+
+
+
+
+
Manual Cascade Injection
+
+
+
+
+
+
+
+
+
+
+
+
+"""
diff --git a/src/pyob/dashboard_server.py b/src/pyob/dashboard_server.py
new file mode 100644
index 0000000..aded7d3
--- /dev/null
+++ b/src/pyob/dashboard_server.py
@@ -0,0 +1,234 @@
+import json
+import logging
+import os
+import signal
+import sys
+import threading
+from datetime import datetime
+
+from flask import Flask, jsonify, render_template, request
+
+from pyob.data_parser import DataParser
+
+app = Flask(__name__)
+
+logger = logging.getLogger(__name__)
+status_lock = threading.Lock() # Initialize a lock for issue_statuses.json
+decision_lock = threading.Lock() # Initialize a lock for proposal_decisions.json
+data_parser_instance = DataParser() # Initialize DataParser once globally
+
+
+@app.route("/")
+def index():
+ return render_template("index.html")
+
+
+@app.route("/analysis")
+def analysis():
+ try:
+ analysis_content = read_file("ANALYSIS.md")
+ return jsonify({"success": True, "data": analysis_content})
+ except FileNotFoundError:
+ return jsonify({"success": False, "message": "Analysis not available"}), 404
+ except UnicodeDecodeError:
+ return jsonify(
+ {
+ "success": False,
+ "message": "Error reading analysis content due to encoding issue",
+ }
+ ), 500
+
+
+@app.route("/history")
+def history():
+ try:
+ history_content = read_file("HISTORY.md")
+ return jsonify({"success": True, "data": history_content})
+ except FileNotFoundError:
+ return jsonify({"success": False, "message": "History not available"}), 404
+ except UnicodeDecodeError:
+ return jsonify(
+ {
+ "success": False,
+ "message": "Error reading history content due to encoding issue",
+ }
+ ), 500
+
+
+@app.route("/api/analysis/issues//acknowledge", methods=["POST"])
+def acknowledge_issue(issue_id):
+ """
+ API endpoint to acknowledge a specific analysis issue.
+ The status is stored in a simple JSON file.
+ """
+ try:
+ status_file = "issue_statuses.json"
+ issue_statuses = {}
+ with status_lock: # Acquire lock for the entire read-modify-write operation
+ if os.path.exists(status_file):
+ with open(status_file, "r", encoding="utf-8") as f:
+ issue_statuses = json.load(f)
+
+ # Update status for the given issue_id
+ issue_statuses[issue_id] = {
+ "status": "acknowledged",
+ "timestamp": datetime.now().isoformat(),
+ }
+ with open(status_file, "w", encoding="utf-8") as f:
+ json.dump(issue_statuses, f, indent=4)
+
+ logger.info(f"Issue {issue_id} acknowledged by user.")
+ return jsonify({"success": True, "message": f"Issue {issue_id} acknowledged."})
+ except (OSError, json.JSONDecodeError) as e:
+ logger.error(f"Error acknowledging issue {issue_id}: {e}")
+ return jsonify(
+ {"success": False, "message": "Failed to acknowledge issue."}
+ ), 500
+
+
+@app.route("/api/decision/", methods=["GET", "POST"])
+def handle_proposal_decision(session_id):
+ """
+ API endpoint to handle and retrieve decisions for a given session_id.
+ - POST: User makes a decision (PROCEED/SKIP/DELETE) for a proposal.
+ Expects JSON body: {"action": "PROCEED" | "SKIP" | "DELETE"}
+ - GET: entrance.py polls for the decision for a specific session_id.
+ Returns: {"success": True, "decision": {"status": "pending" | "PROCEED" | "SKIP" | "DELETE", "timestamp": "..."}}
+ """
+ decision_file = "proposal_decisions.json"
+
+ with decision_lock: # Acquire lock for the entire read-modify-write operation
+ decisions = {}
+ if os.path.exists(decision_file):
+ try:
+ with open(decision_file, "r", encoding="utf-8") as f:
+ decisions = json.load(f)
+ except json.JSONDecodeError:
+ logger.warning(
+ f"Could not decode {decision_file}, starting fresh for proposal decisions."
+ )
+ decisions = {}
+
+ if request.method == "POST":
+ data = request.get_json()
+ action = data.get("action")
+ if not action or action not in ["PROCEED", "SKIP", "DELETE"]:
+ return jsonify(
+ {
+ "success": False,
+ "message": "Invalid or missing 'action' in request body.",
+ }
+ ), 400
+
+ decisions[session_id] = {
+ "status": action,
+ "timestamp": datetime.now().isoformat(),
+ }
+ with open(decision_file, "w", encoding="utf-8") as f:
+ json.dump(decisions, f, indent=4)
+
+ logger.info(f"Decision '{action}' recorded for session {session_id}.")
+ return jsonify(
+ {
+ "success": True,
+ "message": f"Decision '{action}' recorded for session {session_id}.",
+ }
+ )
+
+ elif request.method == "GET":
+ # Return the current status for the session_id, default to "pending"
+ decision_status = decisions.get(session_id, {"status": "pending"})
+ return jsonify({"success": True, "decision": decision_status})
+
+
+@app.route("/api/analysis-data")
+def api_analysis_data():
+ """
+ Returns parsed analysis data, enriched with acknowledgment statuses.
+ Assumes DataParser provides unique 'id' for each issue.
+ """
+ try:
+ analysis_content = read_file("ANALYSIS.md")
+ parsed_data = data_parser_instance.parse_analysis_content(analysis_content)
+
+ issue_statuses = {}
+ status_file = "issue_statuses.json"
+ with status_lock: # Acquire lock before reading shared resource
+ if os.path.exists(status_file):
+ with open(status_file, "r", encoding="utf-8") as f:
+ issue_statuses = json.load(f)
+
+ # Merge statuses into parsed_data.
+ # This logic assumes parsed_data is a dictionary with an 'issues' key,
+ # where 'issues' is a list of dictionaries, each with an 'id'.
+ # Adjust if DataParser returns a different structure.
+ if isinstance(parsed_data, dict) and "issues" in parsed_data:
+ for issue in parsed_data.get("issues", []):
+ issue_id = issue.get("id") # DataParser must provide unique IDs
+ if issue_id and issue_id in issue_statuses:
+ issue["status"] = issue_statuses[issue_id]["status"]
+ issue["acknowledged_at"] = issue_statuses[issue_id]["timestamp"]
+ else:
+ issue["status"] = "new" # Default status for unacknowledged issues
+ elif isinstance(
+ parsed_data, list
+ ): # Fallback if DataParser returns a list directly
+ for issue in parsed_data:
+ issue_id = issue.get("id")
+ if issue_id and issue_id in issue_statuses:
+ issue["status"] = issue_statuses[issue_id]["status"]
+ issue["acknowledged_at"] = issue_statuses[issue_id]["timestamp"]
+ else:
+ issue["status"] = "new"
+
+ return jsonify(parsed_data)
+ except FileNotFoundError:
+ return jsonify(
+ {"success": False, "message": "Analysis data not available"}
+ ), 404
+ except UnicodeDecodeError:
+ return jsonify(
+ {
+ "success": False,
+ "message": "Error parsing analysis content due to encoding issue",
+ }
+ ), 500
+ except Exception as e:
+ logger.error(f"Error processing analysis data: {e}")
+ return jsonify(
+ {"success": False, "message": f"Error processing analysis data: {e}"}
+ ), 500
+
+
+@app.route("/api/history-data")
+def api_history_data():
+ try:
+ history_content = read_file("HISTORY.md")
+ parsed_data = data_parser_instance.parse_history_content(history_content)
+ return jsonify(parsed_data)
+ except FileNotFoundError:
+ return jsonify({"success": False, "message": "History data not available"}), 404
+
+
+def read_file(filename):
+ with open(filename, "r", encoding="utf-8") as f:
+ return f.read()
+
+
+def run_server():
+ logger.info("Starting Flask server...")
+ # Use an environment variable to control debug mode for safety
+ debug_mode = os.environ.get("FLASK_DEBUG", "False").lower() == "true"
+ app.run(debug=debug_mode, use_reloader=False)
+
+
+if __name__ == "__main__":
+ # Cleanup Flask server before exit
+ def cleanup(signum, frame):
+ logger.info("Shutting down Flask server...")
+ sys.exit(0)
+
+ signal.signal(signal.SIGTERM, cleanup)
+ signal.signal(signal.SIGINT, cleanup)
+
+ run_server()
diff --git a/src/pyob/data_parser.py b/src/pyob/data_parser.py
new file mode 100644
index 0000000..e40eaca
--- /dev/null
+++ b/src/pyob/data_parser.py
@@ -0,0 +1,38 @@
+import re
+
+
+class DataParser:
+ def parse_analysis_content(self, content: str) -> dict:
+ """
+ Safely parses numeric stats from analysis content while stripping CSS units
+ to prevent 'invalid decimal literal' errors.
+ """
+ data = []
+ for line in content.splitlines():
+ # Skip complex CSS calc() functions that confuse the parser
+ if "calc(" in line:
+ continue
+
+ # This regex captures the label and the number, then ignores optional units
+ match = re.search(r"(\w+)\s*:\s*([\d\.]+)(?:px|em|rem|%|s)?", line)
+ if match:
+ key = match.group(1)
+ value_str = match.group(2)
+ try:
+ # Convert to float if there is a decimal, otherwise int
+ value = float(value_str) if "." in value_str else int(value_str)
+ data.append({"key": key, "value": value})
+ except ValueError:
+ # If conversion fails for any reason, skip this line safely
+ continue
+ return {"data": data}
+
+ def parse_history_content(self, content: str) -> dict:
+ """Parses event history into structured data."""
+ data = []
+ for line in content.splitlines():
+ # Matches 'EventName: YYYY-MM-DD'
+ match = re.search(r"(\w+): (\d{4}-\d{2}-\d{2})", line)
+ if match:
+ data.append({"event": match.group(1), "date": match.group(2)})
+ return {"data": data}
diff --git a/src/pyob/entrance.py b/src/pyob/entrance.py
new file mode 100644
index 0000000..6858fd6
--- /dev/null
+++ b/src/pyob/entrance.py
@@ -0,0 +1,547 @@
+import ast
+import atexit
+import difflib
+import json
+import logging
+import os
+import re
+import shutil
+import subprocess
+import sys
+import time
+from subprocess import DEVNULL
+from typing import Optional
+
+_current_file_dir = os.path.dirname(os.path.abspath(__file__))
+_pyob_package_root_dir = os.path.dirname(_current_file_dir)
+if _pyob_package_root_dir not in sys.path:
+ sys.path.insert(0, _pyob_package_root_dir)
+
+from pyob.autoreviewer import AutoReviewer # noqa: E402
+from pyob.core_utils import CoreUtilsMixin # noqa: E402
+from pyob.entrance_mixins import EntranceMixin # noqa: E402
+from pyob.evolution_mixins import EvolutionMixin # noqa: E402
+from pyob.pyob_code_parser import CodeParser # noqa: E402
+
+logging.basicConfig(level=logging.INFO, format="%(asctime)s | %(message)s")
+logger = logging.getLogger(__name__)
+
+
+def load_config() -> dict:
+ """Loads configuration and validates gemini_keys.
+
+ As per MEMORY.md, non-empty gemini_keys are required.
+ This function checks for the GEMINI_API_KEY environment variable.
+ """
+ config = {}
+ # Define paths to search for config.json, in order of increasing precedence
+ config_paths = [
+ os.path.join(_current_file_dir, "config.json"),
+ os.path.join(_pyob_package_root_dir, "config.json"),
+ os.path.join(os.getcwd(), "config.json"),
+ ]
+
+ # Merge configurations from files
+ for path in config_paths:
+ if os.path.exists(path):
+ try:
+ with open(path, "r", encoding="utf-8") as f:
+ file_config = json.load(f)
+ config.update(file_config) # Merge, later files override earlier
+ except json.JSONDecodeError as e:
+ logger.warning(f"WARNING: Could not parse config.json at {path}: {e}")
+
+ # Environment variable overrides config.json value
+ gemini_key_env = os.environ.get("PYOB_GEMINI_KEYS")
+ if gemini_key_env:
+ config["gemini_api_key"] = gemini_key_env
+ # Now, gemini_key should be retrieved from the merged config for validation
+ gemini_key = config.get("gemini_api_key")
+ if not gemini_key:
+ logger.critical(
+ "CRITICAL ERROR: GEMINI_API_KEY environment variable is not set or is empty."
+ )
+ logger.critical(
+ "Please set the GEMINI_API_KEY environment variable to proceed."
+ )
+ sys.exit(1)
+ return config
+
+
+class EntranceController(EntranceMixin, CoreUtilsMixin, EvolutionMixin):
+ ENGINE_FILES = [
+ "autoreviewer.py",
+ "cascade_queue_handler.py",
+ "core_utils.py",
+ "dashboard_html.py",
+ "dashboard_server.py",
+ "data_parser.py",
+ "entrance.py",
+ "entrance_mixins.py",
+ "evolution_mixins.py",
+ "feature_mixins.py",
+ "models.py",
+ "prompts_and_memory.py",
+ "pyob_code_parser.py",
+ "pyob_dashboard.py",
+ "pyob_launcher.py",
+ "reviewer_mixins.py",
+ "scanner_mixins.py",
+ "get_valid_edit.py",
+ "stats_updater.py",
+ "targeted_reviewer.py",
+ "xml_mixin.py",
+ ]
+
+ def __init__(self, target_dir: str, dashboard_active: bool = True):
+ self.target_dir = os.path.abspath(target_dir)
+ self.pyob_dir = os.path.join(self.target_dir, ".pyob")
+ os.makedirs(self.pyob_dir, exist_ok=True)
+
+ from pyob.core_utils import GEMINI_API_KEYS
+
+ self.key_cooldowns: dict[str, float] = {
+ key: 0.0 for key in GEMINI_API_KEYS if key.strip()
+ }
+
+ self.skip_dashboard = ("--no-dashboard" in sys.argv) or (not dashboard_active)
+
+ self.analysis_path = os.path.join(self.pyob_dir, "ANALYSIS.md")
+ self.history_path = os.path.join(self.pyob_dir, "HISTORY.md")
+ self.symbols_path = os.path.join(self.pyob_dir, "SYMBOLS.json")
+ self.memory_path = os.path.join(self.pyob_dir, "MEMORY.md")
+ self.llm_engine = AutoReviewer(self.target_dir)
+ self.code_parser = CodeParser()
+ self.ledger = self.load_ledger()
+ self.cascade_queue: list[str] = []
+ self.cascade_diffs: dict[str, str] = {}
+ self.self_evolved_flag: bool = False
+ self.manual_target_file: Optional[str] = None
+ self.dashboard_process: Optional[subprocess.Popen] = None
+ self.session_pr_count = 0
+ self.current_iteration = 1
+
+ if not self.skip_dashboard:
+ logger.info(
+ "Dashboard active: Initializing with EntranceController instance."
+ )
+ self.start_dashboard()
+
+ def set_manual_target_file(self, file_path: str) -> tuple[bool, str]:
+ """Sets a file path to be targeted in the next iteration, overriding LLM choice."""
+ abs_path = os.path.join(self.target_dir, file_path)
+ if os.path.exists(abs_path):
+ self.manual_target_file = file_path
+ logger.info(f"Manual target set for next iteration: {file_path}")
+ return True, f"Manual target set for next iteration: {file_path}"
+ else:
+ logger.warning(f"Manual target file not found: {file_path}")
+ return False, f"Error: File not found at path: {file_path}"
+
+ def start_dashboard(self):
+ """Starts the Flask dashboard server in a separate process."""
+ logger.info("Starting PyOB Dashboard server...")
+ try:
+ env = os.environ.copy()
+ env["PYOB_DIR"] = self.pyob_dir # Pass the .pyob directory path
+ self.dashboard_process = subprocess.Popen(
+ [sys.executable, "-m", "pyob.dashboard_server"],
+ cwd=self.target_dir,
+ stdout=DEVNULL,
+ stderr=DEVNULL,
+ text=True,
+ env=env,
+ )
+ logger.info(
+ f"Dashboard server started with PID: {self.dashboard_process.pid}"
+ )
+ atexit.register(self._terminate_dashboard_process)
+ except Exception as e:
+ logger.error(f"Failed to start dashboard server: {e}")
+ self.dashboard_process = None
+
+ def _terminate_dashboard_process(self):
+ """Terminates the dashboard server process if it's running."""
+ if self.dashboard_process and self.dashboard_process.poll() is None:
+ logger.info("Terminating PyOB Dashboard server...")
+ self.dashboard_process.terminate()
+ try:
+ self.dashboard_process.wait(timeout=5)
+ except subprocess.TimeoutExpired:
+ self.dashboard_process.kill()
+ logger.warning("Dashboard server did not terminate gracefully, killed.")
+ logger.info("PyOB Dashboard server terminated.")
+
+ def sync_with_remote(self) -> bool:
+ """Fetches remote updates and merges main if we are behind."""
+ if not os.path.exists(os.path.join(self.target_dir, ".git")):
+ return False
+
+ logger.info("Checking for remote updates from main...")
+ self._run_git_command(["git", "fetch", "origin"])
+
+ result = subprocess.run(
+ ["git", "rev-list", "--count", "HEAD..origin/main"],
+ cwd=self.target_dir,
+ capture_output=True,
+ text=True,
+ )
+
+ commits_behind = int(result.stdout.strip() or 0)
+
+ if commits_behind > 0:
+ logger.warning(
+ f"Project is behind main by {commits_behind} commits. Syncing..."
+ )
+
+ if self._run_git_command(["git", "merge", "origin/main"]):
+ logger.info("Sync complete. Local files updated.")
+ return True
+ else:
+ logger.error(
+ "Sync failed (likely a merge conflict). Manual intervention required."
+ )
+
+ return False
+
+ def reboot_pyob(self):
+ """Verified Hot-Reboot: Checks for syntax/import errors before restarting."""
+ logger.info("PRE-FLIGHT: Verifying engine integrity before reboot...")
+
+ test_cmd = [sys.executable, "-c", "import pyob.entrance; print('SUCCESS')"]
+ env = os.environ.copy()
+ current_pythonpath_list = env.get("PYTHONPATH", "").split(os.pathsep)
+ if _pyob_package_root_dir not in current_pythonpath_list:
+ if env.get("PYTHONPATH"):
+ env["PYTHONPATH"] = (
+ f"{_pyob_package_root_dir}{os.pathsep}{env['PYTHONPATH']}"
+ )
+ else:
+ env["PYTHONPATH"] = _pyob_package_root_dir
+
+ try:
+ result = subprocess.run(test_cmd, capture_output=True, text=True, env=env)
+ if "SUCCESS" in result.stdout:
+ logger.warning(
+ "SELF-EVOLUTION COMPLETE: Rebooting fresh PYOB engine..."
+ )
+ os.execv(
+ sys.executable,
+ [sys.executable, "-m", "pyob.pyob_launcher", self.target_dir],
+ )
+ else:
+ logger.error(
+ f"REBOOT ABORTED: The evolved code has import/syntax errors:\n{result.stderr}"
+ )
+ self.self_evolved_flag = False
+ except Exception as e:
+ logger.error(f"Pre-flight check failed: {e}")
+ self.self_evolved_flag = False
+
+ def trigger_production_build(self):
+ """Advanced Build: Compiles PYOB into a DMG and replaces the system version."""
+ build_script = os.path.join(self.target_dir, "build_pyinstaller_multiOS.py")
+ if not os.path.exists(build_script):
+ logger.error("Build script not found. Skipping production deploy.")
+ return
+
+ logger.info("STARTING PRODUCTION BUILD... This will take 2-3 minutes.")
+ try:
+ subprocess.run([sys.executable, build_script], check=True)
+
+ app_name = "PyOuroBoros.app"
+ dist_path = os.path.join(self.target_dir, "dist", app_name)
+ applications_path = f"/Applications/{app_name}"
+
+ if sys.platform == "darwin" and os.path.exists(dist_path):
+ logger.warning(
+ f"FORGE COMPLETE: Deploying new version to {applications_path}..."
+ )
+
+ if os.path.exists(applications_path):
+ shutil.rmtree(applications_path)
+ shutil.copytree(dist_path, applications_path)
+
+ subprocess.Popen(
+ ["open", "-a", applications_path, "--args", self.target_dir]
+ )
+
+ logger.info("NEW VERSION RELAYED. ENGINE SHUTTING DOWN.")
+ sys.exit(0)
+
+ except Exception as e:
+ logger.error(f"Production Build Failed: {e}")
+
+ def load_ledger(self) -> dict:
+ """Loads the symbolic ledger from disk with type safety."""
+ if os.path.exists(self.symbols_path):
+ try:
+ with open(self.symbols_path, "r", encoding="utf-8") as f:
+ data = json.load(f)
+ if isinstance(data, dict):
+ return data
+ except (FileNotFoundError, json.JSONDecodeError) as e:
+ logger.warning(
+ f"Failed to load SYMBOLS.json, initializing empty ledger: {e}"
+ )
+ return {"definitions": {}, "references": {}}
+
+ def save_ledger(self):
+ """Saves the current symbolic ledger back to disk."""
+ with open(self.symbols_path, "w", encoding="utf-8") as f:
+ json.dump(self.ledger, f, indent=2)
+
+ def run_master_loop(self):
+ logger.info(
+ "\n" + "=" * 60 + "\nENTRANCE CONTROLLER: SYMBOLIC MODE ACTIVE\n" + "=" * 60
+ )
+ iteration = 1
+ while True:
+ self.current_iteration = iteration
+ self.self_evolved_flag = False
+
+ # Capture Human Directives to prevent AI erasure
+ current_mem = self.load_memory()
+ directives = ""
+ if "# HUMAN DIRECTIVES" in current_mem:
+ match = re.search(
+ r"(# HUMAN DIRECTIVES.*?)(\n#|\Z)", current_mem, re.DOTALL
+ )
+ if match:
+ directives = match.group(1).strip()
+
+ logger.info(
+ f"--- REFRESHING SYMBOLIC CONTEXT FOR ITERATION {iteration} ---"
+ )
+
+ # Clear project map for recursive branch awareness
+ if os.path.exists(self.analysis_path):
+ os.remove(self.analysis_path)
+ if os.path.exists(self.symbols_path):
+ os.remove(self.symbols_path)
+
+ self.build_initial_analysis()
+
+ # Check for remote updates
+ if self.sync_with_remote():
+ res = subprocess.run(
+ ["git", "diff-tree", "--no-commit-id", "--name-only", "-r", "HEAD"],
+ cwd=self.target_dir,
+ capture_output=True,
+ text=True,
+ )
+ changed_files = res.stdout.strip().splitlines()
+ if any(os.path.basename(f) in self.ENGINE_FILES for f in changed_files):
+ logger.warning(
+ "REMOTE EVOLUTION: Engine files updated via sync. Rebooting..."
+ )
+ self.self_evolved_flag = True
+
+ if self.self_evolved_flag:
+ if getattr(sys, "frozen", False):
+ logger.warning("COMPILED ENGINE EVOLVED: Initiating Forge Build.")
+ self.trigger_production_build()
+ else:
+ logger.warning(" SCRIPT ENGINE EVOLVED: Initiating Hot-Reboot.")
+ self.reboot_pyob()
+
+ logger.info(
+ f"\n\n{'=' * 70}\nTargeted Pipeline Loop (Iteration {iteration})\n{'=' * 70}"
+ )
+
+ try:
+ # Execute the evolution cycle
+ self.execute_targeted_iteration(iteration)
+
+ # Restore Human Directives if the AI cleaned the memory file
+ if directives:
+ post_run_mem = self.load_memory()
+ if directives not in post_run_mem:
+ logger.warning(
+ "AI wiped Human Directives. Restoring memory integrity..."
+ )
+ with open(self.memory_path, "w", encoding="utf-8") as f:
+ f.write(directives + "\n\n" + post_run_mem)
+
+ # --- THE WRAP-UP GATE ---
+ # session_pr_count is incremented in handle_git_librarian.
+ # If we hit the goal and have no pending cross-file tasks, we sign off.
+ if getattr(self, "session_pr_count", 0) >= 8 and not getattr(
+ self, "cascade_queue", []
+ ):
+ logger.info(
+ f"🏆 SESSION COMPLETE: {self.session_pr_count} PRs achieved with no pending cascades."
+ )
+ if hasattr(self, "wrap_up_evolution_session"):
+ self.wrap_up_evolution_session()
+ break # Graceful exit from the 6-hour loop
+ # ------------------------
+
+ except KeyboardInterrupt:
+ logger.info("\nExiting Entrance Controller...")
+ break
+ except Exception as e:
+ logger.error(f"Unexpected error in master loop: {e}", exc_info=True)
+
+ iteration += 1
+ logger.info("Iteration complete. Waiting for system cooldown...")
+ time.sleep(120)
+
+ def _extract_path_from_llm_response(self, text: str) -> str:
+ """Extracts a clean relative file path from a conversational LLM response."""
+ cleaned_text = re.sub(r"[`\"*]", "", text).strip()
+
+ matches = re.findall(r"[\w\.\-/]+\.(?:py|js|ts|html|css|json|md)", cleaned_text)
+
+ base_dir = self.target_dir
+
+ for match in matches:
+ clean_match = match.rstrip(".,;:'\")")
+ if os.path.exists(os.path.join(base_dir, clean_match)):
+ return str(clean_match)
+
+ if " " in cleaned_text:
+ parts = cleaned_text.split()
+ for part in parts:
+ clean_part = str(part).rstrip(".,;:'\")")
+ if "/" in clean_part or clean_part.endswith(
+ (".py", ".js", ".ts", ".html", ".css", ".json", ".md")
+ ):
+ abs_path = os.path.join(base_dir, clean_part)
+ if os.path.exists(abs_path):
+ return str(clean_part)
+ return str(parts[0].rstrip(".,;:'\")"))
+
+ return str(cleaned_text)
+
+ def _run_git_command(self, cmd: list[str]) -> bool:
+ """Helper to run git commands safely."""
+ try:
+ result = subprocess.run(
+ cmd, cwd=self.target_dir, capture_output=True, text=True
+ )
+ if result.returncode != 0:
+ logger.warning(
+ f"Git Command Failed: {' '.join(cmd)}\nSTDOUT: {result.stdout}\nSTDERR: {result.stderr}"
+ )
+ return False
+ return True
+ except Exception as e:
+ logger.error(f"Git Execution Error: {e}")
+ return False
+
+ def detect_symbolic_ripples(
+ self, old: str, new: str, source_file: str
+ ) -> list[str]:
+ diff = list(difflib.unified_diff(old.splitlines(), new.splitlines()))
+ changed_text = "\n".join(
+ [line for line in diff if line.startswith("+") or line.startswith("-")]
+ )
+ potential_symbols = set(re.findall(r"\b[a-zA-Z_][a-zA-Z0-9_]*\b", changed_text))
+ impacted_files = []
+ for sym in potential_symbols:
+ if self.ledger["definitions"].get(sym) == source_file:
+ for target_file, refs in self.ledger["references"].items():
+ if sym in refs and target_file != source_file:
+ impacted_files.append(target_file)
+ return list(set(impacted_files))
+
+ def update_analysis_for_single_file(self, target_abs_path: str, rel_path: str):
+ if not os.path.exists(self.analysis_path):
+ return
+ with open(target_abs_path, "r", encoding="utf-8", errors="ignore") as f:
+ code = f.read()
+ structure = self.code_parser.generate_structure_dropdowns(target_abs_path, code)
+ sum_prompt = f"Provide a one-sentence plain text summary of what the file `{rel_path}` does. \n\nStructure:\n{structure}"
+ desc = self.get_valid_llm_response(
+ sum_prompt, lambda t: "" not in t and len(t) > 5, context=rel_path
+ ).strip()
+ new_block = f"### `{rel_path}`\n**Summary:** {desc}\n\n" + structure + "\n---\n"
+ with open(self.analysis_path, "r", encoding="utf-8") as f:
+ analysis_text = f.read()
+ pattern = rf"### `{re.escape(rel_path)}`.*?(?=### `|---\n\Z)"
+ updated_text, num_subs = re.subn(
+ pattern, new_block, analysis_text, flags=re.DOTALL
+ )
+
+ if num_subs == 0:
+ # If no existing block was found, append the new block
+ updated_text = analysis_text + new_block
+
+ with open(self.analysis_path, "w", encoding="utf-8") as f:
+ f.write(updated_text)
+
+ def update_ledger_for_file(self, rel_path: str, code: str):
+ ext = os.path.splitext(rel_path)[1]
+ definitions_to_remove = [
+ name
+ for name, path in self.ledger["definitions"].items()
+ if path == rel_path
+ ]
+ for name in definitions_to_remove:
+ del self.ledger["definitions"][name]
+
+ if ext == ".py":
+ try:
+ tree = ast.parse(code)
+ for n in ast.walk(tree):
+ if isinstance(n, (ast.FunctionDef, ast.ClassDef)):
+ self.ledger["definitions"][n.name] = rel_path
+ elif isinstance(n, ast.Assign):
+ for target in n.targets:
+ if isinstance(target, ast.Name) and target.id.isupper():
+ self.ledger["definitions"][target.id] = rel_path
+ except Exception as e:
+ logger.warning(f"Failed to parse Python AST for {rel_path}: {e}")
+ elif ext in [".js", ".ts"]:
+ defs = re.findall(
+ r"(?:export\s+|async\s+)?(?:function\*?|class|const|var|let)\s+([a-zA-Z0-9_$]+)",
+ code,
+ )
+ for d in defs:
+ if len(d) > 3:
+ self.ledger["definitions"][d] = rel_path
+
+ if rel_path in self.ledger["references"]:
+ del self.ledger["references"][rel_path]
+
+ potential_refs = re.findall(r"\b[a-zA-Z_][a-zA-Z0-9_]{3,}\b", code)
+ self.ledger["references"][rel_path] = list(set(potential_refs))
+ self.save_ledger()
+
+ def append_to_history(self, rel_path: str, old_code: str, new_code: str):
+ diff_lines = list(
+ difflib.unified_diff(
+ old_code.splitlines(keepends=True),
+ new_code.splitlines(keepends=True),
+ fromfile="Original",
+ tofile="Proposed",
+ )
+ )
+ if not diff_lines:
+ return
+ summary_diff = (
+ "".join(diff_lines[:5])
+ + "\n... [TRUNCATED] ...\n"
+ + "".join(diff_lines[-5:])
+ if len(diff_lines) > 20
+ else "".join(diff_lines)
+ )
+ timestamp = time.strftime("%Y-%m-%d %H:%M:%S")
+ with open(self.history_path, "a", encoding="utf-8") as f:
+ f.write(
+ f"\n## {timestamp} - `{rel_path}`\n```diff\n{summary_diff}\n```\n---\n"
+ )
+
+ def _read_file(self, f: str) -> str:
+ if os.path.exists(f):
+ try:
+ with open(f, "r", encoding="utf-8", errors="ignore") as f_obj:
+ return f_obj.read()
+ except Exception:
+ return ""
+ return ""
+
+
+if __name__ == "__main__":
+ EntranceController(sys.argv[1] if len(sys.argv) > 1 else ".").run_master_loop()
diff --git a/src/pyob/entrance_mixins.py b/src/pyob/entrance_mixins.py
new file mode 100644
index 0000000..2b5ccd5
--- /dev/null
+++ b/src/pyob/entrance_mixins.py
@@ -0,0 +1,274 @@
+import difflib
+import json
+import logging
+import os
+import shutil
+import threading
+import time
+import urllib.parse
+from http.server import HTTPServer
+from pathlib import Path
+from typing import Any
+
+from .dashboard_html import OBSERVER_HTML
+from .pyob_dashboard import ObserverHandler
+from .targeted_reviewer import TargetedReviewer
+
+logger = logging.getLogger(__name__)
+
+
+# [TargetedReviewer class moved to targeted_reviewer.py]
+
+
+class EntranceMixin:
+ def start_dashboard(self: Any):
+ # 1. Save to the internal .pyob folder
+ obs_path = os.path.join(self.pyob_dir, "observer.html")
+
+ # Dynamically modify OBSERVER_HTML string to inject manual target UI and logic
+ modified_html_content = OBSERVER_HTML
+
+ # Insert HTML block for manual target selection
+ html_to_insert = """
+
+
+
Manual Target Selection
+
+
+
+
+"""
+ # Assuming OBSERVER_HTML has a structure like: ... \n\n Live Log
+ insertion_marker_html = " \n\n Live Log
"
+ if insertion_marker_html in modified_html_content:
+ modified_html_content = modified_html_content.replace(
+ insertion_marker_html, html_to_insert + "\n" + insertion_marker_html
+ )
+ else:
+ logger.warning(
+ "HTML insertion marker not found in OBSERVER_HTML. Manual target UI may not be visible."
+ )
+
+ # Insert JavaScript for manual target form submission handling
+ js_to_insert = """
+ // Handle manual target form submission
+ document.getElementById('set-target-form').addEventListener('submit', function(event) {
+ event.preventDefault(); // Prevent default form submission
+
+ const filePathInput = document.getElementById('target-file-path');
+ const filePath = filePathInput.value;
+ const targetMessage = document.getElementById('target-message');
+
+ if (!filePath) {
+ targetMessage.textContent = 'Please enter a file path.';
+ targetMessage.style.color = 'red';
+ return;
+ }
+
+ fetch('/set_target', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ },
+ body: `file_path=${encodeURIComponent(filePath)}`
+ })
+ .then(response => {
+ const isOk = response.ok; // Capture response.ok status
+ return response.json().then(data => ({ data, isOk })); // Pass data and status
+ })
+ .then(({ data, isOk }) => { // Destructure to get both
+ targetMessage.textContent = data.message;
+ // Check response.ok from the original fetch response, not data
+ targetMessage.style.color = isOk ? '#6a9955' : 'red';
+ if (isOk) {
+ filePathInput.value = ''; // Clear input on success
+ }
+ })
+ .catch(error => {
+ console.error('Error setting manual target:', error);
+ targetMessage.textContent = 'An error occurred while setting target.';
+ targetMessage.style.color = 'red';
+ });
+ });
+"""
+ # Assuming OBSERVER_HTML has a script tag ending with ' '
+ insertion_marker_js = " "
+ if insertion_marker_js in modified_html_content:
+ modified_html_content = modified_html_content.replace(
+ insertion_marker_js, js_to_insert + "\n" + insertion_marker_js
+ )
+ else:
+ logger.warning(
+ "JavaScript insertion marker not found in OBSERVER_HTML. Manual target logic may not be functional."
+ )
+
+ with open(obs_path, "w", encoding="utf-8") as f:
+ f.write(modified_html_content)
+
+ # 2. Initialize and Start the Live Server
+
+ # Dynamically add do_POST method for manual target handling
+ def _dynamic_do_POST_method(handler_instance: ObserverHandler):
+ if handler_instance.path == "/set_target":
+ try:
+ content_length = int(
+ handler_instance.headers.get("Content-Length", 0)
+ )
+ except (ValueError, TypeError):
+ content_length = 0 # Default to 0 if header is malformed
+
+ if content_length > 0:
+ post_data = handler_instance.rfile.read(content_length).decode(
+ "utf-8"
+ )
+ parsed_data = urllib.parse.parse_qs(post_data)
+ file_path = parsed_data.get("file_path", [""])[0]
+ else:
+ file_path = "" # No content, no file_path
+
+ if file_path and handler_instance.controller:
+ handler_instance.controller.set_manual_target_file(file_path)
+ message = f"Manual target set to: {file_path}"
+ status_code = 200
+ else:
+ message = (
+ "Error: No file path provided or controller not available."
+ )
+ status_code = 400
+
+ handler_instance.send_response(status_code)
+ handler_instance.send_header("Content-type", "application/json")
+ handler_instance.end_headers()
+ handler_instance.wfile.write(
+ json.dumps({"message": message}).encode("utf-8")
+ )
+ else:
+ handler_instance.send_error(404)
+
+ # Fix: Use setattr to bypass Mypy [method-assign] error
+ setattr(ObserverHandler, "do_POST", _dynamic_do_POST_method)
+
+ ObserverHandler.controller = self
+
+ def run_server():
+ try:
+ server = HTTPServer(("localhost", 5000), ObserverHandler)
+ server.serve_forever()
+ except Exception as e:
+ logger.error(f"Dashboard failed to start: {e}")
+
+ threading.Thread(target=run_server, daemon=True).start()
+
+ print("\n" + "=" * 60)
+ print("PyOuroBoros (PyOB) OBSERVER IS LIVE")
+ print("URL: http://localhost:5000")
+ print(f"FILE: {obs_path}")
+ print("=" * 60 + "\n")
+
+ def execute_targeted_iteration(self: Any, iteration: int):
+ backup_state = self.llm_engine.backup_workspace()
+ target_diff = ""
+ if self.cascade_queue:
+ target_rel_path = self.cascade_queue.pop(0)
+ target_diff = self.cascade_diffs.get(target_rel_path, "")
+ logger.warning(
+ f"SYMBOLIC CASCADE: Targeting impacted file: {target_rel_path}"
+ )
+ is_cascade = True
+ else:
+ target_rel_path = self.pick_target_file()
+ is_cascade = False
+
+ if not target_rel_path:
+ return
+
+ is_engine_file = any(Path(target_rel_path).name == f for f in self.ENGINE_FILES)
+
+ if is_engine_file:
+ timestamp = time.strftime("%Y%m%d_%H%M%S")
+ project_name = os.path.basename(self.target_dir)
+ base_backup_path = Path.home() / "Documents" / "PYOB_Backups" / project_name
+ pod_path = base_backup_path / f"safety_pod_v{iteration}_{timestamp}"
+
+ try:
+ pod_path.mkdir(parents=True, exist_ok=True)
+ logger.warning(
+ f"SELF-EVOLUTION: Sheltering engine source EXTERNALLY in {pod_path}"
+ )
+ for f_name in self.ENGINE_FILES:
+ src = os.path.join(self.target_dir, "src", "pyob", f_name)
+ if os.path.exists(src):
+ shutil.copy(src, str(pod_path))
+ except Exception as e:
+ logger.error(f"Failed to create external safety pod: {e}")
+
+ target_abs_path = os.path.join(self.target_dir, target_rel_path)
+ self.llm_engine.session_context = []
+ if is_cascade and target_diff:
+ msg = f"CRITICAL SYMBOLIC RIPPLE: This file depends on code that was just modified. Ensure this file is updated to support these changes:\n\n### DEPDENDENCY CHANGE DIFF:\n{target_diff}"
+ self.llm_engine.session_context.append(msg)
+
+ old_content = ""
+ if os.path.exists(target_abs_path):
+ with open(target_abs_path, "r", encoding="utf-8", errors="ignore") as f:
+ old_content = f.read()
+
+ reviewer = TargetedReviewer(self.target_dir, target_abs_path)
+ reviewer.session_context = self.llm_engine.session_context[:]
+ reviewer.run_pipeline(iteration)
+
+ self.llm_engine.session_context = reviewer.session_context[:]
+
+ new_content = ""
+ if os.path.exists(target_abs_path):
+ with open(target_abs_path, "r", encoding="utf-8", errors="ignore") as f:
+ new_content = f.read()
+
+ logger.info(f"Refreshing metadata for `{target_rel_path}`...")
+ self.update_analysis_for_single_file(target_abs_path, target_rel_path)
+ self.update_ledger_for_file(target_rel_path, new_content)
+
+ if old_content != new_content:
+ logger.info(
+ f"Edit successful. Checking ripples and running final verification for {target_rel_path}..."
+ )
+ self.append_to_history(target_rel_path, old_content, new_content)
+
+ current_diff = "".join(
+ difflib.unified_diff(
+ old_content.splitlines(keepends=True),
+ new_content.splitlines(keepends=True),
+ )
+ )
+
+ ripples = self.detect_symbolic_ripples(
+ old_content, new_content, target_rel_path
+ )
+ if ripples:
+ logger.warning(
+ f"CROSS-FILE DEPTH TRIGGERED! Queuing {len(ripples)} files."
+ )
+ for r in ripples:
+ if r not in self.cascade_queue:
+ self.cascade_queue.append(r)
+ self.cascade_diffs[r] = current_diff
+
+ logger.info("\n" + "=" * 20 + " FINAL VERIFICATION " + "=" * 20)
+ if not self._run_final_verification_and_heal(backup_state):
+ logger.error(
+ "Final verification failed and could not be auto-repaired. Iteration changes have been rolled back."
+ )
+ else:
+ logger.info("Final verification successful. Application is stable.")
+ self.handle_git_librarian(target_rel_path, iteration)
+
+ if is_engine_file:
+ logger.warning(
+ f"SELF-EVOLUTION: `{target_rel_path}` was successfully updated."
+ )
+ self.self_evolved_flag = True
+
+ logger.info("=" * 60 + "\n")
diff --git a/src/pyob/evolution_mixins.py b/src/pyob/evolution_mixins.py
new file mode 100644
index 0000000..ba0d0d6
--- /dev/null
+++ b/src/pyob/evolution_mixins.py
@@ -0,0 +1,321 @@
+import os
+import re
+import shutil
+import subprocess
+import sys
+import time
+from typing import Any, Optional
+
+from .core_utils import logger
+
+
+class EvolutionMixin:
+ """Methods for project analysis, verification, and git librarian duties."""
+
+ target_dir: str
+ analysis_path: str
+ history_path: str
+ symbols_path: str
+ llm_engine: Any
+ code_parser: Any
+ ledger: dict
+ key_cooldowns: dict
+ manual_target_file: Optional[str]
+
+ def handle_git_librarian(self, rel_path: str, iteration: int):
+ """Creates a branch, generates an AI summary, and opens a professional PR."""
+ if not os.path.exists(os.path.join(self.target_dir, ".git")):
+ return
+
+ # 1. Capture the raw diff before committing
+ diff_proc = subprocess.run(
+ ["git", "diff", "HEAD", rel_path],
+ cwd=self.target_dir,
+ capture_output=True,
+ text=True,
+ )
+ diff_text = diff_proc.stdout or "Minor structural refinement."
+
+ # 2. Ask the AI to write the PR summary (inherited from CoreUtilsMixin)
+ summary = getattr(self, "generate_pr_summary")(rel_path, diff_text)
+ title = summary.get("title", f"Evolution: Refactor of {rel_path}")
+ body = summary.get(
+ "body",
+ f"This PR was automatically generated by PyOB.\n\nFile: `{rel_path}`",
+ )
+
+ # 3. Git Operations
+ timestamp = int(time.time())
+ branch_name = f"pyob-evolution-v{iteration}-{timestamp}"
+ logger.info(f" LIBRARIAN: Publishing Evolution: {title}")
+
+ if not getattr(self, "_run_git_command")(
+ ["git", "checkout", "-b", branch_name]
+ ):
+ return
+
+ getattr(self, "_run_git_command")(["git", "add", rel_path])
+
+ # Use AI-generated title as commit message
+ if not getattr(self, "_run_git_command")(["git", "commit", "-m", title]):
+ return
+
+ # 4. Push to GitHub and create the PR
+ if shutil.which("gh"):
+ logger.info("Pushing to GitHub and opening Pull Request...")
+ if getattr(self, "_run_git_command")(
+ ["git", "push", "origin", branch_name]
+ ):
+ getattr(self, "_run_git_command")(
+ [
+ "gh",
+ "pr",
+ "create",
+ "--title",
+ title,
+ "--body",
+ body,
+ "--base",
+ "main",
+ ]
+ )
+ # --- THE WRAP-UP UPDATE ---
+ # Increment the session counter only after successful PR creation
+ if hasattr(self, "session_pr_count"):
+ self.session_pr_count += 1
+ logger.info(
+ f"Session Progress: {self.session_pr_count}/8 PRs completed."
+ )
+ # --------------------------
+ else:
+ logger.warning("GitHub CLI (gh) not found. Committed locally only.")
+
+ def _run_final_verification_and_heal(self, backup_state: dict) -> bool:
+ """Runs the project entry file for 10 seconds and auto-fixes crashes."""
+ entry_file = self.llm_engine._find_entry_file()
+ if not entry_file:
+ logger.warning("No main entry file found. Skipping runtime test.")
+ return True
+
+ rel_entry_file = os.path.relpath(entry_file, self.target_dir)
+
+ for attempt in range(3):
+ logger.info(f"Launching `{rel_entry_file}` (Attempt {attempt + 1}/3)")
+ cmd: list[str] = []
+ is_html = entry_file.endswith((".html", ".htm"))
+ is_py = entry_file.endswith(".py")
+ is_js = entry_file.endswith((".js", "package.json"))
+
+ if is_py:
+ venv_py = os.path.join(self.target_dir, "build_env", "bin", "python3")
+ python_cmd = venv_py if os.path.exists(venv_py) else sys.executable
+ cmd = [python_cmd, entry_file]
+ elif is_js:
+ cmd = (
+ ["npm", "start"]
+ if entry_file.endswith("package.json")
+ else ["node", entry_file]
+ )
+ elif is_html:
+ if os.environ.get("GITHUB_ACTIONS") == "true":
+ return True
+ if sys.platform == "darwin":
+ cmd = ["open", entry_file]
+ elif sys.platform == "win32":
+ cmd = [f'start "" "{entry_file}"']
+ else:
+ cmd = ["xdg-open", entry_file]
+
+ if not cmd:
+ return True
+ use_shell = bool(cmd and (cmd[0].startswith("start") or cmd[0] == "open"))
+
+ start_time = time.time()
+ stdout, stderr = "", "" # Initialize stdout and stderr
+ try:
+ process = subprocess.Popen(
+ cmd,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ cwd=self.target_dir,
+ shell=use_shell,
+ )
+ if is_html:
+ try:
+ stdout, stderr = process.communicate(timeout=5)
+ if process.returncode == 0:
+ return True # HTML launched and exited cleanly within 5s
+ except subprocess.TimeoutExpired:
+ process.terminate()
+ process.wait()
+ return True # HTML launched and ran for 5s, considered success
+ # If HTML process exited with non-zero code within 5s,
+ # stdout/stderr are already captured, fall through to general error handling.
+ else: # For non-HTML processes, use the 10s timeout
+ stdout, stderr = process.communicate(timeout=10)
+ except Exception as e:
+ logger.error(f"Execution failed: {e}")
+ stdout, stderr = "", str(e)
+
+ duration = time.time() - start_time
+ has_error = any(
+ kw in stderr or kw in stdout
+ for kw in [
+ "Traceback",
+ "Exception",
+ "Error:",
+ "ModuleNotFoundError",
+ "ImportError",
+ ]
+ )
+ if process.returncode not in (0, 15, -15, None) or has_error:
+ logger.warning(f"App crashed after {duration:.1f}s!")
+ if attempt < 2:
+ self.llm_engine._fix_runtime_errors(
+ stderr + "\n" + stdout, entry_file
+ )
+ else:
+ logger.info(f"App ran successfully for {duration:.1f}s.")
+ return True
+
+ self.llm_engine.restore_workspace(backup_state)
+ return False
+
+ def pick_target_file(self) -> str:
+ """Uses LLM to strategically pick the next file to evolve."""
+ # Fix: Cast manual_target_file to Optional[str] via local variable for Mypy
+ manual: Optional[str] = getattr(self, "manual_target_file", None)
+ if manual:
+ setattr(self, "manual_target_file", None)
+ return str(manual)
+
+ analysis = str(getattr(self, "_read_file")(self.analysis_path))
+ history = str(
+ getattr(self, "_read_file")(self.history_path) or "No history yet."
+ )
+ last_file = ""
+ for line in reversed(history.strip().split("\n")):
+ if line.startswith("## "):
+ match = re.search(r"`([^`]+)`", line)
+ if match:
+ last_file = match.group(1)
+ break
+
+ prompt = (
+ f"Choose ONE relative file path to review next based on ANALYSIS.md/HISTORY.md.\n"
+ f"STRATEGIC RULES:\n1. DO NOT pick `{last_file}`.\n"
+ f"2. Rotate between logic, UI, and styles.\nOutput ONLY the path.\n\n"
+ f"### Analysis:\n{analysis}\n### History:\n{history}"
+ )
+
+ def val(text: str) -> bool:
+ path = getattr(self, "_extract_path_from_llm_response")(text)
+ return (
+ os.path.exists(os.path.join(self.target_dir, path))
+ and path != last_file
+ )
+
+ response = getattr(self, "get_valid_llm_response")(
+ prompt, val, context="Target Selector"
+ )
+ # Fix: Explicitly return a string
+ return str(getattr(self, "_extract_path_from_llm_response")(response))
+
+ def build_initial_analysis(self):
+ """Bootstraps ANALYSIS.md and SYMBOLS.json."""
+ logger.info("ANALYSIS.md not found. Bootstrapping Deep Symbolic Scan...")
+ all_files = sorted(self.llm_engine.scan_directory())
+ struct_map = "\n".join(os.path.relpath(f, self.target_dir) for f in all_files)
+
+ p_summary = getattr(self, "get_valid_llm_response")(
+ f"Write a 2-sentence summary of this project: {struct_map}",
+ lambda t: len(t) > 5,
+ context="Project Genesis",
+ ).strip()
+ content = f"# Project Analysis\n\n**Project Summary:**\n{p_summary}\n\n-----n\n## File Directory\n\n"
+
+ file_structures = {}
+ for f_path in all_files:
+ rel = os.path.relpath(f_path, self.target_dir)
+ with open(f_path, "r", encoding="utf-8", errors="ignore") as f:
+ code = f.read()
+ self.update_ledger_for_file(rel, code)
+ file_structures[rel] = self.code_parser.generate_structure_dropdowns(
+ f_path, code
+ )
+
+ batch_prompt = "Output 'filepath: summary' for each:\n" + "\n".join(
+ f"{r}: {s}" for r, s in file_structures.items()
+ )
+ batch_resp = getattr(self, "get_valid_llm_response")(
+ batch_prompt, lambda t: ":" in t, context="Batch Genesis"
+ ).strip()
+
+ summaries = {
+ line.split(":", 1)[0].strip("`* "): line.split(":", 1)[1].strip()
+ for line in batch_resp.splitlines()
+ if ":" in line
+ }
+
+ for f_path in all_files:
+ rel = os.path.relpath(f_path, self.target_dir)
+ summary_text = summaries.get(rel, "No summary.")
+ content += f"### `{rel}`\n**Summary:** {summary_text}\n\n{file_structures[rel]}\n---\n"
+
+ with open(self.analysis_path, "w", encoding="utf-8") as f:
+ f.write(content)
+ self.save_ledger()
+
+ def wrap_up_evolution_session(self):
+ """Generates a master summary of the entire session and opens a final PR."""
+ logger.info("🎬 INITIATING WRAP-UP PHASE: Generating session summary...")
+
+ history_text = getattr(self, "_read_file")(self.history_path)
+
+ prompt = f"""
+ Analyze the following evolution history for this session and write a 'Master Session Summary'.
+ We have successfully submitted {self.session_pr_count} Pull Requests.
+ YOUR TASK:
+ Create a Markdown report named 'PR_SUMMARY.md'.
+ Include:
+ 1. ## Session Overview (High-level goals achieved)
+ 2. ## Technical Milestones (List the major features/refactors)
+ 3. ## Architectural Impact (How the codebase is healthier now)
+ HISTORY:
+ {history_text}
+ Output ONLY the Markdown content for the file. Use a professional, triumphant tone.
+ """
+ summary_md = self.get_valid_llm_response(
+ prompt, lambda t: len(t) > 100, context="Session Architect"
+ )
+ # Save the file to root
+ summary_path = os.path.join(self.target_dir, "PR_SUMMARY.md")
+ with open(summary_path, "w", encoding="utf-8") as f:
+ f.write(summary_md)
+
+ # Create a final branch for the summary
+ branch_name = f"evolution-summary-{int(time.time())}"
+ self._run_git_command(["git", "checkout", "-b", branch_name])
+ self._run_git_command(["git", "add", "PR_SUMMARY.md"])
+ self._run_git_command(
+ ["git", "commit", "-m", "Final: Evolution Session Summary Report"]
+ )
+
+ if shutil.which("gh"):
+ self._run_git_command(["git", "push", "origin", branch_name])
+ self._run_git_command(
+ [
+ "gh",
+ "pr",
+ "create",
+ "--title",
+ f"🏆 Final Evolution Summary: {self.session_pr_count} PRs Completed",
+ "--body",
+ "This PR contains the architectural summary for the entire autonomous run. PyOB has completed its assigned tasks and is now entering sleep mode.",
+ "--base",
+ "main",
+ ]
+ )
+
+ logger.info("✅ Final summary submitted. PyOB is now resting.")
diff --git a/src/pyob/feature_mixins.py b/src/pyob/feature_mixins.py
new file mode 100644
index 0000000..cc13529
--- /dev/null
+++ b/src/pyob/feature_mixins.py
@@ -0,0 +1,340 @@
+import os
+import re
+
+from .core_utils import (
+ FEATURE_FILE_NAME,
+ PR_FILE_NAME,
+ logger,
+)
+
+
+class FeatureOperationsMixin:
+ target_dir: str
+ pr_file: str
+ feature_file: str
+ session_context: list[str]
+
+ def write_pr(self, filepath: str, explanation: str, llm_response: str):
+ rel_path = os.path.relpath(filepath, self.target_dir)
+ mode = "a" if os.path.exists(self.pr_file) else "w"
+ with open(self.pr_file, mode, encoding="utf-8") as f:
+ if mode == "w":
+ f.write(
+ "# Autonomous Code Review & Patch Proposals\n\n*Automatically generated by AutoPR Reviewer*\n\n---\n"
+ )
+ f.write(
+ f"\n## Review for `{rel_path}`\n**AI Analysis & Fixes:**\n{explanation}\n\n"
+ )
+ edits = re.findall(
+ r".*?", llm_response, re.DOTALL | re.IGNORECASE
+ )
+ if edits:
+ f.write("### Proposed Patch:\n```xml\n")
+ for edit in edits:
+ f.write(edit.strip() + "\n\n")
+ f.write("```\n")
+ f.write("\n---\n")
+ logger.info(f"Appended successful fix for {rel_path} to {PR_FILE_NAME}")
+
+ def analyze_file(self, filepath: str, current_index: int, total_files: int):
+ try:
+ with open(filepath, "r", encoding="utf-8") as f:
+ lines = f.readlines()
+ content = "".join(lines)
+ except UnicodeDecodeError:
+ return
+ lang_name, lang_tag = getattr(self, "get_language_info")(filepath)
+ filename = os.path.basename(filepath)
+ logger.info(
+ f"[{current_index}/{total_files}] Scanning {filename} ({lang_name}) - Reading {len(lines)} lines into AI context..."
+ )
+ ruff_out, mypy_out, custom_issues = "", "", []
+ if lang_tag == "python":
+ ruff_out, mypy_out = getattr(self, "run_linters")(filepath)
+ custom_issues = getattr(self, "scan_for_lazy_code")(filepath, content)
+ prompt = getattr(self, "build_patch_prompt")(
+ lang_name, lang_tag, content, ruff_out, mypy_out, custom_issues
+ )
+ new_code, explanation, llm_response = getattr(self, "get_valid_edit")(
+ prompt, content, require_edit=False, target_filepath=filepath
+ )
+ if new_code == content:
+ logger.info(f"AI Analysis complete. No changes required for {filename}.\n")
+ return
+ if new_code != content:
+ self.write_pr(filepath, explanation, llm_response)
+ self.session_context.append(
+ f"Proposed patch for `{filename}` to fix: {explanation}"
+ )
+ logger.info(
+ f" Issues found and patched in {filename}. Added to {PR_FILE_NAME}.\n"
+ )
+
+ def propose_feature(self, target_path: str):
+ rel_path = os.path.relpath(target_path, self.target_dir)
+ lang_name, lang_tag = getattr(self, "get_language_info")(target_path)
+ with open(target_path, "r", encoding="utf-8") as f:
+ content = f.read()
+ logger.info(
+ f"\nPHASE 2: Generating an interactive feature proposal for [{rel_path}]..."
+ )
+ memory_section = getattr(self, "_get_rich_context")()
+ prompt = getattr(self, "load_prompt")(
+ "PF.md",
+ lang_name=lang_name,
+ memory_section=memory_section,
+ lang_tag=lang_tag,
+ content=content,
+ rel_path=rel_path,
+ )
+ print("\n" + "=" * 50)
+ print(f" Feature Proposal Prompt Ready: [{rel_path}]")
+ print("=" * 50)
+ print(
+ f"The AI has prepared a prompt to generate a feature proposal for: {rel_path}"
+ )
+ user_choice = getattr(self, "get_user_approval")(
+ "Hit ENTER to send as-is, type 'EDIT_PROMPT' to refine the full prompt, or 'SKIP' to cancel this proposal.",
+ timeout=220,
+ )
+ if user_choice == "SKIP":
+ logger.info("Feature proposal skipped by user.")
+ return
+ if user_choice == "EDIT_PROMPT":
+ prompt = getattr(self, "_edit_prompt_with_external_editor")(prompt)
+ if not prompt.strip():
+ logger.warning("Edited prompt is empty. Skipping feature proposal.")
+ return
+
+ def validator(text):
+ return "" in text and "" in text
+
+ llm_response = getattr(self, "get_valid_llm_response")(
+ prompt, validator, context=rel_path
+ )
+ thought_match = re.search(
+ r"<(?:THOUGHT|EXPLANATION)>(.*?)(?:THOUGHT|EXPLANATION)>",
+ llm_response,
+ re.DOTALL | re.IGNORECASE,
+ )
+ snippet_match = re.search(
+ r"\n?(.*?)\n?", llm_response, re.DOTALL | re.IGNORECASE
+ )
+ explanation = (
+ thought_match.group(1).strip()
+ if thought_match
+ else "No explicit explanation generated."
+ )
+ suggested_code = snippet_match.group(1).strip() if snippet_match else ""
+ suggested_code = re.sub(r"^```[a-zA-Z]*\n", "", suggested_code)
+ suggested_code = re.sub(r"\n```$", "", suggested_code)
+ suggested_code = suggested_code.strip()
+ if not suggested_code:
+ return logger.error("LLM failed to generate a valid feature snippet.")
+ with open(self.feature_file, "w", encoding="utf-8") as f:
+ f.write(
+ f"# Feature Proposal\n\n**Target File:** `{rel_path}`\n\n**Explanation:**\n{explanation}\n\n### Suggested Addition/Optimization:\n```{lang_tag}\n{suggested_code}\n```\n\n---\n> **ACTION REQUIRED:** Review this feature proposal. Wait for terminal prompt to approve.\n"
+ )
+ logger.info(f"Feature proposal written to {FEATURE_FILE_NAME}.")
+
+ def implement_feature(self, feature_content: str) -> bool:
+ match = re.search(r"\*\*Target File:\*\* `(.*?)`", feature_content)
+ if not match:
+ logger.error("Could not determine target file from FEATURE.md formatting.")
+ return False
+
+ rel_path = match.group(1)
+ target_path = os.path.join(self.target_dir, rel_path)
+ target_folder = os.path.dirname(target_path)
+ lang_name, lang_tag = getattr(self, "get_language_info")(target_path)
+
+ with open(target_path, "r", encoding="utf-8") as f_handle:
+ source_code = f_handle.read()
+
+ created_files: list[str] = []
+ new_file_matches = re.finditer(
+ r'(.*?)', feature_content, re.DOTALL
+ )
+
+ for file_match in new_file_matches:
+ new_path_rel = file_match.group(1)
+ new_code_payload = file_match.group(2).strip()
+
+ # Pathing: Naked filenames go into the same package as the target file
+ if "/" not in new_path_rel and "\\" not in new_path_rel:
+ new_path_abs = os.path.join(target_folder, new_path_rel)
+ else:
+ new_path_abs = os.path.join(self.target_dir, new_path_rel)
+
+ if not os.path.exists(new_path_abs):
+ try:
+ os.makedirs(os.path.dirname(new_path_abs), exist_ok=True)
+ logger.warning(
+ f"ARCHITECTURAL SPLIT: Spawning new module `{os.path.basename(new_path_abs)}`"
+ )
+ with open(new_path_abs, "w", encoding="utf-8") as f_new:
+ f_new.write(new_code_payload)
+
+ # Immediately stage for Git so the Librarian sees it
+ import subprocess
+
+ subprocess.run(["git", "add", new_path_abs], cwd=self.target_dir)
+ created_files.append(new_path_abs)
+ except OSError as e:
+ logger.error(f"Failed to create new module {new_path_rel}: {e}")
+
+ exp_match = re.search(
+ r"\*\*Explanation:\*\*(.*?)(?:###|---|>)",
+ feature_content,
+ re.DOTALL | re.IGNORECASE,
+ )
+ feature_explanation = (
+ exp_match.group(1).strip()
+ if exp_match
+ else f"Implemented a new structural feature for: [{rel_path}]..."
+ )
+
+ logger.info(
+ f"Implementing approved feature seamlessly directly into {rel_path}..."
+ )
+ memory_section = getattr(self, "_get_rich_context")()
+ prompt = getattr(self, "load_prompt")(
+ "IF.md",
+ memory_section=memory_section,
+ feature_content=feature_content,
+ lang_name=lang_name,
+ lang_tag=lang_tag,
+ source_code=source_code,
+ rel_path=rel_path,
+ )
+
+ new_code, _, _ = getattr(self, "get_valid_edit")(
+ prompt, source_code, require_edit=True, target_filepath=target_path
+ )
+
+ if new_code == source_code:
+ logger.error("Implementation failed. Rolling back created modules.")
+ for file_path in created_files:
+ if os.path.exists(file_path):
+ os.remove(file_path)
+ return False
+
+ if lang_tag == "python":
+ new_code = getattr(self, "ensure_imports_retained")(
+ source_code, new_code, target_path
+ )
+
+ with open(target_path, "w", encoding="utf-8") as f_out:
+ f_out.write(new_code)
+
+ if lang_tag == "python":
+ # Verification Pipeline
+ if not getattr(self, "run_linter_fix_loop")(
+ context_of_change=feature_content
+ ) or not getattr(self, "run_and_verify_app")(
+ context_of_change=feature_content
+ ):
+ logger.error("Verification failed. Cleaning up spawned modules.")
+ for file_path in created_files:
+ if os.path.exists(file_path):
+ os.remove(file_path)
+ return False
+
+ if not getattr(self, "check_downstream_breakages")(target_path, rel_path):
+ logger.error("Downstream breakages. Rolling back spawned modules.")
+ for file_path in created_files:
+ if os.path.exists(file_path):
+ os.remove(file_path)
+ return False
+
+ logger.info(f"Successfully implemented feature directly into {rel_path}.")
+
+ self.session_context.append(
+ f"SUCCESSFUL CHANGE in `{rel_path}`: {feature_explanation}"
+ )
+
+ if created_files:
+ self.session_context.append(
+ "Created new modules: "
+ + ", ".join([os.path.basename(fp) for fp in created_files])
+ )
+
+ if os.path.exists(self.feature_file):
+ os.remove(self.feature_file)
+
+ return True
+
+ def implement_pr(self, pr_content: str) -> bool:
+ """
+ Implements PR patches atomically.
+ Only applies changes if ALL patches in the PR pass the XML,
+ safety, and verification gates.
+ """
+ logger.info("Implementing approved PRs seamlessly from XML blocks...")
+ file_sections = re.split(r"## (?:🛠 )?Review for `(.*?)`", pr_content)
+
+ # Guard: Ensure content actually contains patches
+ if len(file_sections) < 3:
+ logger.error(f"No valid file patches found in {PR_FILE_NAME} to apply.")
+ return False
+
+ all_success = True
+ patches_to_apply = [] # Atomic transaction buffer
+
+ # 1. PRE-FLIGHT VALIDATION (Check all patches before applying any)
+ for i in range(1, len(file_sections), 2):
+ rel_path = file_sections[i].strip()
+ section_content = file_sections[i + 1]
+ target_path = os.path.join(self.target_dir, rel_path)
+
+ if not os.path.exists(target_path):
+ logger.error(f"Target file {rel_path} not found.")
+ all_success = False
+ continue
+
+ with open(target_path, "r", encoding="utf-8") as f:
+ source_code = f.read()
+
+ # Apply XML edit logic
+ new_code, explanation, success = getattr(self, "apply_xml_edits")(
+ source_code, section_content
+ )
+
+ # --- EMERGENCY DELETION GUARD ---
+ # If the patch deletes > 50% of the file, it is highly likely a hallucination.
+ if len(new_code) < (len(source_code) * 0.5):
+ logger.error(
+ f"EMERGENCY STOP: Patch for {rel_path} would delete too much code."
+ )
+ all_success = False
+ continue
+
+ # Patch Validation
+ if not success:
+ logger.error(f"Failed to match XML blocks for {rel_path}.")
+ all_success = False
+ elif new_code == source_code:
+ logger.warning(f"Patch for {rel_path} changed nothing. Skipping.")
+ else:
+ patches_to_apply.append((target_path, new_code, rel_path))
+
+ # 2. ATOMIC COMMIT (Only write to disk if everything checked out)
+ if all_success and patches_to_apply:
+ for target_path, new_code, rel_path in patches_to_apply:
+ with open(target_path, "w", encoding="utf-8") as f:
+ f.write(new_code)
+ logger.info(f"Successfully applied patch to {rel_path}.")
+
+ # 3. VERIFICATION PIPELINE (The final test)
+ if not getattr(self, "run_linter_fix_loop")(
+ context_of_change=pr_content
+ ) or not getattr(self, "run_and_verify_app")(context_of_change=pr_content):
+ logger.error("Verification failed. Changes will be rolled back.")
+ return False
+
+ self.session_context.append("Applied automated patch XML edits.")
+ if os.path.exists(self.pr_file):
+ os.remove(self.pr_file)
+ return True
+
+ return False
diff --git a/src/pyob/get_valid_edit.py b/src/pyob/get_valid_edit.py
new file mode 100644
index 0000000..256a074
--- /dev/null
+++ b/src/pyob/get_valid_edit.py
@@ -0,0 +1,270 @@
+import difflib
+import os
+import re
+import time
+
+from .core_utils import logger
+
+
+class GetValidEditMixin:
+ def get_valid_edit(
+ self,
+ prompt: str,
+ source_code: str,
+ require_edit: bool = True,
+ target_filepath: str = "",
+ ) -> tuple[str, str, str]:
+ base_dir = getattr(self, "target_dir", os.getcwd())
+ display_name = (
+ os.path.relpath(target_filepath, base_dir)
+ if target_filepath
+ else "System Update"
+ )
+
+ # 1. Pre-Flight Human Check
+ prompt, skip = self._handle_pre_generation_approval(prompt, display_name)
+ if skip:
+ return source_code, "AI generation skipped by user.", ""
+
+ attempts = 0
+ while True:
+ # 2. Fetch from AI (Handles keys, retries, and API limits)
+ response_text, attempts = self._fetch_llm_with_retries(
+ prompt, display_name, attempts
+ )
+
+ # 3. Validate and Apply XML Patch
+ new_code, explanation, is_valid = self._validate_llm_patch(
+ source_code, response_text, require_edit, display_name
+ )
+
+ if not is_valid:
+ attempts += 1
+ continue
+
+ if new_code == source_code:
+ return new_code, explanation, response_text
+
+ # 4. Post-Flight Human Review (Diffs and Approval)
+ final_code, final_exp, final_resp, action = (
+ self._handle_post_generation_review(
+ source_code,
+ new_code,
+ explanation,
+ response_text,
+ target_filepath,
+ display_name,
+ )
+ )
+
+ if action == "REGENERATE":
+ attempts += 1
+ continue
+
+ return final_code, final_exp, final_resp
+
+ # ==========================================
+ # PRIVATE HELPER METHODS
+ # ==========================================
+
+ def _handle_pre_generation_approval(
+ self, prompt: str, display_name: str
+ ) -> tuple[str, bool]:
+ print("\n" + "=" * 50)
+ print(f"AI Generation Prompt Ready: [{display_name}]")
+ print("=" * 50)
+ choice = getattr(self, "get_user_approval")(
+ "Hit ENTER to send as-is, type 'EDIT_PROMPT', 'AUGMENT_PROMPT', or 'SKIP'.",
+ timeout=220,
+ )
+ if choice == "SKIP":
+ return prompt, True
+ elif choice == "EDIT_PROMPT":
+ prompt = getattr(self, "_edit_prompt_with_external_editor")(prompt)
+ elif choice == "AUGMENT_PROMPT":
+ aug = getattr(self, "_get_user_prompt_augmentation")()
+ if aug.strip():
+ prompt += f"\n\n### User Augmentation:\n{aug.strip()}"
+ return prompt, False
+
+ def _fetch_llm_with_retries(
+ self, prompt: str, display_name: str, attempts: int
+ ) -> tuple[str, int]:
+ is_cloud = (
+ os.environ.get("GITHUB_ACTIONS") == "true"
+ or os.environ.get("CI") == "true"
+ or "GITHUB_RUN_ID" in os.environ
+ )
+ key_cooldowns = getattr(self, "key_cooldowns", {})
+
+ while True:
+ now = time.time()
+ gemini_keys = [k for k in key_cooldowns.keys() if "github" not in k]
+ available_keys = [k for k in gemini_keys if now > key_cooldowns[k]]
+ key = None
+ gh_model = "Llama-3"
+
+ if available_keys:
+ key = available_keys[attempts % len(available_keys)]
+ logger.info(
+ f"\n[Attempting Gemini API Key {attempts % len(available_keys) + 1}/{len(gemini_keys)}]"
+ )
+ response = getattr(self, "_stream_single_llm")(
+ prompt, key=key, context=display_name
+ )
+ elif is_cloud:
+ if now < key_cooldowns.get("github_llama", 0):
+ gh_model = "Phi-4"
+ if gh_model == "Phi-4" and now < key_cooldowns.get("github_phi", 0):
+ wait = 120
+ logger.warning(
+ f"All API limits exhausted. Sleeping {wait}s for Gemini refill..."
+ )
+ time.sleep(wait)
+ attempts += 1
+ continue
+
+ logger.warning(
+ f"Gemini limited. Pivoting to GitHub Models ({gh_model})..."
+ )
+ response = getattr(self, "_stream_single_llm")(
+ prompt, key=None, context=display_name, gh_model=gh_model
+ )
+ else:
+ logger.info("\n[All keys exhausted. Falling back to Local Ollama]")
+ response = getattr(self, "_stream_single_llm")(
+ prompt, key=None, context=display_name
+ )
+
+ if response.startswith("ERROR_CODE_429"):
+ if key:
+ key_cooldowns[key] = time.time() + 180
+ logger.warning("Key rate limited. Pivoting to next key...")
+ attempts += 1
+ continue
+ elif "RateLimitReached" in response:
+ match = re.search(r"wait (\d+) seconds", response)
+ wait_time = int(match.group(1)) if match else 86400
+ if gh_model == "Llama-3":
+ key_cooldowns["github_llama"] = time.time() + wait_time + 60
+ else:
+ key_cooldowns["github_phi"] = time.time() + wait_time + 60
+ logger.error(
+ f" GITHUB QUOTA REACHED ({gh_model}). Cooldown: {wait_time}s"
+ )
+ attempts += 1
+ continue
+ else:
+ time.sleep(120)
+ attempts += 1
+ continue
+
+ if "ERROR_CODE_413" in response:
+ logger.warning("Payload too large (413). Backing off 120s...")
+ time.sleep(120)
+ attempts += 1
+ continue
+
+ if response.startswith("ERROR_CODE_") or not response.strip():
+ if key and "429" not in (response or ""):
+ key_cooldowns[key] = time.time() + 30
+
+ if available_keys:
+ logger.warning(
+ f"Engine failed with error: {str(response)[:60]}... Rotating..."
+ )
+ attempts += 1
+ time.sleep(5)
+ continue
+
+ logger.warning("API Error or Empty Response. Sleeping 120s...")
+ time.sleep(120)
+ attempts += 1
+ continue
+
+ return response, attempts
+
+ def _validate_llm_patch(
+ self,
+ source_code: str,
+ response_text: str,
+ require_edit: bool,
+ display_name: str,
+ ) -> tuple[str, str, bool]:
+ new_code, explanation, edit_success = getattr(self, "apply_xml_edits")(
+ source_code, response_text
+ )
+ edit_count = len(re.findall(r"", response_text, re.IGNORECASE))
+ lower_exp = explanation.lower()
+ ai_approved = "no fixes needed" in lower_exp or "looks good" in lower_exp
+
+ if not require_edit and ai_approved:
+ return source_code, explanation, True
+ if edit_count > 0 and not edit_success:
+ logger.warning(
+ f"Partial edit failure in {display_name}. Auto-regenerating..."
+ )
+ time.sleep(30)
+ return source_code, explanation, False
+ if require_edit and new_code == source_code:
+ logger.warning("Search block mismatch. Rotating...")
+ time.sleep(30)
+ return source_code, explanation, False
+ if not require_edit and new_code == source_code and not ai_approved:
+ time.sleep(30)
+ return source_code, explanation, False
+
+ return new_code, explanation, True
+
+ def _handle_post_generation_review(
+ self,
+ source_code: str,
+ new_code: str,
+ explanation: str,
+ response_text: str,
+ target_filepath: str,
+ display_name: str,
+ ) -> tuple[str, str, str, str]:
+ print("\n" + "=" * 50)
+ print(f"AI Proposed Edit Ready for: [{display_name}]")
+ print("=" * 50)
+ diff_lines = list(
+ difflib.unified_diff(
+ source_code.splitlines(keepends=True),
+ new_code.splitlines(keepends=True),
+ fromfile="Original",
+ tofile="Proposed",
+ )
+ )
+ for line in diff_lines[2:22]:
+ clean = line.rstrip()
+ if clean.startswith("+"):
+ print(f"\033[92m{clean}\033[0m")
+ elif clean.startswith("-"):
+ print(f"\033[91m{clean}\033[0m")
+ elif clean.startswith("@@"):
+ print(f"\033[94m{clean}\033[0m")
+ else:
+ print(clean)
+
+ choice = getattr(self, "get_user_approval")(
+ "Hit ENTER to APPLY, type 'EDIT_CODE', 'EDIT_XML', 'REGENERATE', or 'SKIP'.",
+ timeout=220,
+ )
+
+ if choice == "SKIP":
+ return source_code, "Edit skipped.", "", "SKIP"
+ elif choice == "REGENERATE":
+ return source_code, explanation, response_text, "REGENERATE"
+ elif choice == "EDIT_XML":
+ resp = getattr(self, "_edit_prompt_with_external_editor")(response_text)
+ nc, exp, _ = getattr(self, "apply_xml_edits")(source_code, resp)
+ return nc, exp, resp, "APPLY"
+ elif choice == "EDIT_CODE":
+ ext = os.path.splitext(target_filepath)[1] if target_filepath else ".py"
+ ec = getattr(self, "_launch_external_code_editor")(
+ new_code, file_suffix=ext
+ )
+ return ec, explanation + " (User edited)", response_text, "APPLY"
+
+ return new_code, explanation, response_text, "APPLY"
diff --git a/src/pyob/models.py b/src/pyob/models.py
new file mode 100644
index 0000000..45c71da
--- /dev/null
+++ b/src/pyob/models.py
@@ -0,0 +1,386 @@
+import json
+import logging
+import os
+import re
+import shutil
+import sys
+import threading
+import time
+from typing import Callable, Optional
+
+import requests
+
+logger = logging.getLogger("PyOuroBoros")
+
+try:
+ if (
+ os.environ.get("GITHUB_ACTIONS") == "true"
+ or os.environ.get("CI") == "true"
+ or "GITHUB_RUN_ID" in os.environ
+ ):
+ OLLAMA_AVAILABLE = False
+ else:
+ import ollama
+
+ OLLAMA_AVAILABLE = True
+except ImportError:
+ OLLAMA_AVAILABLE = False
+
+GEMINI_MODEL = os.environ.get("PYOB_GEMINI_MODEL", "gemini-2.5-flash")
+LOCAL_MODEL = os.environ.get("PYOB_LOCAL_MODEL", "qwen3-coder:30b")
+
+
+def stream_gemini(prompt: str, api_key: str, on_chunk: Callable[[], None]) -> str:
+ url = f"https://generativelanguage.googleapis.com/v1beta/models/{GEMINI_MODEL}:streamGenerateContent?alt=sse&key={api_key}"
+ headers = {"Content-Type": "application/json"}
+ data = {
+ "contents": [{"parts": [{"text": prompt}]}],
+ "generationConfig": {"temperature": 0.1},
+ }
+
+ # We use a long timeout on the request, but we will monitor chunk arrival internally
+ response = requests.post(url, headers=headers, json=data, stream=True, timeout=120)
+ if response.status_code != 200:
+ return f"ERROR_CODE_{response.status_code}: {response.text}"
+
+ response_text = ""
+ last_chunk_time = time.time()
+
+ for line in response.iter_lines(decode_unicode=True):
+ if not line:
+ # Check for stall
+ if time.time() - last_chunk_time > 30:
+ logger.warning("Gemini stream stalled. Forcing closure.")
+ break
+ continue
+
+ if line.startswith("data: "):
+ last_chunk_time = time.time() # Reset stall timer
+ try:
+ chunk_data = json.loads(line[6:])
+ text = chunk_data["candidates"][0]["content"]["parts"][0]["text"]
+ on_chunk()
+ response_text += text
+ except (KeyError, IndexError, json.JSONDecodeError):
+ pass
+ return response_text
+
+
+def stream_ollama(prompt: str, on_chunk: Callable[[], None]) -> str:
+ if (
+ os.environ.get("GITHUB_ACTIONS") == "true"
+ or os.environ.get("CI") == "true"
+ or "GITHUB_RUN_ID" in os.environ
+ ):
+ logger.error(
+ "SECURITY VIOLATION: Ollama called in Cloud environment. ABORTING."
+ )
+ time.sleep(60) # CRITICAL: Hard sleep kills outer loop machine-gun attempts
+ return "ERROR_CODE_CLOUD_OLLAMA_FORBIDDEN"
+
+ if not OLLAMA_AVAILABLE:
+ logger.error("Ollama is not available.")
+ time.sleep(60)
+ return "ERROR_CODE_OLLAMA_UNAVAILABLE"
+
+ response_text = ""
+ try:
+ stream = ollama.chat(
+ model=LOCAL_MODEL,
+ messages=[{"role": "user", "content": prompt}],
+ options={"temperature": 0.1, "num_ctx": 32000},
+ stream=True,
+ )
+ for chunk in stream:
+ content = chunk.get("message", {}).get("content", "")
+ if content:
+ on_chunk()
+ print(content, end="", flush=True)
+ response_text += content
+ except Exception as e:
+ logger.error(f"Ollama Error: {e}")
+ time.sleep(30)
+ return f"ERROR_CODE_EXCEPTION: {e}"
+ return response_text
+
+
+def stream_github_models(
+ prompt: str, on_chunk: Callable[[], None], model_name: str = "Llama-3"
+) -> str:
+ token = os.environ.get("GITHUB_TOKEN")
+ if not token:
+ return "ERROR_CODE_TOKEN_MISSING"
+
+ endpoint = "https://models.inference.ai.azure.com/chat/completions"
+ headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
+ actual_model = "Llama-3.3-70B-Instruct" if model_name == "Llama-3" else "Phi-4"
+
+ data = {
+ "model": actual_model,
+ "messages": [{"role": "user", "content": prompt}],
+ "stream": True,
+ "max_tokens": 4096,
+ }
+
+ full_text = ""
+ last_chunk_time = time.time()
+
+ try:
+ response = requests.post(
+ endpoint, headers=headers, json=data, stream=True, timeout=120
+ )
+
+ for line in response.iter_lines():
+ if not line:
+ if time.time() - last_chunk_time > 30:
+ logger.warning("GitHub stream stalled. Forcing closure.")
+ break
+ continue
+
+ line_str = line.decode("utf-8").replace("data: ", "")
+ if line_str.strip() == "[DONE]":
+ break
+
+ try:
+ chunk = json.loads(line_str)
+ content = (
+ chunk.get("choices", [{}])[0].get("delta", {}).get("content", "")
+ )
+ if content:
+ last_chunk_time = time.time()
+ full_text += content
+ on_chunk()
+ except Exception:
+ continue
+ return full_text
+ except Exception as e:
+ return f"ERROR_CODE_EXCEPTION: {str(e)}"
+
+
+def stream_single_llm(
+ prompt: str,
+ key: Optional[str] = None,
+ context: str = "",
+ gh_model: str = "Llama-3",
+) -> str:
+ input_tokens = len(prompt) // 4
+ first_chunk_received = [False]
+ gen_start_time = time.time()
+ is_cloud = (
+ os.environ.get("GITHUB_ACTIONS") == "true"
+ or os.environ.get("CI") == "true"
+ or "GITHUB_RUN_ID" in os.environ
+ )
+
+ def spinner():
+ spinner_chars = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
+ i = 0
+ while not first_chunk_received[0]:
+ cols, _ = shutil.get_terminal_size((80, 20))
+ elapsed = time.time() - gen_start_time
+ expected_time = max(1, input_tokens / 34.0)
+ progress = min(1.0, elapsed / expected_time)
+ bar_len = max(10, cols - 65)
+ filled = int(progress * bar_len)
+ bar = "█" * filled + "░" * (bar_len - filled)
+ status = f"{spinner_chars[i]} Reading [{context}] ~{input_tokens} ctx... [{bar}] {progress * 100:.1f}%"
+ sys.stdout.write(f"\r\033[K{status[: cols - 1]}")
+ sys.stdout.flush()
+ i = (i + 1) % len(spinner_chars)
+ time.sleep(0.1)
+
+ if is_cloud:
+ print(f"Reading [{context}] ~{input_tokens} ctx...", flush=True)
+ else:
+ t = threading.Thread(target=spinner, daemon=True)
+ t.start()
+
+ def on_chunk():
+ if not first_chunk_received[0]:
+ first_chunk_received[0] = True
+ if not is_cloud:
+ sys.stdout.write("\r\033[K")
+ sys.stdout.flush()
+ source = f"Gemini ...{key[-4:]}" if key else f"GitHub Models ({gh_model})"
+ if not key and not is_cloud:
+ source = "Local Ollama"
+ print(f"AI Output ({source}): ", end="", flush=True)
+
+ response_text = ""
+ try:
+ if key is not None:
+ response_text = stream_gemini(prompt, key, on_chunk)
+ elif is_cloud:
+ response_text = stream_github_models(prompt, on_chunk, model_name=gh_model)
+
+ # Immediately intercept 413, pause 60s, and force Gemini usage so outer loops don't panic
+ if response_text and "413" in response_text:
+ first_chunk_received[0] = True
+ logger.warning(
+ "\nPayload too large. Sleeping 60s, then pivoting to Gemini..."
+ )
+ time.sleep(60)
+ gemini_keys = [
+ k.strip()
+ for k in os.environ.get("PYOB_GEMINI_KEYS", "").split(",")
+ if k.strip()
+ ]
+ if gemini_keys:
+ # Return a specific signal string so the caller knows it worked
+ return stream_gemini(prompt, gemini_keys[0], on_chunk)
+ else:
+ return "ERROR_CODE_413_NO_GEMINI_FALLBACK"
+
+ # Force mandatory sleep if ANY cloud error escapes, breaking infinite loop triggers
+ if response_text and response_text.startswith("ERROR_CODE_"):
+ time.sleep(30)
+
+ else:
+ response_text = stream_ollama(prompt, on_chunk)
+ except Exception as e:
+ first_chunk_received[0] = True
+ if is_cloud:
+ time.sleep(30)
+ return f"ERROR_CODE_EXCEPTION: {e}"
+
+ first_chunk_received[0] = True
+ if response_text and not response_text.startswith("ERROR_CODE_"):
+ print(
+ f"\n\n[Generation Complete: ~{len(response_text) // 4} tokens in {time.time() - gen_start_time:.1f}s]"
+ )
+ return response_text
+
+
+def get_valid_llm_response_engine(
+ prompt: str,
+ validator: Callable[[str], bool],
+ key_cooldowns: dict[str, float],
+ context: str = "",
+) -> str:
+ """
+ Robust engine that handles key rotation across multiple providers.
+ Uses cooldown tracking to ensure maximum utilization of free-tier quotas.
+ """
+ attempts = 0
+ is_cloud = (
+ os.environ.get("GITHUB_ACTIONS") == "true"
+ or os.environ.get("CI") == "true"
+ or "GITHUB_RUN_ID" in os.environ
+ )
+
+ while True:
+ key = None
+ now = time.time()
+
+ # 1. Identify all registered Gemini keys and find those not on cooldown
+ gemini_keys = [k for k in list(key_cooldowns.keys()) if "github" not in k]
+ available_keys = [k for k in gemini_keys if now > key_cooldowns[k]]
+ response_text = None
+
+ # 2. DECISION LOGIC: Prioritize Gemini rotation
+ if available_keys:
+ # Cycle through available keys using the attempt counter
+ key = available_keys[attempts % len(available_keys)]
+ logger.info(
+ f"Attempting Gemini Key {attempts % len(available_keys) + 1}/{len(gemini_keys)}"
+ )
+ response_text = stream_single_llm(prompt, key=key, context=context)
+
+ elif is_cloud:
+ # 3. CLOUD FALLBACK: If all Gemini keys are cooling down, use GitHub Models
+ if now < key_cooldowns.get("github_llama", 0):
+ # If Llama is also cooling, try Phi
+ logger.warning("Llama-3 limited. Trying Phi-4...")
+ response_text = stream_single_llm(
+ prompt, key=None, context=context, gh_model="Phi-4"
+ )
+ else:
+ logger.warning(
+ "Gemini exhausted. Pivoting to GitHub Models (Llama-3)..."
+ )
+ response_text = stream_single_llm(
+ prompt, key=None, context=context, gh_model="Llama-3"
+ )
+ else:
+ # 4. LOCAL FALLBACK: Fallback to Ollama
+ logger.info(" All Gemini keys exhausted. Falling back to Local Ollama...")
+ response_text = stream_single_llm(prompt, key=None, context=context)
+
+ # --- ERROR HANDLING BLOCK ---
+ if not response_text or response_text.startswith("ERROR_CODE_"):
+ # A. Gemini Rate Limit (429)
+ if key and response_text and "429" in response_text:
+ key_cooldowns[key] = (
+ time.time() + 180
+ ) # 3 min rest for the specific key
+ logger.warning(f"Key {key[-4:]} rate-limited. Pivoting to next key...")
+ attempts += 1
+ continue # Immediately retry with the next key in the pool
+
+ # B. GitHub Models Daily Quota (429)
+ if (
+ response_text
+ and "429" in response_text
+ and "RateLimitReached" in response_text
+ ):
+ match = re.search(r"wait (\d+) seconds", response_text)
+ seconds_to_wait = int(match.group(1)) if match else 86400
+
+ # Assign cooldown to the specific model that failed
+ if "Llama" in (response_text or ""):
+ key_cooldowns["github_llama"] = time.time() + seconds_to_wait + 60
+ else:
+ key_cooldowns["github_phi"] = time.time() + seconds_to_wait + 60
+
+ logger.error(
+ f"GITHUB QUOTA REACHED. Cooling down model for {seconds_to_wait}s"
+ )
+ attempts += 1
+ continue
+
+ # C. Generic Error Handling / Fail-Safe Sleep
+ if not available_keys:
+ # If everything is exhausted, take a long nap
+ wait = 120
+ logger.warning(
+ f"All API resources exhausted. Sleeping {wait}s for refill..."
+ )
+ time.sleep(wait)
+ attempts = 0 # RESET: Start fresh with Key 1 after the nap
+ continue
+ else:
+ # Key failed for unknown reason, rotate and retry
+ if key:
+ key_cooldowns[key] = time.time() + 30
+ attempts += 1
+ time.sleep(2)
+ continue
+
+ # --- VALIDATION BLOCK ---
+ if validator(response_text):
+ if is_cloud:
+ time.sleep(
+ 5
+ ) # Slow down slightly in cloud to prevent 429 machine-gunning
+ return response_text
+ else:
+ # Try cleaning AI chatter (e.g. "Here is the code:") and re-validating
+ clean_text = (
+ re.sub(
+ r"^(Here is the code:)|(I suggest:)|(```[a-z]*)",
+ "",
+ response_text,
+ flags=re.IGNORECASE,
+ )
+ .strip()
+ .rstrip("`")
+ )
+
+ if validator(clean_text):
+ return clean_text
+
+ # If still invalid, back off and retry
+ wait = 120 if is_cloud else 10
+ logger.warning(f"AI response failed validation. Backing off {wait}s...")
+ time.sleep(wait)
+ attempts += 1
diff --git a/src/pyob/prompts_and_memory.py b/src/pyob/prompts_and_memory.py
new file mode 100644
index 0000000..3f4be73
--- /dev/null
+++ b/src/pyob/prompts_and_memory.py
@@ -0,0 +1,153 @@
+import os
+import re
+
+from pyob.core_utils import logger
+
+
+class SearchAndFilterMixin:
+ def __init__(self):
+ self.search_query = ""
+ self.filter_date = ""
+
+ def handle_search(self, search_query):
+ self.search_query = search_query
+ # Call the data_parser to filter the memory entries based on the search query
+
+ def handle_filter(self, filter_date):
+ self.filter_date = filter_date
+ # Call the data_parser to filter the memory entries based on the filter date
+
+
+class PromptsAndMemoryMixin(SearchAndFilterMixin):
+ target_dir: str
+ history_path: str
+ analysis_path: str
+ memory_path: str
+ memory: str
+
+ def _ensure_prompt_files(self) -> None:
+ data_dir = os.path.join(self.target_dir, ".pyob")
+ os.makedirs(data_dir, exist_ok=True)
+ templates = {
+ "UM.md": "You are the PyOB Memory Manager. Your job is to update MEMORY.md.\n\n### Current Memory:\n{current_memory}\n\n### Recent Actions:\n{session_summary}\n\n### INSTRUCTIONS:\n1. Update the memory with the Recent Actions.\n2. TRANSACTIONAL RECORDING: Only record changes as 'Implemented' if the actions specifically state 'SUCCESSFUL CHANGE'. If you see 'CRITICAL: FAILED' or 'ROLLED BACK', record this as a 'Failed Attempt' with the reason, so the engine knows to try a different approach next time.\n3. BREVITY: Keep the ENTIRE document under 200 words. Be ruthless. Delete old, irrelevant details.\n4. FORMAT: Keep lists strictly to bullet points. No long paragraphs.\n5. Respond EXCLUSIVELY with the raw markdown for MEMORY.md. Do not use ```markdown fences or blocks.",
+ "RM.md": "You are the PYOB Memory Manager. The current MEMORY.md is too bloated and is breaking the AI context window.\n\n### Bloated Memory:\n{current_memory}\n\n### INSTRUCTIONS:\n1. AGGRESSIVELY COMPRESS this memory. \n2. Delete duplicate information, repetitive logs, and obvious statements.\n3. Keep ONLY the core architectural rules and crucial file dependencies.\n4. The final output MUST BE UNDER 150 WORDS.\n5. Respond EXCLUSIVELY with the raw markdown. No fences, no thoughts.",
+ "PP.md": "You are an elite PYOB Software Engineer. Analyze the code for bugs or architectural gaps.\n\n{memory_section}{ruff_section}{mypy_section}{custom_issues_section}### Source Code:\n```{lang_tag}\n{content}\n```\n\n### CRITICAL RULES:\n1. **ITERATION BUDGET**: \n - IF iteration < 5: Focus ONLY on bug fixes, linting, and minor performance tweaks. Do NOT create new files, move classes, or extract logic. \n - IF iteration >= 5: You may propose architectural refactors, file extractions, and logic movement.\n2. **SURGICAL FIXES**: Every block must be exactly 2-5 lines. Only provide ONE block per response.\n3. **NO HALLUCINATIONS**: Do not invent bugs. If the code is functional, state 'The code looks good.'\n4. **ARCHITECTURAL BLOAT**: Only flag bloat for future planning until iteration 5.\n5. **MISSING IMPORTS**: If you use a new module/type, you MUST add an block to import it at the top.\n6. **INDENTATION IS SYNTAX**: Your blocks must have perfect, absolute indentation.\n7. **DEFEATING MYPY**: If fixing a `[union-attr]` error, use `assert variable is not None` or `# type: ignore`.\n8. **IMPORT PATHS (MANDATORY)**: Never use the prefix `src.` in any import statement. The root of the package is `pyob`. (Example: Use `from pyob.core_utils import ...`, NOT `from src.pyob.core_utils import ...`).\n9. **TYPE HINTS (CRITICAL)**: Never use the `|` operator with quoted class names (Forward References). Use `Any` for objects that create circular dependencies.\n10. **ATOMIC REFACTORING**: If you are beyond iteration 5 and need to move logic, use a 3-step process: A) Add new file/imports. B) Update call sites. C) Remove old code in a subsequent turn.\n\n### HOW TO RESPOND (CHOOSE ONE):\n\n**SCENARIO A: Code has bugs/bloat and needs edits:**\n\nSummary: ...\nEvaluation: [Address bloat here if flagged]\nImports Required: ...\nAction: I will fix X by doing Y.\n\n\n\nExact lines to replace (2-5 lines max)\n\n\nNew lines\n\n\n\n**SCENARIO B: Code is perfect. NO EDITS NEEDED:**\n\nSummary: ...\nEvaluation: ...\nAction: The code looks good. No fixes needed.\n",
+ "ALF.md": "You are an elite developer fixing syntax errors.\nThe file `{rel_path}` failed validation with these exact errors:\n{err_text}\n\n### Current Code:\n```\n{code}\n```\n\n### Instructions:\n1. Fix the syntax errors (like stray brackets, unexpected tokens, or indentation) using surgical XML edits.\n2. Respond EXCLUSIVELY with a block followed by ONE OR MORE blocks.\n3. Ensure your edits perfectly align with the surrounding brackets.",
+ "FRE.md": "You are an elite PYOB developer fixing runtime crashes.\n{memory_section}The application crashed during a test run.\n\n### Crash Logs & Traceback:\n{logs}\n\nThe traceback indicates the error occurred in `{rel_path}`.\n\n### Current Code of `{rel_path}`:\n```python\n{code}\n```\n\n### Instructions:\n1. Identify the EXACT root cause of the crash.\n2. Fix the error using surgical XML edits.\n3. Respond EXCLUSIVELY with a block followed by ONE OR MORE blocks.\n\n### REQUIRED XML FORMAT:\n\nExplanation of root cause...\nImports Needed: [List new imports required or 'None']\n\n\n\nExact lines to replace\n\n\nNew replacement lines\n\n",
+ "PF.md": "You are the PYOB Product Architect. Review the source code and suggest ONE highly useful, INTERACTIVE feature.\n\n{memory_section}### Source Code:\n```{lang_tag}\n{content}\n```\n\n### CRITICAL RULES:\n1. Suggest an INTERACTIVE feature (UI elements, buttons, menus).\n2. **ARCHITECTURAL SPLIT (MANDATORY)**: If the source code is over 800 lines, you ARE NOT ALLOWED to propose a new feature. You MUST propose an 'Architectural Split'. Identify a logical module (like a Mixin) to move to a NEW file.\n3. **SINGLE FILE LIMIT**: If you are proposing an Architectural Split, you are ONLY allowed to create ONE new file per iteration. Do not attempt to split multiple modules at once. Focus on the largest logical block first.\n4. **NEW FILE FORMAT**: If proposing a split, your block MUST use this format: [Full Code for New File]. Your must then explain how to update the original file to import this new module.\n5. MULTI-FILE ORCHESTRATION: Explicitly list filenames of other files that will need updates in your .\n6. The block MUST contain ONLY the new logic or the block.\n\n### REQUIRED XML FORMAT:\n\n...\n\n\n# New logic OR tag here\n",
+ "IF.md": "You are an elite PYOB Implementation Engineer. Implement the APPROVED feature into `{rel_path}`.\n\n{memory_section}### Feature Proposal Details:\n{feature_content}\n\n### Current Source Code:\n```{lang_tag}\n{source_code}\n```\n\n### CRITICAL INSTRUCTIONS:\n1. **SURGICAL EDITS ONLY**: Every block must be EXACTLY 2-5 lines.\n2. **MISSING IMPORTS**: If your feature introduces a new class/function, add a separate block to import it.\n3. **BLOCK INTEGRITY (CRITICAL)**: Python relies on indentation. If you add an `if`, `try`, or `def` statement, you MUST indent the code beneath it. If you remove one, you MUST dedent the code. Do not leave orphaned indents.\n4. **ABSOLUTE SPACES**: The spaces in your block must match the absolute margin of the source code.\n5. **VARIABLE SCOPE**: Ensure variables are accessible (use `self.` for class states).\n6. **DELETING CODE (CRITICAL)**: If your goal is to remove a block of code (e.g., when moving logic to a new file), DO NOT leave an empty block. Instead, provide a block containing a comment such as `# [Logic moved to new module]` to ensure the surrounding code remains syntactically valid.\n7. **IMPORT PATHS (MANDATORY)**: Never use the prefix `src.` in any import statement. The root of the package is `pyob`. (Example: Use `from pyob.entrance import Controller`, NOT `from src.pyob.entrance import Controller`).\n8. **TYPE HINTS (CRITICAL)**: Never use the `|` operator with quoted class names (Forward References). Use `Any` for objects that create circular dependencies.\n9. **ATOMIC REFACTORING**: Do not move large blocks or classes to new files in a single turn. Focus ONLY on logic improvements or bug fixes within the current file.\n10. **SURGICAL MOVEMENT**: If moving logic, use a 3-step process: A) Add new file/imports. B) Update call sites. C) Remove old code in a subsequent turn.\n11. **NO MASS DELETIONS**: You are strictly forbidden from replacing a large block of code with a smaller one unless you include the surrounding class/method structure in your block. If you cannot fit the structure in 5 lines, use multiple blocks.\n12. **INDENTATION LOCK**: Every block must have the exact same indentation level as the block.\n13. **PRESERVE STRUCTURE**: Never use a block that cuts off a class or method definition. Always include the full line you are matching.\n14. **NO EMOJIS (MANDATORY)**: Never use emojis or non-ASCII characters in logger messages, print statements, or source comments. Use plain ASCII text only. Emojis cause encoding errors and git diff corruption.\n\n### REQUIRED XML FORMAT:\n\n1. Lines to change: ...\n2. New imports needed: [List them or state 'None']\n3. Strategy: ...\n\n\n\nExact 2-5 lines\n\n\nNew code\n\n",
+ "PCF.md": 'You are the PYOB Symbolic Fixer taking over Phase 3 Cascaded Edits.\n{memory_section}We just modified `{trigger_file}`, and it broke a dependency downstream: `{rel_broken_path}`.\n\n### Linter Errors for `{rel_broken_path}`:\n{mypy_errors}\n\n### Source Code of `{rel_broken_path}`:\n```python\n{broken_code}\n```\n\n### Instructions:\n1. Respond EXCLUSIVELY with a block followed by ONE block to fix the broken references.\n2. **DEFEATING MYPY (CRITICAL)**: If the error contains `[union-attr]` or states `Item "None" of ... has no attribute`, adding a type hint will fail. You MUST fix this by either inserting `assert variable is not None` before the operation, or adding `# type: ignore` to the end of the failing line.\n\n### REQUIRED XML FORMAT:\n\nExplanation of cascade fix...\n\n\n\nExact lines to replace\n\n\nNew replacement lines\n\n',
+ "PIR.md": "You are an elite PYOB developer performing a post-implementation repair.\nAn automated attempt to implement a feature or bugfix has failed, resulting in the following errors.\n\n### Original Goal / Change Request:\n{context_of_change}\n\n### The Resulting Errors (Linter/Runtime):\n{err_text}\n\n### The Broken Code in `{rel_path}`:\n```\n{code}\n```\n\n### Instructions:\n1. Analyze the 'Original Goal' and the 'Resulting Errors' together.\n2. CRITICAL INDENTATION CHECK: If the error says `Unexpected indentation`, rewrite the block with PERFECT absolute indentation.\n3. CRITICAL IMPORT CHECK: If the error is `F821 Undefined name`, you MUST create an block at the top of the file to add the missing `import` statement.\n4. DEFEATING MYPY: If the error is a `[union-attr]` or `None` type error, simply adding a type hint will fail the linter again. You MUST insert `assert object is not None` right before it is used, or append `# type: ignore` to the failing line.\n5. Create a surgical XML edit to fix the code.\n6. **DELETING CODE**: If you are removing logic, never use an empty block. Always include a placeholder comment to maintain valid Python syntax.\n\n### REQUIRED XML FORMAT:\n\nRoot Cause: ...\nImports Needed: ...\nIndentation Fix Needed: [Yes/No]\nAction: ...\n\n\n\nExact lines to replace\n\n\nNew replacement lines\n\n",
+ }
+ for filename, content in templates.items():
+ filepath = os.path.join(data_dir, filename) # Use data_dir here
+ with open(filepath, "w", encoding="utf-8") as f:
+ f.write(content)
+
+ def load_prompt(self, filename: str, **kwargs: str) -> str:
+ # Use a consistent path resolution
+ data_dir = os.path.join(self.target_dir, ".pyob")
+ filepath = os.path.join(data_dir, filename)
+
+ try:
+ with open(filepath, "r", encoding="utf-8") as f:
+ template = f.read()
+ for key, value in kwargs.items():
+ template = template.replace(f"{{{key}}}", str(value))
+ return template
+ except Exception as e:
+ logger.error(f"Failed to load prompt {filename} from {filepath}: {e}")
+ return ""
+
+ def _get_impactful_history(self) -> str:
+ if not os.path.exists(self.history_path):
+ return "No prior history."
+ with open(self.history_path, "r", encoding="utf-8") as f:
+ full_history = f.read()
+ entries = re.split(r"## \d{4}-\d{2}-\d{2}", full_history)
+ recent_entries = entries[-3:]
+ summary = "### Significant Recent Architecture Changes:\n"
+ for entry in recent_entries:
+ lines = entry.strip().split("\n")
+ if lines:
+ summary += f"- {lines[0].strip()}\n"
+ return summary
+
+ def _get_rich_context(self) -> str:
+ context = ""
+ analysis_path = os.path.join(self.target_dir, "ANALYSIS.md")
+ if os.path.exists(analysis_path):
+ with open(analysis_path, "r", encoding="utf-8") as f:
+ content = f.read()
+ header_parts = content.split("## File Directory")
+ header = header_parts[0].strip() if header_parts else ""
+ context += f"### Project Goal:\n{header}\n\n"
+
+ if os.path.exists(self.history_path):
+ with open(self.history_path, "r", encoding="utf-8") as f:
+ hist = f.read()
+ headers = [line for line in hist.split("\n") if line.startswith("## ")]
+ context += (
+ "### Recent Edit History:\n" + "\n".join(headers[-3:]) + "\n\n"
+ )
+
+ if self.memory:
+ mem_str = self.memory.strip()
+ if len(mem_str) > 1500:
+ mem_str = (
+ mem_str[:500]
+ + "\n\n... [OLDER MEMORY TRUNCATED FOR CONTEXT LIMITS] ...\n\n"
+ + mem_str[-800:]
+ )
+
+ context += f"### Logic Memory:\n{mem_str}\n\n"
+
+ return context
+
+ def update_memory(self) -> None:
+ session_context: list[str] = getattr(self, "session_context", [])
+ if not session_context:
+ return
+ logger.info("\nPHASE 5: Updating MEMORY.md with session context...")
+ session_summary = "\n".join(f"- {item}" for item in session_context)
+ prompt = self.load_prompt(
+ "UM.md",
+ current_memory=self.memory if self.memory else "No previous memory.",
+ session_summary=session_summary,
+ )
+
+ def validator(text: str) -> bool:
+ return bool(text.strip())
+
+ llm_response = getattr(self, "get_valid_llm_response")(
+ prompt, validator, context="Memory Update"
+ )
+ clean_memory = re.sub(
+ r"^```[a-zA-Z]*\n", "", llm_response.strip(), flags=re.MULTILINE
+ )
+ clean_memory = re.sub(r"\n```$", "", clean_memory, flags=re.MULTILINE)
+ if clean_memory:
+ with open(self.memory_path, "w", encoding="utf-8") as f:
+ f.write(clean_memory)
+ self.memory = clean_memory
+
+ def refactor_memory(self) -> None:
+ if not self.memory:
+ return
+ logger.info("\nPHASE 6: Cleanup! Summarizing and refactoring MEMORY.md...")
+ prompt = self.load_prompt("RM.md", current_memory=self.memory)
+
+ def validator(text: str) -> bool:
+ return bool(text.strip())
+
+ llm_response = getattr(self, "get_valid_llm_response")(
+ prompt, validator, context="Memory Refactor"
+ )
+ clean_memory = re.sub(
+ r"^```[a-zA-Z]*\n", "", llm_response.strip(), flags=re.MULTILINE
+ )
+ clean_memory = re.sub(r"\n```$", "", clean_memory, flags=re.MULTILINE)
+ if clean_memory and len(clean_memory) > 50:
+ with open(self.memory_path, "w", encoding="utf-8") as f:
+ f.write(clean_memory)
+ self.memory = clean_memory
diff --git a/src/pyob/pyob_code_parser.py b/src/pyob/pyob_code_parser.py
new file mode 100644
index 0000000..f2e616b
--- /dev/null
+++ b/src/pyob/pyob_code_parser.py
@@ -0,0 +1,141 @@
+import ast
+import logging
+import os
+import re
+
+logger = logging.getLogger(__name__)
+
+
+class CodeParser:
+ def generate_structure_dropdowns(self, filepath: str, code: str) -> str:
+ ext = os.path.splitext(filepath)[1].lower()
+ if ext == ".py":
+ return self._parse_python(code)
+ elif ext in [".js", ".ts", ".jsx", ".tsx"]:
+ return self._parse_javascript(code)
+ elif ext in [".html", ".htm"]:
+ return self._parse_html(code)
+ elif ext == ".css":
+ return self._parse_css(code)
+ return ""
+
+ def _parse_python(self, code: str) -> str:
+ try:
+ tree = ast.parse(code)
+ imports, classes, functions, consts = [], [], [], []
+ for node in ast.walk(tree):
+ if isinstance(node, (ast.Import, ast.ImportFrom)):
+ try:
+ imports.append(ast.unparse(node))
+ except Exception:
+ pass
+ elif isinstance(node, ast.ClassDef):
+ classes.append(f"class {node.name}")
+ for child in node.body:
+ if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
+ args = [
+ arg.arg for arg in child.args.args if arg.arg != "self"
+ ]
+ functions.append(
+ f"def {node.name}.{child.name}({', '.join(args)})"
+ )
+ elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
+ if not any(f".{node.name}(" in fn for fn in functions):
+ args = [arg.arg for arg in node.args.args]
+ if node.args.vararg:
+ args.append(f"*{node.args.vararg.arg}")
+ if node.args.kwarg:
+ args.append(f"**{node.args.kwarg.arg}")
+ functions.append(f"def {node.name}({', '.join(args)})")
+ elif isinstance(node, ast.Assign):
+ for t in node.targets:
+ if isinstance(t, ast.Name) and t.id.isupper():
+ consts.append(t.id)
+
+ return self._format_dropdowns(imports, classes, functions, consts)
+
+ except SyntaxError as e:
+ logger.warning(
+ f"AST parsing failed (SyntaxError: {e}). Falling back to Regex for structure map."
+ )
+ return self._parse_python_regex_fallback(code)
+ except Exception as e:
+ logger.error(f"Unexpected AST parse error: {e}")
+ return ""
+
+ def _parse_python_regex_fallback(self, code: str) -> str:
+ """Used when a Python file has syntax errors so the AI isn't blinded."""
+ imports = re.findall(r"^(?:import|from)\s+[a-zA-Z0-9_\.]+", code, re.MULTILINE)
+ classes = [
+ f"class {c}"
+ for c in re.findall(r"^class\s+([a-zA-Z0-9_]+)", code, re.MULTILINE)
+ ]
+ functions = [
+ f"def {f}()"
+ for f in re.findall(r"^[ \t]*def\s+([a-zA-Z0-9_]+)", code, re.MULTILINE)
+ ]
+ consts = list(set(re.findall(r"^([A-Z_][A-Z0-9_]+)\s*=", code, re.MULTILINE)))
+
+ return self._format_dropdowns(imports, classes, functions, consts)
+
+ def _parse_javascript(self, code: str) -> str:
+ imports = re.findall(r"(?:import|from|require)\s+['\"].*?['\"]", code)
+ classes = re.findall(r"(?:class|interface)\s+([a-zA-Z0-9_$]+)", code)
+ types = re.findall(r"type\s+([a-zA-Z0-9_$]+)\s*=", code)
+ classes.extend([f"type {t}" for t in types])
+
+ fn_patterns = [
+ r"function\s+([a-zA-Z0-9_$]+)\s*\(([^)]*)\)",
+ r"(?:const|let|var|window\.)\s*([a-zA-Z0-9_$]+)\s*=\s*(?:async\s*)?\(([^)]*)\)\s*=>",
+ r"^\s*(?:async\s*)?([a-zA-Z0-9_$]+)\s*\(([^)]*)\)\s*\{",
+ ]
+ raw_fns = []
+ for pattern in fn_patterns:
+ raw_fns.extend(re.findall(pattern, code, re.MULTILINE))
+
+ clean_fns = []
+ seen = set()
+ for name, params in raw_fns:
+ if name not in seen and name not in [
+ "if",
+ "for",
+ "while",
+ "return",
+ "catch",
+ "switch",
+ ]:
+ clean_fns.append(f"{name}({params.strip()})")
+ seen.add(name)
+
+ entities = re.findall(r"(?:const|var|let)\s+([A-Z0-9_]{3,})", code)
+ return self._format_dropdowns(
+ imports, classes, sorted(clean_fns), sorted(list(set(entities)))
+ )
+
+ def _parse_html(self, code: str) -> str:
+ scripts = re.findall(r" str:
+ selectors = re.findall(r"([#\.][a-zA-Z0-9_-]+)\s*\{", code)
+ unique_selectors = list(dict.fromkeys(selectors))
+ return self._format_dropdowns([], [], unique_selectors[:50], [])
+
+ def _format_dropdowns(self, imp: list, cls: list, fn: list, cnst: list) -> str:
+ res = ""
+ if imp:
+ res += f"Imports ({len(imp)})
{'
'.join(sorted(imp))} \n"
+ if cnst:
+ res += f"Entities ({len(cnst)})
{'
'.join(sorted(cnst))} \n"
+ if cls:
+ res += f"Classes/Types ({len(cls)})
{'
'.join(sorted(cls))} \n"
+ if fn:
+ res += f"Logic ({len(fn)})
{'
'.join(sorted(fn))} \n"
+ return res
diff --git a/src/pyob/pyob_dashboard.py b/src/pyob/pyob_dashboard.py
new file mode 100644
index 0000000..07825b7
--- /dev/null
+++ b/src/pyob/pyob_dashboard.py
@@ -0,0 +1,510 @@
+import json
+import os
+from http.server import BaseHTTPRequestHandler
+from typing import Any
+
+from .dashboard_html import OBSERVER_HTML
+
+
+class ObserverHandler(BaseHTTPRequestHandler):
+ # The 'controller' type is 'Any' to avoid circular dependencies with the main application controller.
+ controller: Any = None
+
+ def _send_json_response(
+ self, status_code: int, payload: dict, allow_cors: bool = True
+ ):
+ self.send_response(status_code)
+ self.send_header("Content-type", "application/json")
+ if allow_cors:
+ self.send_header("Access-Control-Allow-Origin", "*")
+ self.end_headers()
+ self.wfile.write(json.dumps(payload).encode())
+
+ def _send_controller_not_initialized_error(self):
+ self.send_response(503) # Service Unavailable
+ self.send_header("Content-type", "application/json")
+ self.send_header("Access-Control-Allow-Origin", "*")
+ self.end_headers()
+ self.wfile.write(json.dumps({"error": "Controller not initialized"}).encode())
+
+ def do_GET(self):
+ if self.path == "/api/status":
+ if self.controller is None:
+ self._send_controller_not_initialized_error()
+ return
+ self.send_response(200)
+ self.send_header("Content-type", "application/json")
+ self.send_header("Access-Control-Allow-Origin", "*")
+ self.end_headers()
+ status = {
+ "iteration": getattr(self.controller, "current_iteration", 1),
+ "cascade_queue": getattr(self.controller, "cascade_queue", []),
+ "ledger_stats": {
+ "definitions": len(self.controller.ledger["definitions"]),
+ "references": len(self.controller.ledger["references"]),
+ },
+ "analysis": self.controller._read_file(self.controller.analysis_path),
+ "memory": self.controller._read_file(
+ os.path.join(self.controller.target_dir, ".pyob", "MEMORY.md")
+ ),
+ "history": self.controller._read_file(self.controller.history_path)[
+ -5000:
+ ],
+ "patches_count": len(self.controller.get_pending_patches())
+ if hasattr(self.controller, "get_pending_patches")
+ else 0,
+ }
+ self.wfile.write(json.dumps(status).encode())
+ # New GET endpoint for pending patches
+ elif self.path == "/api/pending_patches":
+ if self.controller is None:
+ self._send_controller_not_initialized_error()
+ return
+ try:
+ pending_patches = (
+ self.controller.get_pending_patches()
+ ) # Assumes this method exists in EntranceController
+ self.send_response(200)
+ self.send_header("Content-type", "application/json")
+ self.send_header("Access-Control-Allow-Origin", "*")
+ self.end_headers()
+ self.wfile.write(json.dumps({"patches": pending_patches}).encode())
+ except AttributeError:
+ self.send_response(500)
+ self.send_header("Content-type", "application/json")
+ self.end_headers()
+ self.wfile.write(
+ json.dumps(
+ {
+ "error": "Controller method 'get_pending_patches' not found. Ensure entrance.py is updated."
+ }
+ ).encode()
+ )
+ except Exception as e:
+ self.send_response(500)
+ self.send_header("Content-type", "application/json")
+ self.end_headers()
+ self.wfile.write(
+ json.dumps({"error": f"Internal server error: {str(e)}"}).encode()
+ )
+ elif self.path == "/" or self.path == "/observer.html":
+ self.send_response(200)
+ self.send_header("Content-type", "text/html")
+ self.end_headers()
+ self.wfile.write(OBSERVER_HTML.encode())
+ else:
+ self.send_response(404)
+ self.end_headers()
+
+ def do_POST(self):
+ if self.path == "/api/set_target_file":
+ if self.controller is None:
+ self.send_response(503)
+ self.send_header("Content-type", "application/json")
+ self.end_headers()
+ self.wfile.write(
+ json.dumps({"error": "Controller not initialized"}).encode()
+ )
+ return
+
+ content_length = int(self.headers.get("Content-Length", 0))
+ post_data = self.rfile.read(content_length)
+ try:
+ data = json.loads(post_data.decode("utf-8"))
+ target_file = data.get("target_file")
+
+ if not target_file:
+ self.send_response(400)
+ self.send_header("Content-type", "application/json")
+ self.end_headers()
+ self.wfile.write(
+ json.dumps(
+ {"error": "Missing 'target_file' in request body"}
+ ).encode()
+ )
+ return
+
+ # This method call depends on entrance.py being updated
+ self.controller.set_manual_target_file(target_file)
+
+ self.send_response(200)
+ self.send_header("Content-type", "application/json")
+ self.send_header("Access-Control-Allow-Origin", "*")
+ self.end_headers()
+ self.wfile.write(
+ json.dumps(
+ {
+ "message": "Manual target file set",
+ "target_file": target_file,
+ }
+ ).encode()
+ )
+
+ except json.JSONDecodeError:
+ self.send_response(400)
+ self.send_header("Content-type", "application/json")
+ self.send_header("Access-Control-Allow-Origin", "*")
+ self.end_headers()
+ self.wfile.write(json.dumps({"error": "Invalid JSON"}).encode())
+ except AttributeError:
+ # If controller doesn't have set_manual_target_file yet
+ self.send_response(500)
+ self.send_header("Content-type", "application/json")
+ self.end_headers()
+ self.wfile.write(
+ json.dumps(
+ {
+ "error": "Controller method 'set_manual_target_file' not found. Ensure entrance.py is updated."
+ }
+ ).encode()
+ )
+ except Exception as e:
+ self.send_response(500)
+ self.send_header("Content-type", "application/json")
+ self.end_headers()
+ self.wfile.write(
+ json.dumps({"error": f"Internal server error: {str(e)}"}).encode()
+ )
+ # New POST endpoint for reviewing patches
+ elif self.path == "/api/review_patch":
+ if self.controller is None:
+ self.send_response(503)
+ self.send_header("Content-type", "application/json")
+ self.end_headers()
+ self.wfile.write(
+ json.dumps({"error": "Controller not initialized"}).encode()
+ )
+ return
+
+ content_length = int(self.headers.get("Content-Length", 0))
+ post_data = self.rfile.read(content_length)
+ try:
+ data = json.loads(post_data.decode("utf-8"))
+ patch_id = data.get("patch_id")
+ action = data.get("action") # 'approve' or 'reject'
+
+ if not patch_id or not action:
+ self.send_response(400)
+ self.send_header("Content-type", "application/json")
+ self.end_headers()
+ self.wfile.write(
+ json.dumps(
+ {"error": "Missing 'patch_id' or 'action' in request body"}
+ ).encode()
+ )
+ return
+ if action not in ["approve", "reject"]:
+ self.send_response(400)
+ self.send_header("Content-type", "application/json")
+ self.end_headers()
+ self.wfile.write(
+ json.dumps(
+ {"error": "Action must be 'approve' or 'reject'"}
+ ).encode()
+ )
+ return
+
+ self.controller.process_patch_review(
+ patch_id, action
+ ) # Assumes this method exists in EntranceController
+
+ self.send_response(200)
+ self.send_header("Content-type", "application/json")
+ self.send_header("Access-Control-Allow-Origin", "*")
+ self.end_headers()
+ self.wfile.write(
+ json.dumps(
+ {
+ "message": f"Patch {patch_id} {action}d successfully",
+ "patch_id": patch_id,
+ "action": action,
+ }
+ ).encode()
+ )
+
+ except json.JSONDecodeError:
+ self.send_response(400)
+ self.send_header("Content-type", "application/json")
+ self.send_header("Access-Control-Allow-Origin", "*")
+ self.end_headers()
+ self.wfile.write(json.dumps({"error": "Invalid JSON"}).encode())
+ except AttributeError:
+ self.send_response(500)
+ self.send_header("Content-type", "application/json")
+ self.end_headers()
+ self.wfile.write(
+ json.dumps(
+ {
+ "error": "Controller method 'process_patch_review' not found. Ensure entrance.py is updated."
+ }
+ ).encode()
+ )
+ except Exception as e:
+ self.send_response(500)
+ self.send_header("Content-type", "application/json")
+ self.end_headers()
+ self.wfile.write(
+ json.dumps({"error": f"Internal server error: {str(e)}"}).encode()
+ )
+ # NEW POST endpoint for updating Logic Memory
+ elif self.path == "/api/update_memory":
+ if self.controller is None:
+ self.send_response(503)
+ self.send_header("Content-type", "application/json")
+ self.end_headers()
+ self.wfile.write(
+ json.dumps({"error": "Controller not initialized"}).encode()
+ )
+ return
+
+ content_length = int(self.headers.get("Content-Length", 0))
+ post_data = self.rfile.read(content_length)
+ try:
+ data = json.loads(post_data.decode("utf-8"))
+ new_memory_content = data.get("content")
+
+ if new_memory_content is None:
+ self.send_response(400)
+ self.send_header("Content-type", "application/json")
+ self.end_headers()
+ self.wfile.write(
+ json.dumps(
+ {"error": "Missing 'content' in request body"}
+ ).encode()
+ )
+ return
+
+ self.controller.update_memory(new_memory_content)
+
+ self.send_response(200)
+ self.send_header("Content-type", "application/json")
+ self.send_header("Access-Control-Allow-Origin", "*")
+ self.end_headers()
+ self.wfile.write(
+ json.dumps(
+ {"message": "Logic Memory updated successfully"}
+ ).encode()
+ )
+
+ except json.JSONDecodeError:
+ self.send_response(400)
+ self.send_header("Content-type", "application/json")
+ self.end_headers()
+ self.wfile.write(json.dumps({"error": "Invalid JSON"}).encode())
+ except AttributeError:
+ self.send_response(500)
+ self.send_header("Content-type", "application/json")
+ self.end_headers()
+ self.wfile.write(
+ json.dumps(
+ {
+ "error": "Controller method 'update_memory' not found. Ensure entrance.py is updated."
+ }
+ ).encode()
+ )
+ except Exception as e:
+ self.send_response(500)
+ self.send_header("Content-type", "application/json")
+ self.end_headers()
+ self.wfile.write(
+ json.dumps({"error": f"Internal server error: {str(e)}"}).encode()
+ )
+ # NEW POST endpoint for moving cascade queue items
+ elif self.path == "/api/cascade_queue/move":
+ if self.controller is None:
+ self.send_response(503)
+ self.send_header("Content-type", "application/json")
+ self.end_headers()
+ self.wfile.write(
+ json.dumps({"error": "Controller not initialized"}).encode()
+ )
+ return
+
+ content_length = int(self.headers.get("Content-Length", 0))
+ post_data = self.rfile.read(content_length)
+ try:
+ data = json.loads(post_data.decode("utf-8"))
+ item_id = data.get("item_id")
+ direction = data.get("direction")
+
+ if not item_id or direction not in ["up", "down"]:
+ self.send_response(400)
+ self.send_header("Content-type", "application/json")
+ self.end_headers()
+ self.wfile.write(
+ json.dumps(
+ {
+ "error": "Missing 'item_id' or invalid 'direction' in request body"
+ }
+ ).encode()
+ )
+ return
+
+ self.controller.move_cascade_queue_item(item_id, direction)
+
+ self.send_response(200)
+ self.send_header("Content-type", "application/json")
+ self.send_header("Access-Control-Allow-Origin", "*")
+ self.end_headers()
+ self.wfile.write(
+ json.dumps(
+ {"message": f"Item {item_id} moved {direction} successfully"}
+ ).encode()
+ )
+
+ except json.JSONDecodeError:
+ self.send_response(400)
+ self.send_header("Content-type", "application/json")
+ self.end_headers()
+ self.wfile.write(json.dumps({"error": "Invalid JSON"}).encode())
+ except AttributeError:
+ self.send_response(500)
+ self.send_header("Content-type", "application/json")
+ self.end_headers()
+ self.wfile.write(
+ json.dumps(
+ {
+ "error": "Controller method 'move_cascade_queue_item' not found. Ensure entrance.py is updated."
+ }
+ ).encode()
+ )
+ except Exception as e:
+ self.send_response(500)
+ self.send_header("Content-type", "application/json")
+ self.end_headers()
+ self.wfile.write(
+ json.dumps({"error": f"Internal server error: {str(e)}"}).encode()
+ )
+
+ # NEW POST endpoint for removing cascade queue items
+ elif self.path == "/api/cascade_queue/remove":
+ if self.controller is None:
+ self.send_response(503)
+ self.send_header("Content-type", "application/json")
+ self.end_headers()
+ self.wfile.write(
+ json.dumps({"error": "Controller not initialized"}).encode()
+ )
+ return
+
+ content_length = int(self.headers.get("Content-Length", 0))
+ post_data = self.rfile.read(content_length)
+ try:
+ data = json.loads(post_data.decode("utf-8"))
+ item_id = data.get("item_id")
+
+ if not item_id:
+ self.send_response(400)
+ self.send_header("Content-type", "application/json")
+ self.end_headers()
+ self.wfile.write(
+ json.dumps(
+ {"error": "Missing 'item_id' in request body"}
+ ).encode()
+ )
+ return
+
+ self.controller.remove_cascade_queue_item(item_id)
+
+ self.send_response(200)
+ self.send_header("Content-type", "application/json")
+ self.send_header("Access-Control-Allow-Origin", "*")
+ self.end_headers()
+ self.wfile.write(
+ json.dumps(
+ {"message": f"Item {item_id} removed successfully"}
+ ).encode()
+ )
+
+ except json.JSONDecodeError:
+ self.send_response(400)
+ self.send_header("Content-type", "application/json")
+ self.end_headers()
+ self.wfile.write(json.dumps({"error": "Invalid JSON"}).encode())
+ except AttributeError:
+ self.send_response(500)
+ self.send_header("Content-type", "application/json")
+ self.end_headers()
+ self.wfile.write(
+ json.dumps(
+ {
+ "error": "Controller method 'remove_cascade_queue_item' not found. Ensure entrance.py is updated."
+ }
+ ).encode()
+ )
+ except Exception as e:
+ self.send_response(500)
+ self.send_header("Content-type", "application/json")
+ self.end_headers()
+ self.wfile.write(
+ json.dumps({"error": f"Internal server error: {str(e)}"}).encode()
+ )
+ # NEW POST endpoint for adding items to cascade queue
+ elif self.path == "/api/cascade_queue/add":
+ if self.controller is None:
+ self.send_response(503)
+ self.send_header("Content-type", "application/json")
+ self.end_headers()
+ self.wfile.write(
+ json.dumps({"error": "Controller not initialized"}).encode()
+ )
+ return
+
+ content_length = int(self.headers.get("Content-Length", 0))
+ post_data = self.rfile.read(content_length)
+ try:
+ data = json.loads(post_data.decode("utf-8"))
+ item = data.get("item")
+
+ if not item:
+ self.send_response(400)
+ self.send_header("Content-type", "application/json")
+ self.end_headers()
+ self.wfile.write(
+ json.dumps({"error": "Missing 'item' in request body"}).encode()
+ )
+ return
+
+ self.controller.add_to_cascade_queue(item)
+
+ self.send_response(200)
+ self.send_header("Content-type", "application/json")
+ self.send_header("Access-Control-Allow-Origin", "*")
+ self.end_headers()
+ self.wfile.write(
+ json.dumps(
+ {
+ "message": f"Item '{item}' added to cascade queue successfully"
+ }
+ ).encode()
+ )
+
+ except json.JSONDecodeError:
+ self.send_response(400)
+ self.send_header("Content-type", "application/json")
+ self.end_headers()
+ self.wfile.write(json.dumps({"error": "Invalid JSON"}).encode())
+ except AttributeError:
+ self.send_response(500)
+ self.send_header("Content-type", "application/json")
+ self.end_headers()
+ self.wfile.write(
+ json.dumps(
+ {
+ "error": "Controller method 'add_to_cascade_queue' not found. Ensure entrance.py is updated."
+ }
+ ).encode()
+ )
+ except Exception as e:
+ self.send_response(500)
+ self.send_header("Content-type", "application/json")
+ self.end_headers()
+ self.wfile.write(
+ json.dumps({"error": f"Internal server error: {str(e)}"}).encode()
+ )
+ else:
+ self.send_response(404)
+ self.end_headers()
+
+ def log_message(self, format: str, *args: object) -> None:
+ return
diff --git a/src/pyob/pyob_launcher.py b/src/pyob/pyob_launcher.py
new file mode 100644
index 0000000..5c02bfe
--- /dev/null
+++ b/src/pyob/pyob_launcher.py
@@ -0,0 +1,221 @@
+import json
+import os
+import shlex
+import subprocess
+import sys
+from pathlib import Path
+
+CONFIG_FILE = Path.home() / ".pyob_config"
+
+DEFAULT_GEMINI_MODEL = "gemini-2.5-flash"
+DEFAULT_LOCAL_MODEL = "qwen3-coder:30b"
+
+# OBSERVER_PATCH_REVIEW_HTML content has been moved to pyob_dashboard.py
+# or a dedicated UI template file, as it is UI-specific content.
+
+
+def load_config():
+ """Load config from file or environment, or prompt user if missing."""
+ # 1. Check for Environment Variables (Ensures it works in GitHub Actions/Docker)
+ # This takes highest priority as per "Override by PYOB_GEMINI_KEYS" rule.
+ env_keys = os.environ.get("PYOB_GEMINI_KEYS")
+ if env_keys:
+ return {
+ "gemini_keys": env_keys,
+ "gemini_model": os.environ.get("PYOB_GEMINI_MODEL", DEFAULT_GEMINI_MODEL),
+ "local_model": os.environ.get("PYOB_LOCAL_MODEL", DEFAULT_LOCAL_MODEL),
+ }
+
+ # 1. Check for Environment Variables (Ensures it works in GitHub Actions/Docker)
+ # This takes highest priority as per "Override by PYOB_GEMINI_KEYS" rule.
+ env_keys = os.environ.get("PYOB_GEMINI_KEYS")
+ if env_keys:
+ return {
+ "gemini_keys": env_keys,
+ "gemini_model": os.environ.get("PYOB_GEMINI_MODEL", DEFAULT_GEMINI_MODEL),
+ "local_model": os.environ.get("PYOB_LOCAL_MODEL", DEFAULT_LOCAL_MODEL),
+ }
+
+ # 2. Try loading from the local configuration file
+ if CONFIG_FILE.exists():
+ try:
+ with open(CONFIG_FILE, "r") as f:
+ config_data = json.load(f)
+ if isinstance(config_data, dict):
+ return config_data
+ else:
+ raise ValueError("Configuration file content is not a dictionary.")
+ except (json.JSONDecodeError, OSError, ValueError) as e:
+ print(
+ f"Warning: Configuration file {CONFIG_FILE} is invalid or inaccessible ({e}). Re-creating."
+ )
+ CONFIG_FILE.unlink(missing_ok=True) # Delete invalid file
+
+ # 2. Check for Environment Variables (Ensures it works in GitHub Actions/Docker)
+ env_keys = os.environ.get("PYOB_GEMINI_KEYS")
+ if env_keys:
+ return {
+ "gemini_keys": env_keys,
+ "gemini_model": os.environ.get("PYOB_GEMINI_MODEL", DEFAULT_GEMINI_MODEL),
+ "local_model": os.environ.get("PYOB_LOCAL_MODEL", DEFAULT_LOCAL_MODEL),
+ }
+
+ # 3. Safety Check for Headless Environments
+ if not sys.stdin.isatty():
+ print("Error: No API keys found in environment and stdin is not a TTY.")
+ print(" In GitHub Actions, please set the PYOB_GEMINI_KEYS secret.")
+ sys.exit(1)
+
+ # 4. Standard Interactive Setup (reached on local iMac first-run)
+ print("PYOB First-Time Setup")
+ print("═" * 40)
+ print("\nStep 1: Gemini API Keys")
+ print("Enter up to 10 keys separated by commas:")
+ keys = input("Keys: ").strip()
+ if not keys:
+ print("Error: Gemini API keys cannot be empty during interactive setup.")
+ sys.exit(1)
+
+ print("\nStep 2: Model Configuration")
+ print("WARNING: PYOB is optimized for 'gemini-2.0-flash' and 'qwen3-coder:30b'.")
+ print(" Changing these may result in parsing errors or logic loops.")
+
+ g_model = (
+ input(f"\nEnter Gemini Model [default: {DEFAULT_GEMINI_MODEL}]: ").strip()
+ or DEFAULT_GEMINI_MODEL
+ )
+ l_model = (
+ input(f"Enter Local Ollama Model [default: {DEFAULT_LOCAL_MODEL}]: ").strip()
+ or DEFAULT_LOCAL_MODEL
+ )
+
+ config = {"gemini_keys": keys, "gemini_model": g_model, "local_model": l_model}
+
+ with open(CONFIG_FILE, "w") as f:
+ json.dump(config, f, indent=4)
+
+ print(f"\nConfiguration saved to {CONFIG_FILE}")
+ print(" (To change these later, simply delete that file and restart PYOB.)\n")
+ return config
+
+
+def ensure_terminal():
+ if ".app/Contents/MacOS" in sys.executable and not os.isatty(sys.stdin.fileno()):
+ script_path = shlex.quote(sys.argv[0])
+ args = " ".join(shlex.quote(arg) for arg in sys.argv[1:])
+ full_command = f"{sys.executable} {script_path} {args}".strip()
+ cmd = f'tell application "Terminal" to do script "{full_command}"'
+ subprocess.run(["osascript", "-e", cmd])
+ sys.exit(0)
+
+
+def main():
+ if sys.platform == "darwin":
+ ensure_terminal()
+
+ print("═" * 70)
+ print(" PYOB Launcher")
+ print("═" * 70)
+
+ config = load_config()
+
+ # Prioritize environment variables if set (e.g. by Docker/Actions)
+ os.environ.setdefault("PYOB_GEMINI_KEYS", config.get("gemini_keys", ""))
+ os.environ.setdefault(
+ "PYOB_GEMINI_MODEL", config.get("gemini_model", DEFAULT_GEMINI_MODEL)
+ )
+ os.environ.setdefault(
+ "PYOB_LOCAL_MODEL", config.get("local_model", DEFAULT_LOCAL_MODEL)
+ )
+ # Determine if dashboard is active, e.g., via environment variable
+ dashboard_active = (
+ os.environ.get("PYOB_DASHBOARD_ACTIVE", "false").lower() == "true"
+ )
+
+ from pyob.entrance import EntranceController
+
+ if len(sys.argv) > 1:
+ arg = sys.argv[1]
+ # Filter out internal macOS process paths
+ if ".app/Contents/MacOS" in arg or arg == sys.executable:
+ if sys.stdin.isatty():
+ target_dir = input(
+ "\nEnter the FULL PATH to your target project directory\n"
+ "(or just press Enter to use the current folder): "
+ ).strip()
+ else:
+ target_dir = "."
+ else:
+ target_dir = arg
+ else:
+ if sys.stdin.isatty():
+ target_dir = input(
+ "\nEnter the FULL PATH to your target project directory\n"
+ "(or just press Enter to use the current folder): "
+ ).strip()
+ else:
+ target_dir = "."
+
+ if not target_dir:
+ target_dir = "."
+
+ target_dir = os.path.abspath(target_dir)
+
+ if not os.path.isdir(target_dir):
+ print(f"Error: Directory does not exist → {target_dir}")
+ if sys.stdin.isatty():
+ input("\nPress Enter to exit...")
+ sys.exit(1)
+
+ # --- NEW: CLOUD GIT CONFIGURATION FIX ---
+ if os.environ.get("GITHUB_ACTIONS") == "true":
+ # 1. Fix the "dubious ownership" block for Docker volumes
+ subprocess.run(
+ ["git", "config", "--global", "--add", "safe.directory", "*"],
+ capture_output=True,
+ )
+
+ # 2. Auto-set Bot Identity (Prevents "Author unknown" errors for Marketplace users)
+ check_name = subprocess.run(
+ ["git", "config", "user.name"], capture_output=True, text=True
+ )
+ if not check_name.stdout.strip():
+ subprocess.run(
+ ["git", "config", "--global", "user.name", "pyob-bot"],
+ capture_output=True,
+ )
+ subprocess.run(
+ [
+ "git",
+ "config",
+ "--global",
+ "user.email",
+ "pyob-bot@users.noreply.github.com",
+ ],
+ capture_output=True,
+ )
+ # ----------------------------------------
+
+ print(f"\nStarting PYOB on: {target_dir}")
+ print(f"Gemini Model: {os.environ['PYOB_GEMINI_MODEL']}")
+ print(f"Local Model: {os.environ['PYOB_LOCAL_MODEL']}")
+ print(" (Terminal will stay open — press Ctrl+C to stop)\n")
+
+ try:
+ # Pass dashboard_active status to the EntranceController
+ controller = EntranceController(target_dir, dashboard_active=dashboard_active)
+ controller.run_master_loop()
+ except KeyboardInterrupt:
+ print("\n\nPYOB stopped by user.")
+ except Exception as e:
+ print(f"\nUnexpected error: {e}")
+ import traceback
+
+ traceback.print_exc()
+ finally:
+ if sys.stdin.isatty():
+ input("\nPress Enter to close this window...")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/pyob/reviewer_mixins.py b/src/pyob/reviewer_mixins.py
new file mode 100644
index 0000000..3475f24
--- /dev/null
+++ b/src/pyob/reviewer_mixins.py
@@ -0,0 +1,446 @@
+import os
+import re
+import shutil
+import subprocess
+import sys
+
+from .core_utils import (
+ IGNORE_DIRS,
+ IGNORE_FILES,
+ logger,
+)
+
+
+class ValidationMixin:
+ target_dir: str
+ session_context: list[str]
+ memory: str
+
+ def run_linter_fix_loop(self, context_of_change: str = "") -> bool:
+ logger.info("\nValidating codebase syntax (Python, JS, CSS)...")
+ success: bool = True
+ try:
+ subprocess.run(["ruff", "format", self.target_dir], capture_output=True)
+
+ subprocess.run(
+ ["ruff", "check", self.target_dir, "--fix"], capture_output=True
+ )
+
+ res = subprocess.run(
+ ["ruff", "check", self.target_dir], capture_output=True, text=True
+ )
+ if res.returncode != 0:
+ logger.warning(f"Ruff found logic errors:\n{res.stdout.strip()}")
+ py_fixed = False
+ for attempt in range(3):
+ file_errors: dict[str, list[str]] = {}
+ for line in res.stdout.splitlines():
+ if ".py:" in line:
+ filepath = line.split(":")[0].strip()
+ if os.path.exists(filepath):
+ file_errors.setdefault(filepath, []).append(line)
+ if not file_errors:
+ break
+ for fpath, errs in file_errors.items():
+ self._apply_linter_fixes(
+ fpath, "\n".join(errs), context_of_change
+ )
+ recheck = subprocess.run(
+ ["ruff", "check", self.target_dir],
+ capture_output=True,
+ text=True,
+ )
+ if recheck.returncode == 0:
+ logger.info("Python Auto-fix successful!")
+ py_fixed = True
+ break
+ if not py_fixed:
+ logger.error("Python errors remain unfixable.")
+ success = False
+ except FileNotFoundError:
+ pass
+ try:
+ js_files = []
+ for root, dirs, files in os.walk(self.target_dir):
+ dirs[:] = [d for d in dirs if d not in IGNORE_DIRS]
+ for file in files:
+ if file.endswith(".js") and file not in IGNORE_FILES:
+ js_files.append(os.path.join(root, file))
+ for js_file in js_files:
+ res = subprocess.run(
+ ["node", "--check", js_file], capture_output=True, text=True
+ )
+ if res.returncode != 0:
+ rel_name = os.path.basename(js_file)
+ err_msg = res.stderr.strip()
+ logger.warning(f"JS Syntax Error in {rel_name}:\n{err_msg}")
+ js_fixed = False
+ for attempt in range(3):
+ logger.info(
+ f"Asking AI to fix JS syntax (Attempt {attempt + 1}/3)..."
+ )
+ self._apply_linter_fixes(js_file, err_msg, context_of_change)
+ recheck = subprocess.run(
+ ["node", "--check", js_file], capture_output=True, text=True
+ )
+ if recheck.returncode == 0:
+ logger.info(f"JS Auto-fix successful for {rel_name}!")
+ js_fixed = True
+ break
+ if not js_fixed:
+ logger.error(f"JS syntax in {rel_name} remains broken.")
+ success = False
+ break
+ except FileNotFoundError:
+ logger.info("Node.js not installed. Skipping JS syntax validation.")
+ for root, dirs, files in os.walk(self.target_dir):
+ dirs[:] = [d for d in dirs if d not in IGNORE_DIRS]
+ for file in files:
+ if file.endswith(".css") and file not in IGNORE_FILES:
+ path = os.path.join(root, file)
+ try:
+ with open(path, "r", encoding="utf-8") as f:
+ css_content = f.read()
+ if css_content.count("{") != css_content.count("}"):
+ logger.error(
+ f"CSS Syntax Error in {file}: Unbalanced braces."
+ )
+ success = False
+ except Exception:
+ pass
+ return success
+
+ def _apply_linter_fixes(
+ self, filepath: str, err_text: str, context_of_change: str = ""
+ ):
+ with open(filepath, "r", encoding="utf-8") as f:
+ code = f.read()
+ rel_path = os.path.relpath(filepath, self.target_dir)
+ if context_of_change:
+ logger.info(f"Applying CONTEXT-AWARE fix for `{rel_path}`...")
+ prompt = getattr(self, "load_prompt")(
+ "PIR.md",
+ context_of_change=context_of_change,
+ rel_path=rel_path,
+ err_text=err_text,
+ code=code,
+ )
+ else:
+ logger.info(f"Applying standard linter fix for `{rel_path}`...")
+ prompt = getattr(self, "load_prompt")(
+ "ALF.md", rel_path=rel_path, err_text=err_text, code=code
+ )
+ new_code, _, _ = getattr(self, "get_valid_edit")(
+ prompt, code, require_edit=True, target_filepath=filepath
+ )
+ if new_code != code:
+ with open(filepath, "w", encoding="utf-8") as f:
+ f.write(new_code)
+ self.session_context.append(
+ f"Auto-fixed syntax/linting errors in `{rel_path}`."
+ )
+
+ def run_and_verify_app(self, context_of_change: str = "") -> bool:
+ check_script = os.path.join(self.target_dir, "check.sh")
+
+ if os.path.exists(check_script):
+ logger.info("PHASE 3.5: Running full validation suite (check.sh)...")
+ try:
+ os.chmod(check_script, 0o755)
+
+ subprocess.run(
+ [check_script, "--fix"],
+ capture_output=True,
+ text=True,
+ cwd=self.target_dir,
+ )
+
+ res = subprocess.run(
+ [check_script], capture_output=True, text=True, cwd=self.target_dir
+ )
+
+ if res.returncode != 0:
+ logger.warning(
+ f"Validation suite failed after auto-fix!\n{res.stdout.strip()}"
+ )
+ self._fix_runtime_errors(
+ res.stdout + "\n" + res.stderr,
+ "Validation Suite",
+ context_of_change,
+ )
+ return False
+ except Exception as e:
+ logger.error(f"Failed to execute validation script: {e}")
+ return False
+ else:
+ logger.warning("No check.sh found in target project. Skipping PHASE 3.5.")
+
+ entry_file = getattr(self, "_find_entry_file")()
+ if not entry_file:
+ logger.warning("No entry point detected. Skipping runtime smoke test.")
+ return True
+
+ venv_python = os.path.join(self.target_dir, "build_env", "bin", "python3")
+ if not os.path.exists(venv_python):
+ venv_python = os.path.join(self.target_dir, "venv", "bin", "python3")
+
+ python_cmd = venv_python if os.path.exists(venv_python) else sys.executable
+
+ for attempt in range(3):
+ logger.info(
+ f"\nPHASE 4: Runtime Verification. Launching {os.path.basename(entry_file)} (Attempt {attempt + 1}/3)..."
+ )
+
+ is_html = entry_file.endswith((".html", ".htm"))
+
+ process = subprocess.Popen(
+ [python_cmd, entry_file],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ cwd=self.target_dir,
+ )
+
+ stdout, stderr = "", ""
+ try:
+ stdout, stderr = process.communicate(timeout=10)
+ except subprocess.TimeoutExpired:
+ process.terminate()
+ try:
+ stdout, stderr = process.communicate(timeout=2)
+ except subprocess.TimeoutExpired:
+ process.kill()
+ stdout, stderr = process.communicate()
+
+ error_keywords = [
+ "Traceback",
+ "Exception:",
+ "Error:",
+ "NameError:",
+ "AttributeError:",
+ ]
+
+ if is_html:
+ logger.info("HTML Entry detected. Verification assumed successful.")
+ return True
+
+ has_crash = any(kw in stderr or kw in stdout for kw in error_keywords) or (
+ process.returncode != 0 and process.returncode not in (0, 15, -15, None)
+ )
+
+ if not has_crash:
+ logger.info("App ran successfully for 10 seconds.")
+ return True
+
+ logger.warning(f"App crashed!\n{stderr}")
+ self._fix_runtime_errors(
+ stderr + "\n" + stdout, entry_file, context_of_change
+ )
+
+ logger.error("Exhausted runtime auto-fix attempts.")
+ return False
+
+ def _fix_runtime_errors(
+ self, logs: str, entry_file: str, context_of_change: str = ""
+ ):
+ """Detects crashes. Handles missing packages automatically, otherwise asks AI."""
+ package_match = re.search(r"ModuleNotFoundError: No module named '(.*?)'", logs)
+ if not package_match:
+ package_match = re.search(r"ImportError: No module named '(.*?)'", logs)
+
+ if package_match:
+ pkg = package_match.group(1)
+ logger.info(
+ f"Auto-detected missing dependency: {pkg}. Attempting pip install..."
+ )
+ venv_python = os.path.join(self.target_dir, "build_env", "bin", "python3")
+ if not os.path.exists(venv_python):
+ venv_python = os.path.join(self.target_dir, "venv", "bin", "python3")
+
+ if os.path.exists(venv_python):
+ python_cmd = venv_python
+ elif getattr(sys, "frozen", False):
+ python_cmd = (
+ shutil.which("python3") or shutil.which("python") or "python3"
+ )
+ else:
+ python_cmd = sys.executable
+ try:
+ subprocess.run(
+ [
+ python_cmd,
+ "-m",
+ "pip",
+ "install",
+ pkg,
+ "--break-system-packages",
+ ],
+ check=True,
+ )
+ subprocess.run(
+ [
+ python_cmd,
+ "-m",
+ "pip",
+ "install",
+ f"types-{pkg}",
+ "--break-system-packages",
+ ],
+ capture_output=True,
+ )
+ logger.info(
+ f"Successfully installed {pkg}. System will now retry launch."
+ )
+
+ # --- AUTO-DEPENDENCY LOCKING ---
+ try:
+ req_path = os.path.join(
+ getattr(self, "target_dir"), "requirements.txt"
+ )
+ with open(req_path, "w", encoding="utf-8") as f_req:
+ subprocess.run(
+ [python_cmd, "-m", "pip", "freeze"],
+ stdout=f_req,
+ check=True,
+ )
+ logger.info("Auto-locked dependencies in requirements.txt")
+ except Exception as e:
+ logger.warning(f"Failed to lock dependencies: {e}")
+ # -------------------------------
+
+ return
+ except subprocess.CalledProcessError as e:
+ logger.error(f"Failed to install {pkg} automatically: {e}")
+
+ tb_files = re.findall(r'File "([^"]+)"', logs)
+ target_file = entry_file
+ for f in reversed(tb_files):
+ abs_f = os.path.abspath(f)
+ if (
+ abs_f.startswith(self.target_dir)
+ and not any(ign in abs_f for ign in IGNORE_DIRS)
+ and os.path.exists(abs_f)
+ ):
+ target_file = abs_f
+ break
+ rel_path = os.path.relpath(target_file, self.target_dir)
+ with open(target_file, "r", encoding="utf-8") as f_obj:
+ code = f_obj.read()
+ if context_of_change:
+ logger.info(
+ f"Applying CONTEXT-AWARE fix for runtime crash in `{rel_path}`..."
+ )
+ prompt = getattr(self, "load_prompt")(
+ "PIR.md",
+ context_of_change=context_of_change,
+ rel_path=rel_path,
+ err_text=logs[-2000:],
+ code=code,
+ )
+ else:
+ logger.info(f"Applying standard fix for runtime crash in `{rel_path}`...")
+ memory_section = (
+ f"### Project Memory / Context:\n{self.memory}\n\n"
+ if self.memory
+ else ""
+ )
+ prompt = getattr(self, "load_prompt")(
+ "FRE.md",
+ memory_section=memory_section,
+ logs=logs[-2000:],
+ rel_path=rel_path,
+ code=code,
+ )
+ new_code, explanation, _ = getattr(self, "get_valid_edit")(
+ prompt, code, require_edit=True, target_filepath=target_file
+ )
+ if new_code != code:
+ with open(target_file, "w", encoding="utf-8") as f_out:
+ f_out.write(new_code)
+ logger.info(f"AI Auto-patched runtime crash in `{rel_path}`")
+ self.session_context.append(
+ f"Auto-fixed runtime crash in `{rel_path}`: {explanation}"
+ )
+ self.run_linter_fix_loop()
+
+ def check_downstream_breakages(self, target_path: str, rel_path: str) -> bool:
+ logger.info(
+ f"\nPHASE 3: Simulating workspace to check for downstream breakages caused by {rel_path} edits..."
+ )
+ try:
+ excludes = (
+ set(IGNORE_DIRS) | set(IGNORE_FILES) | {os.path.basename(__file__)}
+ )
+ exclude_regex = (
+ r"(^|/|\\)(" + "|".join(re.escape(x) for x in excludes) + r")(/|\\|$)"
+ )
+ result = subprocess.run(
+ [
+ "mypy",
+ self.target_dir,
+ "--exclude",
+ exclude_regex,
+ "--ignore-missing-imports",
+ ],
+ capture_output=True,
+ text=True,
+ )
+ if "error:" in result.stdout:
+ logger.warning(
+ f"Downstream Breakage Detected!\n{result.stdout.strip()}"
+ )
+ return self.propose_cascade_fix(result.stdout.strip(), rel_path)
+ logger.info("No downstream breakages detected.")
+ return True
+ except Exception as e:
+ logger.error(f"Error during Phase 3 assessment: {e}")
+ return True
+
+ def propose_cascade_fix(self, mypy_errors: str, trigger_file: str) -> bool:
+ problem_file = None
+ for line in mypy_errors.splitlines():
+ if ".py:" in line:
+ candidate = line.split(":")[0].strip()
+ if (
+ os.path.exists(candidate)
+ and os.path.basename(candidate) not in IGNORE_FILES
+ and not any(ign in candidate for ign in IGNORE_DIRS)
+ ):
+ problem_file = candidate
+ if trigger_file not in candidate:
+ break
+ if not problem_file:
+ return False
+ rel_broken_path = os.path.relpath(problem_file, self.target_dir)
+ with open(problem_file, "r", encoding="utf-8") as f:
+ broken_code = f.read()
+ memory_section = (
+ f"### Project Context:\n{self.memory}\n\n" if self.memory else ""
+ )
+ prompt = getattr(self, "load_prompt")(
+ "PCF.md",
+ memory_section=memory_section,
+ trigger_file=trigger_file,
+ rel_broken_path=rel_broken_path,
+ mypy_errors=mypy_errors,
+ broken_code=broken_code,
+ )
+ new_code, _, _ = getattr(self, "get_valid_edit")(
+ prompt, broken_code, require_edit=True, target_filepath=problem_file
+ )
+ if new_code != broken_code:
+ with open(problem_file, "w", encoding="utf-8") as f:
+ f.write(new_code)
+ logger.info(
+ f"Auto-patched cascading fix directly into `{os.path.basename(problem_file)}`"
+ )
+ self.session_context.append(
+ f"Auto-applied downstream cascade fix in `{os.path.basename(problem_file)}`"
+ )
+ self.run_linter_fix_loop()
+ final_check = subprocess.run(
+ ["mypy", problem_file, "--ignore-missing-imports"], capture_output=True
+ )
+ return final_check.returncode == 0
+ logger.error(f"Failed to auto-patch `{rel_broken_path}`.")
+ return False
diff --git a/src/pyob/scanner_mixins.py b/src/pyob/scanner_mixins.py
new file mode 100644
index 0000000..2db6ba0
--- /dev/null
+++ b/src/pyob/scanner_mixins.py
@@ -0,0 +1,21 @@
+import os
+
+from .core_utils import IGNORE_DIRS, IGNORE_FILES, SUPPORTED_EXTENSIONS
+
+
+class ScannerMixin:
+ # Type hint so Mypy knows this mixin expects a target_dir attribute
+ target_dir: str
+
+ def scan_directory(self) -> list[str]:
+ file_list = []
+ for root, dirs, files in os.walk(self.target_dir):
+ dirs[:] = [d for d in dirs if d not in IGNORE_DIRS]
+ for file in files:
+ if file in IGNORE_FILES:
+ continue
+ if file.endswith(".spec") or file.endswith(".dmg"):
+ continue
+ if any(file.endswith(ext) for ext in SUPPORTED_EXTENSIONS):
+ file_list.append(os.path.join(root, file))
+ return file_list
diff --git a/src/pyob/stats_updater.py b/src/pyob/stats_updater.py
new file mode 100644
index 0000000..e326836
--- /dev/null
+++ b/src/pyob/stats_updater.py
@@ -0,0 +1,71 @@
+import json
+
+from dashboard_server import fetch_api
+
+
+class StatsUpdater:
+ async def update_stats(self):
+ try:
+ response = await fetch_api("/api/status")
+ data = await response.json()
+ return data
+ except Exception as e:
+ print(f"Error updating stats: {e}")
+ return None
+
+ async def update_pending_patches(self):
+ try:
+ response = await fetch_api("/api/pending_patches")
+ data = await response.json()
+ return data
+ except Exception as e:
+ print(f"Failed to fetch pending patches: {e}")
+ return None
+
+ async def review_patch(self, patch_id, action):
+ try:
+ await fetch_api(
+ "/api/review_patch",
+ method="POST",
+ data=json.dumps({"patch_id": patch_id, "action": action}),
+ )
+ except Exception as e:
+ print(f"Failed to {action} patch {patch_id}: {e}")
+
+ async def save_memory(self, memory_content):
+ try:
+ await fetch_api(
+ "/api/update_memory",
+ method="POST",
+ data=json.dumps({"content": memory_content}),
+ )
+ except Exception as e:
+ print(f"Failed to save Logic Memory: {e}")
+
+ async def add_cascade_item(self, item):
+ try:
+ await fetch_api(
+ "/api/cascade_queue/add", method="POST", data=json.dumps({"item": item})
+ )
+ except Exception as e:
+ print(f"Failed to add item to cascade queue: {e}")
+
+ async def move_queue_item(self, item_id, direction):
+ try:
+ await fetch_api(
+ "/api/cascade_queue/move",
+ method="POST",
+ data=json.dumps({"item_id": item_id, "direction": direction}),
+ )
+ except Exception as e:
+ print(f"Failed to move item {item_id} {direction}: {e}")
+
+ async def remove_queue_item(self, item_id):
+ try:
+ await fetch_api(
+ "/api/cascade_queue/remove",
+ method="POST",
+ data=json.dumps({"item_id": item_id}),
+ )
+ except Exception as e:
+ print(f"Failed to remove item {item_id}: {e}")
diff --git a/src/pyob/targeted_reviewer.py b/src/pyob/targeted_reviewer.py
new file mode 100644
index 0000000..95f46d9
--- /dev/null
+++ b/src/pyob/targeted_reviewer.py
@@ -0,0 +1,26 @@
+import os
+
+from .autoreviewer import AutoReviewer
+from .xml_mixin import ApplyXMLMixin
+
+
+class TargetedReviewer(AutoReviewer, ApplyXMLMixin):
+ """
+ Specialized reviewer that targets a single specific file.
+ Automatically handles both absolute and relative path inputs for cross-platform stability.
+ """
+
+ def __init__(self, target_dir: str, target_file: str):
+ super().__init__(target_dir)
+
+ if os.path.isabs(target_file):
+ self.forced_target_file = os.path.relpath(target_file, self.target_dir)
+ else:
+ self.forced_target_file = target_file
+
+ def scan_directory(self) -> list[str]:
+ """Returns only the specific targeted file for the pipeline to process."""
+ full_target_path = os.path.join(self.target_dir, self.forced_target_file)
+ if os.path.exists(full_target_path):
+ return [full_target_path]
+ return []
diff --git a/src/pyob/xml_mixin.py b/src/pyob/xml_mixin.py
new file mode 100644
index 0000000..73e3af6
--- /dev/null
+++ b/src/pyob/xml_mixin.py
@@ -0,0 +1,240 @@
+import ast
+import re
+
+from .core_utils import logger
+
+
+class ApplyXMLMixin:
+ def ensure_imports_retained(
+ self, orig_code: str, new_code: str, filepath: str
+ ) -> str:
+ try:
+ orig_tree = ast.parse(orig_code)
+ new_tree = ast.parse(new_code)
+ except Exception:
+ return new_code
+ orig_imports = []
+ for node in orig_tree.body:
+ if isinstance(node, (ast.Import, ast.ImportFrom)):
+ start_line = node.lineno - 1
+ end_line = getattr(node, "end_lineno", node.lineno)
+ import_text = "\n".join(orig_code.splitlines()[start_line:end_line])
+ orig_imports.append((node, import_text))
+ missing_imports = []
+ for orig_node, import_text in orig_imports:
+ found = False
+ for new_node in new_tree.body:
+ if isinstance(new_node, type(orig_node)):
+ if isinstance(orig_node, ast.Import) and isinstance(
+ new_node, ast.Import
+ ):
+ if {alias.name for alias in orig_node.names}.issubset(
+ {alias.name for alias in new_node.names}
+ ):
+ found = True
+ break
+ elif isinstance(orig_node, ast.ImportFrom) and isinstance(
+ new_node, ast.ImportFrom
+ ):
+ if orig_node.module == new_node.module and {
+ alias.name for alias in orig_node.names
+ }.issubset({alias.name for alias in new_node.names}):
+ found = True
+ break
+ if not found:
+ missing_imports.append(import_text)
+ if missing_imports:
+ return "\n".join(missing_imports) + "\n\n" + new_code
+ return new_code
+
+ def apply_xml_edits(
+ self, source_code: str, llm_response: str
+ ) -> tuple[str, str, bool]:
+ source_code = source_code.replace("\r\n", "\n")
+ llm_response = llm_response.replace("\r\n", "\n")
+
+ explanation = self._extract_explanation(llm_response)
+ matches = self._extract_edit_blocks(llm_response)
+
+ if not matches:
+ return source_code, explanation, True
+
+ new_code = source_code
+ all_edits_succeeded = True
+
+ for m in matches:
+ raw_search = re.sub(
+ r"^```[\w]*\n|\n```$", "", m.group(1), flags=re.MULTILINE
+ )
+ raw_replace = re.sub(
+ r"^```[\w]*\n|\n```$", "", m.group(2), flags=re.MULTILINE
+ )
+
+ raw_replace = self._fix_replace_indentation(raw_search, raw_replace)
+
+ new_code, success = self._apply_single_block(
+ new_code, raw_search, raw_replace
+ )
+ if not success:
+ all_edits_succeeded = False
+
+ return new_code, explanation, all_edits_succeeded
+
+ # ==========================================
+ # PRIVATE HELPER METHODS FOR XML PATCHING
+ # ==========================================
+
+ def _extract_explanation(self, llm_response: str) -> str:
+ thought_match = re.search(
+ r"(.*?)", llm_response, re.DOTALL | re.IGNORECASE
+ )
+ return (
+ thought_match.group(1).strip()
+ if thought_match
+ else "No explanation provided."
+ )
+
+ def _extract_edit_blocks(self, llm_response: str) -> list[re.Match]:
+ pattern = re.compile(
+ r"\s*\s*\n?(.*?)\n?\s*\s*\s*\n?(.*?)\n?\s*\s*",
+ re.DOTALL | re.IGNORECASE,
+ )
+ return list(pattern.finditer(llm_response))
+
+ def _fix_replace_indentation(self, search: str, replace: str) -> str:
+ search_lines = search.split("\n")
+ replace_lines = replace.split("\n")
+
+ search_indent = ""
+ for line in search_lines:
+ if line.strip():
+ search_indent = line[: len(line) - len(line.lstrip(" \t"))]
+ break
+
+ replace_base_indent = ""
+ for line in replace_lines:
+ if line.strip():
+ replace_base_indent = line[: len(line) - len(line.lstrip(" \t"))]
+ break
+
+ fixed_replace_lines = []
+ for line in replace_lines:
+ if line.strip():
+ if line.startswith(replace_base_indent):
+ clean_line = line[len(replace_base_indent) :]
+ else:
+ clean_line = line.lstrip(" \t")
+ fixed_replace_lines.append(search_indent + clean_line)
+ else:
+ fixed_replace_lines.append("")
+ return "\n".join(fixed_replace_lines)
+
+ def _apply_single_block(
+ self, source: str, search: str, replace: str
+ ) -> tuple[str, bool]:
+ # Strategy 1: Exact Match
+ if search in source:
+ return source.replace(search, replace, 1), True
+
+ clean_search = search.strip("\n")
+ clean_replace = replace.strip("\n")
+
+ # Strategy 2: Clean Exact Match
+ if clean_search and clean_search in source:
+ return source.replace(clean_search, clean_replace, 1), True
+
+ # Strategy 3: Normalized Match
+ source, success = self._attempt_normalized_match(source, search, replace)
+ if success:
+ return source, True
+
+ # Strategy 4: Regex Fuzzy Match
+ source, success = self._attempt_regex_fuzzy_match(source, clean_search, replace)
+ if success:
+ return source, True
+
+ # Strategy 5: Line-by-Line Robust Match
+ source, success = self._attempt_line_by_line_match(source, search, replace)
+ if success:
+ return source, True
+
+ return source, False
+
+ def _attempt_normalized_match(
+ self, source: str, search: str, replace: str
+ ) -> tuple[str, bool]:
+ def normalize(t: str) -> str:
+ t = re.sub(r"#.*", "", t)
+ return re.sub(r"\s+", " ", t).strip()
+
+ norm_search = normalize(search)
+ if not norm_search:
+ return source, False
+
+ search_lines = search.split("\n")
+ lines = source.splitlines()
+ for i in range(len(lines)):
+ test_block = normalize("\n".join(lines[i : i + len(search_lines)]))
+ if norm_search in test_block:
+ lines[i : i + len(search_lines)] = replace.splitlines()
+ logger.info(f"Normalization match succeeded at line {i + 1}.")
+ return "\n".join(lines), True
+ return source, False
+
+ def _attempt_regex_fuzzy_match(
+ self, source: str, clean_search: str, replace: str
+ ) -> tuple[str, bool]:
+ try:
+ search_lines_cleaned = [
+ line.strip() for line in clean_search.split("\n") if line.strip()
+ ]
+ if not search_lines_cleaned:
+ return source, False
+
+ regex_parts = [
+ r"^[ \t]*" + re.escape(line) + r"[ \t]*\n+"
+ for line in search_lines_cleaned[:-1]
+ ]
+ regex_parts.append(
+ r"^[ \t]*" + re.escape(search_lines_cleaned[-1]) + r"[ \t]*\n?"
+ )
+ pattern_str = r"".join(regex_parts)
+ fuzzy_match = re.search(pattern_str, source, re.MULTILINE)
+ if fuzzy_match:
+ new_code = (
+ source[: fuzzy_match.start()]
+ + replace
+ + "\n"
+ + source[fuzzy_match.end() :]
+ )
+ return new_code, True
+ except Exception:
+ pass
+ return source, False
+
+ def _attempt_line_by_line_match(
+ self, source: str, search: str, replace: str
+ ) -> tuple[str, bool]:
+ search_lines = search.split("\n")
+ search_lines_stripped = [line.strip() for line in search_lines if line.strip()]
+ if not search_lines_stripped:
+ return source, False
+
+ replace_lines = replace.split("\n")
+ code_lines = source.splitlines()
+ for i in range(len(code_lines) - len(search_lines_stripped) + 1):
+ match = True
+ for j, sline in enumerate(search_lines_stripped):
+ if sline not in code_lines[i + j].strip():
+ match = False
+ break
+ if match:
+ new_code_lines = (
+ code_lines[:i] + replace_lines + code_lines[i + len(search_lines) :]
+ )
+ new_code = "\n".join(new_code_lines)
+ if not new_code.endswith("\n") and source.endswith("\n"):
+ new_code += "\n"
+ logger.info(f"Robust fuzzy match succeeded at line {i + 1}.")
+ return new_code, True
+ return source, False