import threading import time import copy import sys import traceback import os import re import inspect from typing import Any, List, Dict, Optional, Callable from pathlib import Path from src import workspace_manager import json import uuid import tomli_w import requests from dataclasses import asdict from datetime import datetime from tkinter import filedialog, Tk from fastapi import FastAPI, Depends, HTTPException from fastapi.security.api_key import APIKeyHeader from pydantic import BaseModel from src import events from src import paths from src import session_logger from src import project_manager from src import performance_monitor from src import models from src import presets from src import thinking_parser from src.file_cache import ASTParser from src import ai_client from src import shell_runner from src import mcp_client from src import aggregate from src import orchestrator_pm from src import conductor_tech_lead from src import multi_agent_conductor from src import tool_presets from src import rag_engine from src import theme_2 as theme def hide_tk_root() -> Tk: root = Tk() root.withdraw() root.wm_attributes("-topmost", True) return root def parse_symbols(text: str) -> list[str]: """ Finds all occurrences of '@SymbolName' in text and returns SymbolName. SymbolName can be a function, class, or method (e.g. @MyClass, @my_func, @MyClass.my_method). """ return re.findall(r"@([a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)*)", text) def get_symbol_definition(symbol: str, files: list[str]) -> tuple[str, str, int] | None: for file_path in files: result = mcp_client.py_get_symbol_info(file_path, symbol) if isinstance(result, tuple): source, line = result return (file_path, source, line) return None class GenerateRequest(BaseModel): prompt: str auto_add_history: bool = True temperature: float | None = None top_p: float | None = None max_tokens: int | None = None class ConfirmRequest(BaseModel): approved: bool script: Optional[str] = None class ConfirmDialog: def __init__(self, script: str, base_dir: str) -> None: self._uid = str(uuid.uuid4()) self._script = str(script) if script is not None else "" self._base_dir = str(base_dir) if base_dir is not None else "" self._condition = threading.Condition() self._done = False self._approved = False def wait(self) -> tuple[bool, str]: start_time = time.time() with self._condition: while not self._done: if time.time() - start_time > 120: return False, self._script self._condition.wait(timeout=0.1) return self._approved, self._script class MMAApprovalDialog: def __init__(self, ticket_id: str, payload: str) -> None: self._payload = payload self._condition = threading.Condition() self._done = False self._approved = False def wait(self) -> tuple[bool, str]: start_time = time.time() with self._condition: while not self._done: if time.time() - start_time > 120: return False, self._payload self._condition.wait(timeout=0.1) return self._approved, self._payload class MMASpawnApprovalDialog: def __init__(self, ticket_id: str, role: str, prompt: str, context_md: str) -> None: self._prompt = prompt self._context_md = context_md self._condition = threading.Condition() self._done = False self._approved = False self._abort = False def wait(self) -> dict[str, Any]: start_time = time.time() with self._condition: while not self._done: if time.time() - start_time > 120: return {'approved': False, 'abort': True, 'prompt': self._prompt, 'context_md': self._context_md} self._condition.wait(timeout=0.1) return { 'approved': self._approved, 'abort': self._abort, 'prompt': self._prompt, 'context_md': self._context_md } class AppController: """ The headless controller for the Manual Slop application. Owns the application state and manages background services. """ PROVIDERS: list[str] = ["gemini", "anthropic", "gemini_cli", "deepseek", "minimax"] def __init__(self): # Initialize locks first to avoid initialization order issues self._send_thread_lock: threading.Lock = threading.Lock() self._disc_entries_lock: threading.Lock = threading.Lock() self._pending_comms_lock: threading.Lock = threading.Lock() self._pending_tool_calls_lock: threading.Lock = threading.Lock() self._pending_history_adds_lock: threading.Lock = threading.Lock() self._pending_gui_tasks_lock: threading.Lock = threading.Lock() self._pending_dialog_lock: threading.Lock = threading.Lock() self._api_event_queue_lock: threading.Lock = threading.Lock() self.config: Dict[str, Any] = {} self.project: Dict[str, Any] = {} self.active_project_path: str = "" self.project_paths: List[str] = [] self.active_discussion: str = "main" self.disc_entries: List[Dict[str, Any]] = [] self.ui_active_persona: str = "" self.disc_roles: List[str] = [] self.files: List[str] = [] self.screenshots: List[str] = [] self.event_queue: events.AsyncEventQueue = events.AsyncEventQueue() self._loop_thread: Optional[threading.Thread] = None self.tracks: List[Dict[str, Any]] = [] self.active_track: Optional[models.Track] = None self.engine: Optional[multi_agent_conductor.ConductorEngine] = None self.active_tickets: List[Dict[str, Any]] = [] self.mma_streams: Dict[str, str] = {} self._worker_status: Dict[str, str] = {} # stream_id -> "running" | "completed" | "failed" | "killed" self.MAX_STREAM_SIZE: int = 10 * 1024 # 10KB max per stream self._pending_patch_text: Optional[str] = None self._pending_patch_files: List[str] = [] self._show_patch_modal: bool = False self._patch_error_message: Optional[str] = None self.mma_status: str = "idle" self._tool_log: List[Dict[str, Any]] = [] self._tool_stats: Dict[str, Dict[str, Any]] = {} # {tool_name: {"count": 0, "total_time_ms": 0.0, "failures": 0}} self._cached_cache_stats: Dict[str, Any] = {} # Pre-computed cache stats for GUI self._cached_files: List[str] = [] self._token_history: List[Dict[str, Any]] = [] # Token usage over time [{"time": t, "input": n, "output": n, "model": s}, ...] self._session_start_time: float = time.time() # For calculating burn rate self._ticket_start_times: dict[str, float] = {} self._avg_ticket_time: float = 0.0 self._completed_ticket_count: int = 0 self._comms_log: List[Dict[str, Any]] = [] self.session_usage: Dict[str, Any] = { "input_tokens": 0, "output_tokens": 0, "cache_read_input_tokens": 0, "cache_creation_input_tokens": 0, "total_tokens": 0, "last_latency": 0.0, "percentage": 0.0 } self.mma_tier_usage: Dict[str, Dict[str, Any]] = { "Tier 1": {"input": 0, "output": 0, "provider": "gemini", "model": "gemini-3.1-pro-preview", "tool_preset": None}, "Tier 2": {"input": 0, "output": 0, "provider": "gemini", "model": "gemini-3-flash-preview", "tool_preset": None}, "Tier 3": {"input": 0, "output": 0, "provider": "gemini", "model": "gemini-2.5-flash-lite", "tool_preset": None}, "Tier 4": {"input": 0, "output": 0, "provider": "gemini", "model": "gemini-2.5-flash-lite", "tool_preset": None}, } self.perf_monitor: performance_monitor.PerformanceMonitor = performance_monitor.PerformanceMonitor() self._last_telemetry_time: float = 0.0 self._pending_gui_tasks: List[Dict[str, Any]] = [] self._api_event_queue: List[Dict[str, Any]] = [] # Pending dialogs state moved from App self._pending_dialog: Optional[ConfirmDialog] = None self._pending_dialog_open: bool = False self._pending_actions: Dict[str, ConfirmDialog] = {} self._pending_ask_dialog: bool = False self.mcp_config: models.MCPConfiguration = models.MCPConfiguration() self.rag_config: Optional[models.RAGConfig] = None self.rag_engine: Optional[rag_engine.RAGEngine] = None self.rag_status: str = 'idle' # AI settings state self._current_provider: str = "gemini" self._current_model: str = "gemini-2.5-flash-lite" self.temperature: float = 0.0 self.top_p: float = 1.0 self.max_tokens: int = 8192 self.history_trunc_limit: int = 8000 # UI-related state moved to controller self.ui_ai_input: str = "" self.ui_disc_new_name_input: str = "" self.ui_disc_new_role_input: str = "" self.ui_epic_input: str = "" self.ui_new_track_name: str = "" self.ui_new_track_desc: str = "" self.ui_new_track_type: str = "feature" self.ui_conductor_setup_summary: str = "" self.ui_last_script_text: str = "" self.ui_last_script_output: str = "" self.ui_new_ticket_id: str = "" self.ui_new_ticket_desc: str = "" self.ui_new_ticket_target: str = "" self.ui_new_ticket_deps: str = "" self.ui_output_dir: str = "" self.ui_files_base_dir: str = "" self.ui_shots_base_dir: str = "" self.ui_project_git_dir: str = "" self.ui_project_main_context: str = "" self.ui_project_system_prompt: str = "" self.ui_project_execution_mode: str = "native" self.ui_gemini_cli_path: str = "gemini" self.ui_word_wrap: bool = True self.ui_auto_add_history: bool = False self.ui_active_tool_preset: str | None = None self.ui_global_system_prompt: str = "" self.ui_base_system_prompt: str = "" self.ui_use_default_base_prompt: bool = True self._show_base_prompt_diff_modal: bool = False self.ui_project_context_marker: str = "" self.ui_agent_tools: Dict[str, bool] = {} self.available_models: List[str] = [] self.all_available_models: Dict[str, List[str]] = {} # provider -> list of models self._autofocus_response_tab = False self.proposed_tracks: List[Dict[str, Any]] = [] self._show_track_proposal_modal: bool = False self.ai_status: str = 'idle' self.ai_response: str = '' self.last_md: str = '' self.last_aggregate_markdown: str = '' self.last_resolved_system_prompt: str = '' self.last_md_path: Optional[Path] = None self.last_file_items: List[Any] = [] self.send_thread: Optional[threading.Thread] = None self.models_thread: Optional[threading.Thread] = None self.show_windows: Dict[str, bool] = {} self.show_script_output: bool = False self.show_text_viewer: bool = False self.text_viewer_title: str = '' self.text_viewer_content: str = '' self.text_viewer_type: str = 'text' self._pending_comms: List[Dict[str, Any]] = [] self._pending_tool_calls: List[Dict[str, Any]] = [] self._pending_history_adds: List[Dict[str, Any]] = [] self.perf_history: Dict[str, List[float]] = {'frame_time': [0.0]*100, 'fps': [0.0]*100, 'cpu': [0.0]*100, 'input_lag': [0.0]*100} self._perf_last_update: float = 0.0 self._autosave_interval: float = 60.0 self._last_autosave: float = time.time() # More state moved from App self._ask_dialog_open: bool = False self._ask_request_id: Optional[str] = None self._ask_tool_data: Optional[Dict[str, Any]] = None self.mma_step_mode: bool = False self.active_tier: Optional[str] = None self.ui_focus_agent: Optional[str] = None self._pending_mma_approval: Optional[Dict[str, Any]] = None self._mma_approval_open: bool = False self._mma_approval_edit_mode: bool = False self._mma_approval_payload: str = "" self._pending_mma_spawn: Optional[Dict[str, Any]] = None self._mma_spawn_open: bool = False self._mma_spawn_edit_mode: bool = False self._mma_spawn_prompt: str = '' self._mma_spawn_context: str = '' self._trigger_blink: bool = False self._is_blinking: bool = False self._blink_start_time: float = 0.0 self._trigger_script_blink: bool = False self._is_script_blinking: bool = False self._script_blink_start_time: float = 0.0 self._scroll_disc_to_bottom: bool = False self._scroll_comms_to_bottom: bool = False self._scroll_tool_calls_to_bottom: bool = False self._gemini_cache_text: str = "" self._last_stable_md: str = '' self._token_stats: Dict[str, Any] = {} self._comms_log_dirty: bool = True self._tool_log_dirty: bool = True self._token_stats_dirty: bool = True self.ui_disc_truncate_pairs: int = 2 self.ui_auto_scroll_comms: bool = True self.ui_auto_scroll_tool_calls: bool = True self._show_add_ticket_form: bool = False self._track_discussion_active: bool = False self._tier_stream_last_len: Dict[str, int] = {} self.is_viewing_prior_session: bool = False self._current_session_usage = None self._current_mma_tier_usage = None self.prior_session_entries: List[Dict[str, Any]] = [] self.prior_tool_calls: List[Dict[str, Any]] = [] self.prior_disc_entries: List[Dict[str, Any]] = [] self.prior_mma_dashboard_state = {} self._current_token_history = None self._current_session_start_time = None self.test_hooks_enabled: bool = ("--enable-test-hooks" in sys.argv) or (os.environ.get("SLOP_TEST_HOOKS") == "1") self.ui_manual_approve: bool = False # Injection state self._inject_file_path: str = "" self._inject_mode: str = "skeleton" self._inject_preview: str = "" self._show_inject_modal: bool = False self.show_preset_manager_window: bool = False self.show_tool_preset_manager_window: bool = False self.show_persona_editor_window: bool = False self._editing_preset_name: str = "" self._editing_preset_content: str = "" self._editing_preset_temperature: float = 0.0 self._editing_preset_top_p: float = 0.0 self._editing_preset_max_output_tokens: int = 4096 self._editing_preset_scope: str = "project" self._editing_tool_preset_name: str = "" self._editing_tool_preset_categories: Dict[str, Dict[str, Any]] = {} self._editing_tool_preset_scope: str = "project" self.diagnostic_log: List[Dict[str, Any]] = [] self._settable_fields: Dict[str, str] = { 'ai_input': 'ui_ai_input', 'project_git_dir': 'ui_project_git_dir', 'auto_add_history': 'ui_auto_add_history', 'disc_new_name_input': 'ui_disc_new_name_input', 'project_main_context': 'ui_project_main_context', 'gcli_path': 'ui_gemini_cli_path', 'output_dir': 'ui_output_dir', 'files_base_dir': 'ui_files_base_dir', 'files': 'files', 'screenshots': 'screenshots', 'ai_status': 'ai_status', 'ai_response': 'ai_response', 'active_discussion': 'active_discussion', 'current_provider': 'current_provider', 'current_model': 'current_model', 'token_budget_pct': '_token_budget_pct', 'token_budget_current': '_token_budget_current', 'token_budget_label': '_token_budget_label', 'show_confirm_modal': 'show_confirm_modal', 'mma_epic_input': 'ui_epic_input', 'mma_status': 'mma_status', 'rag_status': 'rag_status', 'rag_enabled': 'rag_enabled', 'rag_source': 'rag_source', 'rag_emb_provider': 'rag_emb_provider', 'rag_mcp_server': 'rag_mcp_server', 'rag_mcp_tool': 'rag_mcp_tool', 'rag_chunk_size': 'rag_chunk_size', 'rag_chunk_overlap': 'rag_chunk_overlap', 'mcp_config_json': 'mcp_config_json', 'mma_active_tier': 'active_tier', 'ui_new_track_name': 'ui_new_track_name', 'ui_new_track_desc': 'ui_new_track_desc', 'manual_approve': 'ui_manual_approve', 'global_system_prompt': 'ui_global_system_prompt', 'project_system_prompt': 'ui_project_system_prompt', 'base_system_prompt': 'ui_base_system_prompt', 'use_default_base_prompt': 'ui_use_default_base_prompt', 'show_base_prompt_diff_modal': '_show_base_prompt_diff_modal', 'global_preset_name': 'ui_global_preset_name', 'project_preset_name': 'ui_project_preset_name', 'ui_active_tool_preset': 'ui_active_tool_preset', 'ui_active_bias_profile': 'ui_active_bias_profile', 'temperature': 'temperature', 'max_tokens': 'max_tokens', 'show_preset_manager_window': 'show_preset_manager_window', 'show_tool_preset_manager_window': 'show_tool_preset_manager_window', 'show_persona_editor_window': 'show_persona_editor_window', '_editing_preset_name': '_editing_preset_name', '_editing_preset_content': '_editing_preset_content', '_editing_preset_temperature': '_editing_preset_temperature', '_editing_preset_top_p': '_editing_preset_top_p', '_editing_preset_max_output_tokens': '_editing_preset_max_output_tokens', '_editing_preset_scope': '_editing_preset_scope', '_editing_tool_preset_name': '_editing_tool_preset_name', '_editing_tool_preset_categories': '_editing_tool_preset_categories', '_editing_tool_preset_scope': '_editing_tool_preset_scope', 'show_windows': 'show_windows', 'ui_separate_task_dag': 'ui_separate_task_dag', 'ui_separate_usage_analytics': 'ui_separate_usage_analytics', 'ui_separate_tier1': 'ui_separate_tier1', 'ui_separate_tier2': 'ui_separate_tier2', 'ui_separate_tier3': 'ui_separate_tier3', 'ui_separate_tier4': 'ui_separate_tier4', 'show_text_viewer': 'show_text_viewer', 'text_viewer_title': 'text_viewer_title', 'text_viewer_type': 'text_viewer_type', 'disc_entries': 'disc_entries', 'ui_file_paths': 'ui_file_paths', 'ui_auto_switch_layout': 'ui_auto_switch_layout', 'ui_tier_layout_bindings': 'ui_tier_layout_bindings' } self._gettable_fields = dict(self._settable_fields) self._gettable_fields.update({ 'show_windows': 'show_windows', 'ui_focus_agent': 'ui_focus_agent', 'active_discussion': 'active_discussion', '_track_discussion_active': '_track_discussion_active', 'proposed_tracks': 'proposed_tracks', 'mma_streams': 'mma_streams', '_worker_status': '_worker_status', 'active_track': 'active_track', 'active_tickets': 'active_tickets', 'tracks': 'tracks', 'thinking_indicator': 'thinking_indicator', 'operations_live_indicator': 'operations_live_indicator', 'prior_session_indicator': 'prior_session_indicator', '_show_patch_modal': '_show_patch_modal', '_pending_patch_text': '_pending_patch_text', '_pending_patch_files': '_pending_patch_files', '_inject_file_path': '_inject_file_path', '_inject_mode': '_inject_mode', '_inject_preview': '_inject_preview', '_show_inject_modal': '_show_inject_modal', 'bg_shader_enabled': 'bg_shader_enabled', 'global_system_prompt': 'ui_global_system_prompt', 'project_system_prompt': 'ui_project_system_prompt', 'base_system_prompt': 'ui_base_system_prompt', 'use_default_base_prompt': 'ui_use_default_base_prompt', 'show_base_prompt_diff_modal': '_show_base_prompt_diff_modal', 'global_preset_name': 'ui_global_preset_name', 'project_preset_name': 'ui_project_preset_name', 'ui_active_tool_preset': 'ui_active_tool_preset', 'ui_active_bias_profile': 'ui_active_bias_profile', 'temperature': 'temperature', 'max_tokens': 'max_tokens', 'show_preset_manager_window': 'show_preset_manager_window', 'show_tool_preset_manager_window': 'show_tool_preset_manager_window', 'show_persona_editor_window': 'show_persona_editor_window', '_editing_preset_name': '_editing_preset_name', '_editing_preset_content': '_editing_preset_content', '_editing_preset_temperature': '_editing_preset_temperature', '_editing_preset_top_p': '_editing_preset_top_p', '_editing_preset_max_output_tokens': '_editing_preset_max_output_tokens', '_editing_preset_scope': '_editing_preset_scope', 'ui_separate_task_dag': 'ui_separate_task_dag', 'ui_separate_usage_analytics': 'ui_separate_usage_analytics', 'ui_separate_tier1': 'ui_separate_tier1', 'ui_separate_tier2': 'ui_separate_tier2', 'ui_separate_tier3': 'ui_separate_tier3', 'ui_separate_tier4': 'ui_separate_tier4', 'show_text_viewer': 'show_text_viewer', 'text_viewer_title': 'text_viewer_title', 'text_viewer_type': 'text_viewer_type' }) self.perf_monitor = performance_monitor.get_monitor() self._perf_profiling_enabled = False self._init_actions() @property def perf_profiling_enabled(self) -> bool: return self._perf_profiling_enabled @perf_profiling_enabled.setter def perf_profiling_enabled(self, value: bool) -> None: self._perf_profiling_enabled = value if hasattr(self, 'perf_monitor'): self.perf_monitor.enabled = value @property def active_project_root(self) -> str: if self.active_project_path: return str(Path(self.active_project_path).parent) return self.ui_files_base_dir def _update_inject_preview(self) -> None: """Updates the preview content based on the selected file and injection mode.""" if not self._inject_file_path: self._inject_preview = "" return target_path = self._inject_file_path if not os.path.isabs(target_path): target_path = os.path.join(self.active_project_root, target_path) if not os.path.exists(target_path): self._inject_preview = "" return try: with open(target_path, "r", encoding="utf-8") as f: content = f.read() if self._inject_mode == "skeleton" and target_path.endswith(".py"): parser = ASTParser("python") preview = parser.get_skeleton(content) else: preview = content lines = preview.splitlines() if len(lines) > 500: preview = "\n".join(lines[:500]) + "\n... (truncated)" self._inject_preview = preview except Exception as e: self._inject_preview = f"Error reading file: {e}" @property def thinking_indicator(self) -> bool: return self.ai_status in ("sending...", "streaming...") @property def rag_enabled(self) -> bool: return self.rag_config.enabled if self.rag_config else False @rag_enabled.setter def rag_enabled(self, value: bool) -> None: if self.rag_config: self.rag_config.enabled = value self.rag_engine = rag_engine.RAGEngine(self.rag_config, self.active_project_root) @property def rag_source(self) -> str: return self.rag_config.vector_store.provider if self.rag_config else 'mock' @rag_source.setter def rag_source(self, value: str) -> None: if self.rag_config: self.rag_config.vector_store.provider = value if self.rag_engine: self.rag_engine = rag_engine.RAGEngine(self.rag_config, self.active_project_root) @property def rag_emb_provider(self) -> str: return self.rag_config.embedding_provider if self.rag_config else 'gemini' @rag_emb_provider.setter def rag_emb_provider(self, value: str) -> None: if self.rag_config: self.rag_config.embedding_provider = value if self.rag_engine: self.rag_engine = rag_engine.RAGEngine(self.rag_config, self.active_project_root) @property def rag_chunk_size(self) -> int: return self.rag_config.chunk_size if self.rag_config else 1000 @rag_chunk_size.setter def rag_chunk_size(self, value: int) -> None: if self.rag_config: self.rag_config.chunk_size = value @property def rag_chunk_overlap(self) -> int: return self.rag_config.chunk_overlap if self.rag_config else 200 @rag_chunk_overlap.setter def rag_chunk_overlap(self, value: int) -> None: if self.rag_config: self.rag_config.chunk_overlap = value @property def rag_mcp_server(self) -> str: return self.rag_config.vector_store.mcp_server or "" if self.rag_config else "" @rag_mcp_server.setter def rag_mcp_server(self, value: str) -> None: if self.rag_config: self.rag_config.vector_store.mcp_server = value @property def rag_mcp_tool(self) -> str: return self.rag_config.vector_store.mcp_tool or "" if self.rag_config else "" @rag_mcp_tool.setter def rag_mcp_tool(self, value: str) -> None: if self.rag_config: self.rag_config.vector_store.mcp_tool = value @property def mcp_config_json(self) -> str: return json.dumps(self.mcp_config.to_dict()) if self.mcp_config else "{}" @mcp_config_json.setter def mcp_config_json(self, value: str) -> None: try: data = json.loads(value) self.mcp_config = models.MCPConfiguration.from_dict(data) except: pass @property def ui_file_paths(self) -> list[str]: return [f.path if hasattr(f, 'path') else str(f) for f in self.files] @ui_file_paths.setter def ui_file_paths(self, value: list[str]) -> None: old_files = {f.path: f for f in self.files if hasattr(f, 'path')} new_files = [] import time now = time.time() for p in value: if p in old_files: new_files.append(old_files[p]) else: from src import models new_files.append(models.FileItem(path=p, injected_at=now)) self.files = new_files @property def operations_live_indicator(self) -> bool: return not self.is_viewing_prior_session @property def prior_session_indicator(self) -> bool: return self.is_viewing_prior_session def _init_actions(self) -> None: # Set up state-related action maps self._clickable_actions: dict[str, Callable[..., Any]] = { 'btn_reset': self._handle_reset_session, 'btn_gen_send': self._handle_generate_send, 'btn_md_only': self._handle_md_only, 'btn_approve_script': self._handle_approve_script, 'btn_reject_script': self._handle_reject_script, 'btn_project_save': self._cb_project_save, 'btn_disc_create': self._cb_disc_create, 'btn_mma_plan_epic': self._cb_plan_epic, 'btn_mma_accept_tracks': self._cb_accept_tracks, 'btn_mma_start_track': self._cb_start_track, 'btn_mma_create_track': lambda: self._cb_create_track(self.ui_new_track_name, self.ui_new_track_desc, self.ui_new_track_type), 'btn_approve_tool': self._handle_approve_ask, 'btn_approve_mma_step': lambda: self._handle_mma_respond(approved=True), 'btn_approve_spawn': lambda: self._handle_mma_respond(approved=True), 'btn_prune_logs': self.cb_prune_logs, 'btn_reset_base_prompt': self._cb_reset_base_prompt, 'btn_show_base_prompt_diff': self._cb_show_base_prompt_diff, 'btn_rebuild_rag_index': self._rebuild_rag_index, } self._predefined_callbacks: dict[str, Callable[..., Any]] = { '_test_callback_func_write_to_file': self._test_callback_func_write_to_file, '_set_env_var': lambda k, v: os.environ.update({k: v}), '_set_attr': lambda k, v: setattr(self, k, v), '_apply_preset': self._apply_preset, '_cb_save_preset': self._cb_save_preset, '_cb_delete_preset': self._cb_delete_preset, '_cb_save_tool_preset': self._cb_save_tool_preset, '_cb_delete_tool_preset': self._cb_delete_tool_preset, '_switch_project': self._switch_project, '_refresh_from_project': self._refresh_from_project, 'save_workspace_profile': self._cb_save_workspace_profile, 'load_workspace_profile': self._cb_load_workspace_profile, 'delete_workspace_profile': self._cb_delete_workspace_profile, } def _update_gcli_adapter(self, path: str) -> None: sys.stderr.write(f"[DEBUG] _update_gcli_adapter called with: {path}\n") sys.stderr.flush() if not ai_client._gemini_cli_adapter: ai_client._gemini_cli_adapter = ai_client.GeminiCliAdapter(binary_path=str(path)) else: ai_client._gemini_cli_adapter.binary_path = str(path) def _set_status(self, status: str) -> None: """Thread-safe update of ai_status via the GUI task queue.""" with self._pending_gui_tasks_lock: self._pending_gui_tasks.append({ "action": "set_ai_status", "payload": status }) def _set_mma_status(self, status: str) -> None: """Thread-safe update of mma_status via the GUI task queue.""" with self._pending_gui_tasks_lock: self._pending_gui_tasks.append({ "action": "set_mma_status", "payload": status }) def _set_rag_status(self, status: str) -> None: """Thread-safe update of rag_status via the GUI task queue.""" with self._pending_gui_tasks_lock: self._pending_gui_tasks.append({ "action": "set_value", "item": "rag_status", "value": status }) def _rebuild_rag_index(self) -> None: """Background thread that re-indexes all files in the current project.""" if not self.rag_config or not self.rag_config.enabled or not self.rag_engine: return def _run(): try: self._set_rag_status("indexing...") import concurrent.futures # 1. Incremental indexing of current files in parallel with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor: futures = [] for f in self.files: path = f.path if hasattr(f, "path") else str(f) futures.append(executor.submit(self.rag_engine.index_file, path)) concurrent.futures.wait(futures) # 2. Cleanup stale entries (files no longer tracked) indexed_paths = self.rag_engine.get_all_indexed_paths() current_paths = {f.path if hasattr(f, "path") else str(f) for f in self.files} stale_paths = [p for p in indexed_paths if p not in current_paths] if stale_paths: self.rag_engine.delete_documents_by_path(stale_paths) self._set_rag_status("ready") except Exception as e: self._set_rag_status(f"error: {e}") threading.Thread(target=_run, daemon=True).start() def _trigger_gui_refresh(self): with self._pending_gui_tasks_lock: self._pending_gui_tasks.append({'action': 'set_comms_dirty'}) self._pending_gui_tasks.append({'action': 'set_tool_log_dirty'}) def _process_pending_gui_tasks(self) -> None: # Periodic telemetry broadcast now = time.time() if hasattr(self, 'event_queue') and hasattr(self.event_queue, 'websocket_server') and self.event_queue.websocket_server: if now - self._last_telemetry_time >= 1.0: self._last_telemetry_time = now metrics = self.perf_monitor.get_metrics() self.event_queue.websocket_server.broadcast("telemetry", metrics) if not self._pending_gui_tasks: return sys.stderr.write(f"[DEBUG] _process_pending_gui_tasks: processing {len(self._pending_gui_tasks)} tasks\n") sys.stderr.flush() with self._pending_gui_tasks_lock: tasks = self._pending_gui_tasks[:] self._pending_gui_tasks.clear() for task in tasks: try: action = task.get("action") sys.stderr.write(f"[DEBUG] Processing GUI task: action={action}\n") sys.stderr.flush() if action: session_logger.log_api_hook("PROCESS_TASK", action, str(task)) # ... if action == "refresh_api_metrics": self._refresh_api_metrics(task.get("payload", {}), md_content=self.last_md or None) elif action == 'set_comms_dirty': self._comms_log_dirty = True elif action == 'set_tool_log_dirty': self._tool_log_dirty = True elif action == "set_ai_status": self.ai_status = task.get("payload", "") sys.stderr.write(f"[DEBUG] Updated ai_status via task to: {self.ai_status}\n") sys.stderr.flush() elif action == "set_mma_status": self.mma_status = task.get("payload", "") elif action == "handle_ai_response": payload = task.get("payload", {}) text = payload.get("text", "") stream_id = payload.get("stream_id") is_streaming = payload.get("status") == "streaming..." if stream_id: if is_streaming: if stream_id not in self.mma_streams: self.mma_streams[stream_id] = "" if stream_id not in self._worker_status: self._worker_status[stream_id] = "running" self.mma_streams[stream_id] += text if len(self.mma_streams[stream_id]) > self.MAX_STREAM_SIZE: self.mma_streams[stream_id] = self.mma_streams[stream_id][-self.MAX_STREAM_SIZE:] else: self.mma_streams[stream_id] = text if stream_id in self._worker_status and self._worker_status[stream_id] == "running": self._worker_status[stream_id] = "completed" if stream_id == "Tier 1": if "status" in payload: self.ai_status = payload["status"] else: if is_streaming: self.ai_response += text else: self.ai_response = text self.ai_status = payload.get("status", "done") sys.stderr.write(f"[DEBUG] Updated ai_status to: {self.ai_status}\n") sys.stderr.flush() self._trigger_blink = True if not stream_id: self._token_stats_dirty = True if not is_streaming: self._autofocus_response_tab = True elif action in ("mma_stream", "mma_stream_append"): # Some events might have these at top level, some in a 'payload' dict stream_id = task.get("stream_id") or task.get("payload", {}).get("stream_id") text = task.get("text") or task.get("payload", {}).get("text", "") if stream_id: if stream_id not in self.mma_streams: self.mma_streams[stream_id] = "" self.mma_streams[stream_id] += text elif action == "show_track_proposal": self.proposed_tracks = task.get("payload", []) self._show_track_proposal_modal = True elif action == "mma_state_update": # Handle both internal (nested) and hook-server (flattened) payloads p = task.get("payload") if not isinstance(p, dict): p = task # Fallback to task itself if payload is missing or wrong type sys.stderr.write(f"[DEBUG] mma_state_update: status={p.get('status')} active_tier={p.get('active_tier')}\n") sys.stderr.flush() self.mma_status = p.get("status", self.mma_status) old_tier = self.active_tier self.active_tier = p.get("active_tier", self.active_tier) if getattr(self, "ui_auto_switch_layout", False) and self.active_tier and self.active_tier != old_tier: for tier_prefix in ["Tier 1", "Tier 2", "Tier 3", "Tier 4"]: if self.active_tier.startswith(tier_prefix): bound_profile = getattr(self, "ui_tier_layout_bindings", {}).get(tier_prefix) if bound_profile: self._cb_load_workspace_profile(bound_profile) break # Preserve existing model/provider config if not explicitly in payload new_usage = p.get("tier_usage", {}) for tier, data in new_usage.items(): if tier in self.mma_tier_usage: # Update usage counts but keep selected model/provider if not in update self.mma_tier_usage[tier]["input"] = data.get("input", self.mma_tier_usage[tier]["input"]) self.mma_tier_usage[tier]["output"] = data.get("output", self.mma_tier_usage[tier]["output"]) if "model" in data: self.mma_tier_usage[tier]["model"] = data["model"] if "provider" in data: self.mma_tier_usage[tier]["provider"] = data["provider"] else: self.mma_tier_usage[tier] = data self.active_tickets = p.get("tickets", []) track_data = p.get("track") if track_data: tickets = [] for t_data in self.active_tickets: if isinstance(t_data, models.Ticket): tickets.append(t_data) else: # Map 'goal' from Godot format to 'description' if needed if "goal" in t_data and "description" not in t_data: t_data["description"] = t_data["goal"] tickets.append(models.Ticket.from_dict(t_data)) self.active_track = models.Track( id=track_data.get("id"), description=track_data.get("title", ""), tickets=tickets ) elif action == "set_value": item = task.get("item") value = task.get("value") sys.stderr.write(f"[DEBUG] Processing set_value: {item}={value}\n") sys.stderr.flush() if item in self._settable_fields: attr_name = self._settable_fields[item] setattr(self, attr_name, value) sys.stderr.write(f"[DEBUG] Set {attr_name} to {value}\n") sys.stderr.flush() if item == "gcli_path": self._update_gcli_adapter(str(value)) elif action == "click": item = task.get("item") user_data = task.get("user_data") sys.stderr.write(f"[DEBUG] Processing click: {item} (user_data={user_data})\n") sys.stderr.flush() if item == "btn_project_new_automated": self._cb_new_project_automated(user_data) elif item == "btn_mma_load_track": self._cb_load_track(str(user_data or "")) elif item in self._clickable_actions: func = self._clickable_actions[item] try: sig = inspect.signature(func) if 'user_data' in sig.parameters: func(user_data=user_data) else: func() except Exception: func() elif action == "select_list_item": item = task.get("listbox", task.get("item")) value = task.get("item_value", task.get("value")) if item == "disc_listbox": self._switch_discussion(str(value or "")) elif task.get("type") == "ask": self._pending_ask_dialog = True self._ask_request_id = task.get("request_id") self._ask_tool_data = task.get("data", {}) elif action == "clear_ask": if self._ask_request_id == task.get("request_id"): self._pending_ask_dialog = False self._ask_request_id = None self._ask_tool_data = None elif action == "custom_callback": cb = task.get("callback") args = task.get("args", []) if callable(cb): try: cb(*args) except Exception as e: print(f"Error in direct custom callback: {e}") elif cb in self._predefined_callbacks: self._predefined_callbacks[cb](*args) elif action == "mma_step_approval": dlg = MMAApprovalDialog(str(task.get("ticket_id") or ""), str(task.get("payload") or "")) self._pending_mma_approval = task if "dialog_container" in task: task["dialog_container"][0] = dlg elif action == 'refresh_from_project': self._refresh_from_project() elif action == "show_patch_modal": self._pending_patch_text = task.get("patch_text", "") self._pending_patch_files = task.get("file_paths", []) self._show_patch_modal = True elif action == "hide_patch_modal": self._show_patch_modal = False self._pending_patch_text = None self._pending_patch_files = [] elif action == "mma_spawn_approval": spawn_dlg = MMASpawnApprovalDialog( str(task.get("ticket_id") or ""), str(task.get("role") or ""), str(task.get("prompt") or ""), str(task.get("context_md") or "") ) self._pending_mma_spawn = task self._mma_spawn_prompt = task.get("prompt", "") self._mma_spawn_context = task.get("context_md", "") self._mma_spawn_open = True self._mma_spawn_edit_mode = False if "dialog_container" in task: task["dialog_container"][0] = spawn_dlg elif action == "ticket_started": payload = task.get("payload", {}) ticket_id = payload.get("ticket_id") start_time = payload.get("timestamp") persona_id = payload.get("persona_id") model = payload.get("model") if ticket_id and start_time: self._ticket_start_times[ticket_id] = start_time if ticket_id and (persona_id or model): stream_id = f"Tier 3 (Worker): {ticket_id}" meta_info = f"[STARTED] Ticket: {ticket_id}" if model: meta_info += f" | Model: {model}" if persona_id: meta_info += f" | Persona: {persona_id}" meta_info += "\n" + "="*50 + "\n" if stream_id not in self.mma_streams: self.mma_streams[stream_id] = "" self.mma_streams[stream_id] = meta_info + self.mma_streams[stream_id] elif action == "ticket_completed": payload = task.get("payload", {}) ticket_id = payload.get("ticket_id") end_time = payload.get("timestamp") if ticket_id and end_time and ticket_id in self._ticket_start_times: start_time = self._ticket_start_times.pop(ticket_id) elapsed = end_time - start_time self._completed_ticket_count += 1 self._avg_ticket_time = ((self._avg_ticket_time * (self._completed_ticket_count - 1)) + elapsed) / self._completed_ticket_count elif action == "bead_updated": payload = task.get("payload", {}) bid = payload.get("bead_id") status = payload.get("status") if bid and status: stream_id = "Tier 2" msg = f"\n[BEAD UPDATE] {bid} -> status: {status}\n" if stream_id not in self.mma_streams: self.mma_streams[stream_id] = "" self.mma_streams[stream_id] += msg elif action == "bead_updated": payload = task.get("payload", {}) bead_id = payload.get("bead_id") status = payload.get("status") stream_id = "Tier 2 (Tech Lead)" if stream_id not in self.mma_streams: self.mma_streams[stream_id] = "" self.mma_streams[stream_id] += f"[BEAD UPDATE] {bead_id} -> status: {status}\n" except Exception as e: sys.stderr.write(f"[DEBUG] Error executing GUI task: {e}\n{traceback.format_exc()}\n") sys.stderr.flush() print(f"Error executing GUI task: {e}") def _process_pending_history_adds(self) -> None: """Synchronizes pending history entries to the active discussion and project state.""" with self._pending_history_adds_lock: items = self._pending_history_adds[:] self._pending_history_adds.clear() if not items: return self._scroll_disc_to_bottom = True for item in items: item.get("role", "unknown") if item.get("role") and item["role"] not in self.disc_roles: self.disc_roles.append(item["role"]) disc_sec = self.project.get("discussion", {}) discussions = disc_sec.get("discussions", {}) disc_data = discussions.get(self.active_discussion) if disc_data is not None: if item.get("disc_title", self.active_discussion) == self.active_discussion: if self.disc_entries is not disc_data.get("history"): if "history" not in disc_data: disc_data["history"] = [] disc_data["history"].append(project_manager.entry_to_str(item)) disc_data["last_updated"] = project_manager.now_ts() with self._disc_entries_lock: self.disc_entries.append(item) def _process_pending_tool_calls(self) -> bool: """Drains pending tool calls into the tool log. Returns True if any were processed.""" with self._pending_tool_calls_lock: items = self._pending_tool_calls[:] self._pending_tool_calls.clear() if not items: return False for item in items: self._append_tool_log( item.get("script", ""), item.get("result", ""), source_tier=item.get("source_tier") ) return True def _test_callback_func_write_to_file(self, data: str) -> None: """A dummy function that a custom_callback would execute for testing.""" callback_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "tests", "artifacts", "temp_callback_output.txt") os.makedirs(os.path.dirname(callback_path), exist_ok=True) with open(callback_path, "w") as f: f.write(data) def _handle_approve_script(self, user_data=None) -> None: """Approves the currently pending PowerShell script.""" with self._pending_dialog_lock: dlg = self._pending_dialog if dlg: with dlg._condition: dlg._approved = True dlg._done = True dlg._condition.notify_all() self._pending_dialog = None def _handle_reject_script(self, user_data=None) -> None: """Rejects the currently pending PowerShell script.""" with self._pending_dialog_lock: dlg = self._pending_dialog if dlg: with dlg._condition: dlg._approved = False dlg._done = True dlg._condition.notify_all() self._pending_dialog = None def init_state(self): """Initializes the application state from configurations.""" self.ui_separate_task_dag = False self.ui_separate_usage_analytics = False self.ui_separate_tier1 = False self.ui_separate_tier2 = False self.ui_separate_tier3 = False self.ui_separate_tier4 = False self.ui_separate_external_tools = False self.config = models.load_config() path_info = paths.get_full_path_info() self.ui_logs_dir = str(path_info['logs_dir']['path']) self.ui_scripts_dir = str(path_info['scripts_dir']['path']) theme.load_from_config(self.config) ai_cfg = self.config.get("ai", {}) self._current_provider = ai_cfg.get("provider", "gemini") self._current_model = ai_cfg.get("model", "gemini-2.5-flash-lite") self.temperature = ai_cfg.get("temperature", 0.0) self.top_p = ai_cfg.get("top_p", 1.0) self.max_tokens = ai_cfg.get("max_tokens", 8192) self.history_trunc_limit = ai_cfg.get("history_trunc_limit", 8000) projects_cfg = self.config.get("projects", {}) self.project_paths = list(projects_cfg.get("paths", [])) self.active_project_path = projects_cfg.get("active", "") self._load_active_project() self.workspace_manager = workspace_manager.WorkspaceManager(project_root=Path(self.active_project_path).parent if self.active_project_path else None) self.workspace_profiles = self.workspace_manager.load_all_profiles() # Deserialize FileItems in files.paths raw_paths = self.project.get("files", {}).get("paths", []) self.files = [] for p in raw_paths: if isinstance(p, models.FileItem): self.files.append(p) elif isinstance(p, dict): self.files.append(models.FileItem.from_dict(p)) else: self.files.append(models.FileItem(path=str(p))) self.screenshots = list(self.project.get("screenshots", {}).get("paths", [])) disc_sec = self.project.get("discussion", {}) self.disc_roles = list(disc_sec.get("roles", ["User", "AI", "Vendor API", "System", "Reasoning", "Context"])) self.active_discussion = disc_sec.get("active", "main") disc_data = disc_sec.get("discussions", {}).get(self.active_discussion, {}) with self._disc_entries_lock: self.disc_entries = models.parse_history_entries(disc_data.get("history", []), self.disc_roles) # UI state self.ui_output_dir = self.project.get("output", {}).get("output_dir", "./md_gen") self.ui_files_base_dir = self.project.get("files", {}).get("base_dir", ".") self.ui_shots_base_dir = self.project.get("screenshots", {}).get("base_dir", ".") proj_meta = self.project.get("project", {}) self.ui_project_git_dir = proj_meta.get("git_dir", "") self.ui_project_conductor_dir = self.project.get('conductor', {}).get('dir', 'conductor') self.ui_project_main_context = proj_meta.get("main_context", "") self.ui_project_system_prompt = proj_meta.get("system_prompt", "") self.ui_gemini_cli_path = self.project.get("gemini_cli", {}).get("binary_path", "gemini") self._update_gcli_adapter(self.ui_gemini_cli_path) self.ui_word_wrap = proj_meta.get("word_wrap", True) self.ui_auto_add_history = disc_sec.get("auto_add", False) self.ui_global_system_prompt = self.config.get("ai", {}).get("system_prompt", "") self.ui_base_system_prompt = self.config.get("ai", {}).get("base_system_prompt", "") self.ui_use_default_base_prompt = self.config.get("ai", {}).get("use_default_base_prompt", True) self.ui_project_context_marker = proj_meta.get("context_marker", "") self.preset_manager = presets.PresetManager(Path(self.active_project_path).parent if self.active_project_path else None) self.presets = self.preset_manager.load_all() self.tool_preset_manager = tool_presets.ToolPresetManager(Path(self.active_project_path).parent if self.active_project_path else None) self.tool_presets = self.tool_preset_manager.load_all_presets() self.bias_profiles = self.tool_preset_manager.load_all_bias_profiles() mcp_path = self.project.get('project', {}).get('mcp_config_path') or self.config.get('ai', {}).get('mcp_config_path') if mcp_path: mcp_p = Path(mcp_path) if not mcp_p.is_absolute() and self.active_project_path: mcp_p = Path(self.active_project_path).parent / mcp_path if mcp_p.exists(): self.mcp_config = models.load_mcp_config(str(mcp_p)) else: self.mcp_config = models.MCPConfiguration() else: self.mcp_config = models.MCPConfiguration() rag_data = self.config.get('rag') if rag_data: self.rag_config = models.RAGConfig.from_dict(rag_data) else: self.rag_config = models.RAGConfig() self.rag_engine = rag_engine.RAGEngine(self.rag_config, self.active_project_root) if self.rag_config.enabled and self.rag_engine.is_empty(): self._rebuild_rag_index() from src.personas import PersonaManager self.persona_manager = PersonaManager(Path(self.active_project_path).parent if self.active_project_path else None) self.personas = self.persona_manager.load_all() self._fetch_models(self.current_provider) self.ui_active_tool_preset = os.environ.get('SLOP_TOOL_PRESET') or ai_cfg.get("active_tool_preset") self.ui_active_bias_profile = ai_cfg.get("active_bias_profile") ai_client.set_tool_preset(self.ui_active_tool_preset) ai_client.set_bias_profile(self.ui_active_bias_profile) self.ui_global_preset_name = ai_cfg.get("active_preset") self.ui_project_preset_name = proj_meta.get("active_preset") gui_cfg = self.config.get("gui", {}) self.ui_auto_switch_layout = gui_cfg.get("auto_switch_layout", False) self.ui_tier_layout_bindings = gui_cfg.get("tier_layout_bindings", {"Tier 1": "", "Tier 2": "", "Tier 3": "", "Tier 4": ""}) from src import bg_shader bg_shader.get_bg().enabled = gui_cfg.get("bg_shader_enabled", False) _default_windows = { "Project Settings": True, "Files & Media": True, "AI Settings": True, "MMA Dashboard": True, "Task DAG": False, "Usage Analytics": False, "Tier 1": False, "Tier 2": False, "Tier 3": False, "Tier 4": False, "Tier 1: Strategy": True, "Tier 2: Tech Lead": True, "Tier 3: Workers": True, "Tier 4: QA": True, "Discussion Hub": True, "Operations Hub": True, "Message": False, "Response": False, "Tool Calls": False, "Theme": True, "Log Management": False, } saved = self.config.get("gui", {}).get("show_windows", {}) self.show_windows = {k: saved.get(k, v) for k, v in _default_windows.items()} agent_tools_cfg = self.project.get("agent", {}).get("tools", {}) self.ui_agent_tools = {t: agent_tools_cfg.get(t, True) for t in models.AGENT_TOOL_NAMES} label = self.project.get("project", {}).get("name", "") session_logger.reset_session(label=label) # Trigger auto-start of MCP servers self.event_queue.put('refresh_external_mcps', None) async def refresh_external_mcps(self): await mcp_client.get_external_mcp_manager().stop_all() # Start servers with auto_start=True for name, cfg in self.mcp_config.mcpServers.items(): if cfg.auto_start: await mcp_client.get_external_mcp_manager().add_server(cfg) def cb_load_prior_log(self, path: Optional[str] = None) -> None: root = hide_tk_root() if path is None: path = filedialog.askdirectory( title="Select Session Directory", initialdir=str(paths.get_logs_dir()) ) root.destroy() if not path: return if not self.is_viewing_prior_session: self._current_session_usage = copy.deepcopy(self.session_usage) self._current_mma_tier_usage = copy.deepcopy(self.mma_tier_usage) self._current_token_history = copy.deepcopy(self._token_history) self._current_session_start_time = self._session_start_time log_path = Path(path) if log_path.is_dir(): log_file = log_path / "comms.log" session_dir = log_path else: log_file = log_path session_dir = log_path.parent if not log_file.exists(): self._set_status(f"log file not found: {log_file}") return def _resolve_log_ref(content: Any, session_dir: Path) -> str: if not content or not isinstance(content, str) or "[REF:" not in content: return str(content) if content is not None else "" pattern = r'\[REF:([^\]]+)\]' def replace_ref(match): ref_file = match.group(1) paths_to_check = [ session_dir / "outputs" / ref_file, session_dir / "scripts" / ref_file ] for p in paths_to_check: if p.exists(): try: with open(p, "r", encoding="utf-8") as rf: return rf.read() except Exception: return f"[ERROR READING REF: {ref_file}]" return match.group(0) return re.sub(pattern, replace_ref, content) entries = [] disc_entries = [] paired_tools = {} final_tool_calls = [] new_token_history = [] new_usage = {'input_tokens': 0, 'output_tokens': 0, 'cache_read_input_tokens': 0, 'cache_creation_input_tokens': 0, 'total_tokens': 0, 'last_latency': 0.0, 'percentage': 0.0} new_mma_usage = copy.deepcopy(self.mma_tier_usage) for t in new_mma_usage: new_mma_usage[t]['input'] = 0 new_mma_usage[t]['output'] = 0 try: with open(log_file, "r", encoding="utf-8") as f: for line in f: line = line.strip() if line: try: entry = json.loads(line) entries.append(entry) kind = entry.get("kind", entry.get("type", "")) payload = entry.get("payload", {}) ts = entry.get("ts", "") if kind == 'tool_call': tid = payload.get('id') or payload.get('call_id') script = payload.get('script') or json.dumps(payload.get('args', {}), indent=1) script = _resolve_log_ref(script, session_dir) entry_obj = { 'source_tier': entry.get('source_tier', 'main'), 'script': script, 'result': '', # Waiting for result 'ts': ts } if tid: paired_tools[tid] = entry_obj final_tool_calls.append(entry_obj) elif kind == 'tool_result': tid = payload.get('id') or payload.get('call_id') output = payload.get('output', payload.get('content', '')) output = _resolve_log_ref(output, session_dir) if tid and tid in paired_tools: paired_tools[tid]['result'] = output else: # Fallback: if no ID, try matching last entry in final_tool_calls that has no result for old_call in reversed(final_tool_calls): if not old_call['result']: old_call['result'] = output break if kind == 'response' and 'usage' in payload: u = payload['usage'] for k in ['input_tokens', 'output_tokens', 'cache_read_input_tokens', 'cache_creation_input_tokens', 'total_tokens']: if k in new_usage: new_usage[k] += u.get(k, 0) or 0 tier = entry.get('source_tier', 'main') if tier in new_mma_usage: new_mma_usage[tier]['input'] += u.get('input_tokens', 0) or 0 new_mma_usage[tier]['output'] += u.get('output_tokens', 0) or 0 new_token_history.append({ 'time': ts, 'input': u.get('input_tokens', 0) or 0, 'output': u.get('output_tokens', 0) or 0, 'model': entry.get('model', 'unknown') }) if kind == "history_add": content = payload.get("content", payload.get("text", payload.get("message", ""))) content = _resolve_log_ref(content, session_dir) disc_entries.append({ "role": payload.get("role", "AI"), "content": content, "collapsed": payload.get("collapsed", False), "ts": ts }) elif kind == "request": content = payload.get("message", payload.get("content", payload.get("text", ""))) content = _resolve_log_ref(content, session_dir) disc_entries.append({ "role": "User", "content": content, "collapsed": False, "ts": ts }) elif kind == "response": text = payload.get("text", payload.get("content", payload.get("message", ""))) text = _resolve_log_ref(text, session_dir) tool_calls = payload.get("tool_calls", []) content = text if tool_calls: try: tc_str = json.dumps(tool_calls, indent=1) if content: content += f"\n\n[TOOL CALLS]\n{tc_str}" else: content = f"[TOOL CALLS]\n{tc_str}" except: if content: content += f"\n\n[TOOL CALLS PRESENT]" else: content = "[TOOL CALLS PRESENT]" disc_entries.append({ "role": "AI", "content": content, "collapsed": False, "ts": ts }) elif kind == "tool_result": output = payload.get("output", payload.get("content", "")) output = _resolve_log_ref(output, session_dir) disc_entries.append({ "role": "Tool", "content": f"[TOOL RESULT]\n{output}", "collapsed": True, "ts": ts }) except json.JSONDecodeError: continue except Exception as e: self._set_status(f"log load error: {e}") return self.session_usage = new_usage self.mma_tier_usage = new_mma_usage self._token_history = new_token_history if new_token_history: try: import datetime first_ts = new_token_history[0]['time'] dt = datetime.datetime.strptime(first_ts, '%Y-%m-%dT%H:%M:%S') self._session_start_time = dt.timestamp() except: self._session_start_time = time.time() self.prior_session_entries = entries self.prior_disc_entries = disc_entries self.prior_tool_calls = final_tool_calls self.is_viewing_prior_session = True self._trigger_gui_refresh() self._set_status(f"viewing prior session: {session_dir.name} ({len(entries)} entries)") def cb_exit_prior_session(self): self.is_viewing_prior_session = False if self._current_session_usage: self.session_usage = self._current_session_usage self._current_session_usage = None if self._current_mma_tier_usage: self.mma_tier_usage = self._current_mma_tier_usage self._current_mma_tier_usage = None if self._current_token_history is not None: self._token_history = self._current_token_history self._current_token_history = None if self._current_session_start_time is not None: self._session_start_time = self._current_session_start_time self._current_session_start_time = None self.prior_session_entries.clear() self.prior_disc_entries.clear() self.prior_tool_calls.clear() self._trigger_gui_refresh() self._set_status('idle') def cb_prune_logs(self) -> None: """Manually triggers the log pruning process with aggressive thresholds.""" self._set_status("Manual prune started (Age > 0d, Size < 100KB)...") def run_manual_prune() -> None: try: from src import log_registry from src import log_pruner registry = log_registry.LogRegistry(str(paths.get_logs_dir() / "log_registry.toml")) pruner = log_pruner.LogPruner(registry, str(paths.get_logs_dir())) # Aggressive: Prune anything not whitelisted, even if just created, if under 100KB # Note: max_age_days=0 means cutoff is NOW. pruner.prune(max_age_days=0, min_size_kb=100) self._set_status("Manual prune complete.") except Exception as e: self._set_status(f"Manual prune error: {e}") print(f"Error during manual log pruning: {e}") thread = threading.Thread(target=run_manual_prune, daemon=True) thread.start() def _load_active_project(self) -> None: """Loads the active project configuration, with fallbacks.""" if self.active_project_path and Path(self.active_project_path).exists(): try: self.project = project_manager.load_project(self.active_project_path) return except Exception as e: print(f"Failed to load project {self.active_project_path}: {e}") for pp in self.project_paths: if Path(pp).exists(): try: self.project = project_manager.load_project(pp) self.active_project_path = pp return except Exception: continue self.project = project_manager.migrate_from_legacy_config(self.config) name = self.project.get("project", {}).get("name", "project") fallback_path = f"{name}.toml" project_manager.save_project(self.project, fallback_path) self.active_project_path = fallback_path if fallback_path not in self.project_paths: self.project_paths.append(fallback_path) def _prune_old_logs(self) -> None: """Asynchronously prunes old insignificant logs on startup.""" def run_prune() -> None: try: from src import log_registry from src import log_pruner registry = log_registry.LogRegistry(str(paths.get_logs_dir() / "log_registry.toml")) pruner = log_pruner.LogPruner(registry, str(paths.get_logs_dir())) pruner.prune() except Exception as e: print(f"Error during log pruning: {e}") thread = threading.Thread(target=run_prune, daemon=True) thread.start() def _fetch_models(self, provider: str) -> None: self._set_status("fetching models...") def do_fetch() -> None: try: for p in self.PROVIDERS: try: self.all_available_models[p] = ai_client.list_models(p) except Exception as e: sys.stderr.write(f"[DEBUG] Error fetching models for {p}: {e}\n") self.all_available_models[p] = [] models_list = self.all_available_models.get(provider, []) self.available_models = models_list if self.current_model not in models_list and models_list: self.current_model = models_list[0] ai_client.set_provider(self._current_provider, self.current_model) self._set_status(f"models loaded: {len(models_list)}") except Exception as e: self._set_status(f"model fetch error: {e}") self.models_thread = threading.Thread(target=do_fetch, daemon=True) self.models_thread.start() def start_services(self, app: Any = None): """Starts background threads.""" sys.stderr.write("[DEBUG] AppController.start_services called\n") sys.stderr.flush() self._prune_old_logs() self._init_ai_and_hooks(app) self._loop_thread = threading.Thread(target=self._run_event_loop, daemon=True) self._loop_thread.start() sys.stderr.write(f"[DEBUG] _loop_thread started: {self._loop_thread.ident}\n") sys.stderr.flush() def shutdown(self) -> None: """Stops background threads and cleans up resources.""" from src import ai_client ai_client.cleanup() if hasattr(self, 'hook_server') and self.hook_server: self.hook_server.stop() self.event_queue.put("shutdown", None) if self._loop_thread and self._loop_thread.is_alive(): self._loop_thread.join(timeout=2.0) def _init_ai_and_hooks(self, app: Any = None) -> None: from src import api_hooks ai_client.set_provider(self._current_provider, self._current_model) if self._current_provider == "gemini_cli": if not ai_client._gemini_cli_adapter: ai_client._gemini_cli_adapter = ai_client.GeminiCliAdapter(binary_path=self.ui_gemini_cli_path) else: ai_client._gemini_cli_adapter.binary_path = self.ui_gemini_cli_path ai_client.confirm_and_run_callback = self._confirm_and_run ai_client.comms_log_callback = self._on_comms_entry ai_client.tool_log_callback = self._on_tool_log mcp_client.perf_monitor_callback = self.perf_monitor.get_metrics self.perf_monitor.alert_callback = self._on_performance_alert ai_client.events.on("request_start", lambda **kw: self._on_api_event("request_start", **kw)) ai_client.events.on("response_received", lambda **kw: self._on_api_event("response_received", **kw)) ai_client.events.on("tool_execution", lambda **kw: self._on_api_event("tool_execution", **kw)) self.hook_server = api_hooks.HookServer(app if app else self) self.hook_server.start() def _run_event_loop(self): """Internal loop runner.""" def queue_fallback() -> None: while True: try: if hasattr(self, '_process_pending_gui_tasks'): self._process_pending_gui_tasks() if hasattr(self, '_process_pending_history_adds'): self._process_pending_history_adds() except: pass time.sleep(0.1) fallback_thread = threading.Thread(target=queue_fallback, daemon=True) fallback_thread.start() self._process_event_queue() def _process_event_queue(self) -> None: """Listens for and processes events from the SyncEventQueue.""" sys.stderr.write("[DEBUG] _process_event_queue entered\n") sys.stderr.flush() while True: event_name, payload = self.event_queue.get() sys.stderr.write(f"[DEBUG] _process_event_queue got event: {event_name} with payload: {str(payload)[:100]}\n") sys.stderr.flush() if event_name == "shutdown": break if event_name == "user_request": threading.Thread(target=self._handle_request_event, args=(payload,), daemon=True).start() elif event_name == "gui_task": with self._pending_gui_tasks_lock: # Directly append the task from the hook server. # It already contains 'action' and any necessary fields. self._pending_gui_tasks.append(payload) elif event_name == "mma_state_update": with self._pending_gui_tasks_lock: self._pending_gui_tasks.append({ "action": "mma_state_update", "payload": payload }) elif event_name == "mma_stream": with self._pending_gui_tasks_lock: self._pending_gui_tasks.append({ "action": "mma_stream_append", "payload": payload }) elif event_name in ("mma_spawn_approval", "mma_step_approval"): with self._pending_gui_tasks_lock: # These payloads already contain the 'action' field self._pending_gui_tasks.append(payload) elif event_name == "response": with self._pending_gui_tasks_lock: self._pending_gui_tasks.append({ "action": "handle_ai_response", "payload": payload }) if self.test_hooks_enabled: with self._api_event_queue_lock: self._api_event_queue.append({"type": "response", "payload": payload}) elif event_name == "ticket_started": with self._pending_gui_tasks_lock: self._pending_gui_tasks.append({ "action": "ticket_started", "payload": payload }) elif event_name == "ticket_completed": with self._pending_gui_tasks_lock: self._pending_gui_tasks.append({ "action": "ticket_completed", "payload": payload }) elif event_name == "refresh_external_mcps": import asyncio asyncio.run(self.refresh_external_mcps()) def _handle_request_event(self, event: events.UserRequestEvent) -> None: """Processes a UserRequestEvent by calling the AI client.""" self._set_status('sending...') ai_client.set_current_tier(None) # Ensure main discussion is untagged # Clear response area for new turn self.ai_response = "" csp = filter(bool, [self.ui_global_system_prompt.strip(), self.ui_project_system_prompt.strip()]) custom_prompt = "\n\n".join(csp) ai_client.set_custom_system_prompt(custom_prompt) ai_client.set_base_system_prompt(self.ui_base_system_prompt) ai_client.set_use_default_base_prompt(self.ui_use_default_base_prompt) ai_client.set_project_context_marker(self.ui_project_context_marker) self.last_resolved_system_prompt = ai_client.get_combined_system_prompt() ai_client.set_model_params(self.temperature, self.max_tokens, self.history_trunc_limit, self.top_p) ai_client.set_agent_tools(self.ui_agent_tools) # Force update adapter path right before send to bypass potential duplication issues self._update_gcli_adapter(self.ui_gemini_cli_path) sys.stderr.write(f"[DEBUG] Calling ai_client.send with provider={ai_client.get_provider()}, model={self.current_model}, gcli_path={self.ui_gemini_cli_path}\n") sys.stderr.flush() try: resp = ai_client.send( event.stable_md, event.prompt, event.base_dir, event.file_items, event.disc_text, stream=True, stream_callback=lambda text: self._on_ai_stream(text), pre_tool_callback=self._confirm_and_run, qa_callback=ai_client.run_tier4_analysis, patch_callback=ai_client.run_tier4_patch_callback, rag_engine=self.rag_engine ) self.event_queue.put("response", {"text": resp, "status": "done", "role": "AI"}) except ai_client.ProviderError as e: sys.stderr.write(f"[DEBUG] _handle_request_event ai_client.ProviderError: {e.ui_message()}\n") sys.stderr.flush() self.event_queue.put("response", {"text": e.ui_message(), "status": "error", "role": "Vendor API"}) except Exception as e: sys.stderr.write(f"[DEBUG] _handle_request_event ERROR: {e}\n{traceback.format_exc()}\n") sys.stderr.flush() self.event_queue.put("response", {"text": f"ERROR: {e}", "status": "error", "role": "System"}) def _offload_entry_payload(self, entry: Dict[str, Any]) -> Dict[str, Any]: optimized = copy.deepcopy(entry) kind = optimized.get("kind") payload = optimized.get("payload", {}) if kind == "tool_result" and "output" in payload: output = payload["output"] ref_path = session_logger.log_tool_output(output) if ref_path: filename = Path(ref_path).name payload["output"] = f"[REF:{filename}]" if kind == "tool_call" and "script" in payload: script = payload["script"] ref_path = session_logger.log_tool_call(script, "LOG_ONLY", None) if ref_path: filename = Path(ref_path).name payload["script"] = f"[REF:{filename}]" return optimized def _on_ai_stream(self, text: str) -> None: """Handles streaming text from the AI.""" self.event_queue.put("response", {"text": text, "status": "streaming...", "role": "AI"}) def _on_comms_entry(self, entry: Dict[str, Any]) -> None: optimized_entry = self._offload_entry_payload(entry) session_logger.log_comms(optimized_entry) entry["local_ts"] = time.time() kind = entry.get("kind") payload = entry.get("payload", {}) if kind == "response" and "usage" in payload: u = payload["usage"] inp = u.get("input_tokens") or u.get("prompt_tokens") or 0 out = u.get("output_tokens") or u.get("completion_tokens") or 0 cache_read = u.get("cache_read_input_tokens") or 0 cache_create = u.get("cache_creation_input_tokens") or 0 total = u.get("total_tokens") or 0 # Store normalized usage back in payload for history rendering u["input_tokens"] = inp u["output_tokens"] = out u["cache_read_input_tokens"] = cache_read self.session_usage["input_tokens"] += inp self.session_usage["output_tokens"] += out self.session_usage["cache_read_input_tokens"] += cache_read self.session_usage["cache_creation_input_tokens"] += cache_create self.session_usage["total_tokens"] += total input_t = u.get("input_tokens") or 0 output_t = u.get("output_tokens") or 0 model = payload.get("model", "unknown") self._token_history.append({ "time": time.time(), "input": input_t, "output": output_t, "model": model }) if kind == "request": if self.ui_auto_add_history: with self._pending_history_adds_lock: self._pending_history_adds.append({ "role": "User", "content": payload.get("message", ""), "collapsed": payload.get("collapsed", False), "ts": entry.get("ts", project_manager.now_ts()) }) if kind == "response": if self.ui_auto_add_history: role = payload.get("role", "AI") text_content = payload.get("text", "") if text_content.strip(): segments, parsed_response = thinking_parser.parse_thinking_trace(text_content) entry_obj = { "role": role, "content": parsed_response.strip() if parsed_response else "", "collapsed": True, "ts": entry.get("ts", project_manager.now_ts()) } if segments: entry_obj["thinking_segments"] = [{"content": s.content, "marker": s.marker} for s in segments] if entry_obj["content"] or segments: with self._pending_history_adds_lock: self._pending_history_adds.append(entry_obj) if kind in ("tool_result", "tool_call"): if self.ui_auto_add_history: role = "Tool" if kind == "tool_result" else "Vendor API" content = "" if kind == "tool_result": content = payload.get("output", "") else: content = payload.get("script") or payload.get("args") or payload.get("message", "") if isinstance(content, dict): content = json.dumps(content, indent=1) with self._pending_history_adds_lock: self._pending_history_adds.append({ "role": role, "content": f"[{kind.upper().replace('_', ' ')}]\n{content}", "collapsed": True, "ts": entry.get("ts", project_manager.now_ts()) }) if kind == "history_add": payload = entry.get("payload", {}) with self._pending_history_adds_lock: self._pending_history_adds.append({ "role": payload.get("role", "AI"), "content": payload.get("content", ""), "collapsed": payload.get("collapsed", False), "ts": entry.get("ts", project_manager.now_ts()) }) return with self._pending_comms_lock: self._pending_comms.append(entry) def _on_tool_log(self, script: str, result: str) -> None: session_logger.log_tool_call(script, result, None) session_logger.log_tool_output(result) source_tier = ai_client.get_current_tier() with self._pending_tool_calls_lock: self._pending_tool_calls.append({"script": script, "result": result, "ts": time.time(), "source_tier": source_tier}) def _on_api_event(self, event_name: str = "generic_event", **kwargs: Any) -> None: payload = kwargs.get("payload", {}) # Push to background event queue, NOT GUI queue self.event_queue.put("refresh_api_metrics", payload) if self.test_hooks_enabled: with self._api_event_queue_lock: self._api_event_queue.append({"type": event_name, "payload": payload}) def _on_performance_alert(self, message: str) -> None: self.diagnostic_log.append({ "ts": project_manager.now_ts(), "message": message, "type": "performance" }) def _confirm_and_run(self, script: str, base_dir: str, qa_callback: Optional[Callable[[str], str]] = None, patch_callback: Optional[Callable[[str, str], Optional[str]]] = None) -> Optional[str]: sys.stderr.write(f"[DEBUG] _confirm_and_run called. test_hooks={self.test_hooks_enabled}, manual_approve={getattr(self, 'ui_manual_approve', False)}\n") sys.stderr.flush() if self.test_hooks_enabled and not getattr(self, "ui_manual_approve", False): sys.stderr.write("[DEBUG] Auto-approving script.\n") sys.stderr.flush() self._set_status("running powershell...") output = shell_runner.run_powershell(script, base_dir, qa_callback=qa_callback, patch_callback=patch_callback) self._set_status("powershell done, awaiting AI...") return output sys.stderr.write("[DEBUG] Creating ConfirmDialog.\n") sys.stderr.flush() dialog = ConfirmDialog(script, base_dir) is_headless = "--headless" in sys.argv if is_headless: with self._pending_dialog_lock: self._pending_actions[dialog._uid] = dialog else: with self._pending_dialog_lock: self._pending_dialog = dialog if self.test_hooks_enabled and hasattr(self, '_api_event_queue'): with self._api_event_queue_lock: self._api_event_queue.append({ "type": "script_confirmation_required", "action_id": dialog._uid, "script": str(script), "base_dir": str(base_dir), "ts": time.time() }) sys.stderr.write(f"[DEBUG] Appended script_confirmation_required to _api_event_queue. ID={dialog._uid}\n") sys.stderr.flush() sys.stderr.write(f"[DEBUG] Waiting for dialog ID={dialog._uid}...\n") sys.stderr.flush() approved, final_script = dialog.wait() sys.stderr.write(f"[DEBUG] Dialog ID={dialog._uid} finished wait. approved={approved}\n") sys.stderr.flush() if is_headless: with self._pending_dialog_lock: if dialog._uid in self._pending_actions: del self._pending_actions[dialog._uid] if not approved: self._append_tool_log(final_script, "REJECTED by user") return None self._set_status("running powershell...") output = shell_runner.run_powershell(final_script, base_dir, qa_callback=qa_callback, patch_callback=patch_callback) self._append_tool_log(final_script, output) self._set_status("powershell done, awaiting AI...") return output def _append_tool_log(self, script: str, result: str, source_tier: str | None = None, elapsed_ms: float = 0.0) -> None: self._tool_log.append({"script": script, "result": result, "ts": time.time(), "source_tier": source_tier}) tool_name = self._extract_tool_name(script) is_failure = "REJECTED" in result or "Error" in result or "error" in result.lower() if tool_name: if tool_name not in self._tool_stats: self._tool_stats[tool_name] = {"count": 0, "total_time_ms": 0.0, "failures": 0} self._tool_stats[tool_name]["count"] += 1 self._tool_stats[tool_name]["total_time_ms"] += elapsed_ms if is_failure: self._tool_stats[tool_name]["failures"] += 1 self.ui_last_script_text = script self.ui_last_script_output = result self._trigger_script_blink = True self.show_script_output = True if self.ui_auto_scroll_tool_calls: self._scroll_tool_calls_to_bottom = True def _extract_tool_name(self, script: str) -> str: if not script: return "unknown" script_lower = script.lower() if "powershell" in script_lower or "run_powershell" in script_lower: return "run_powershell" if "read_file" in script_lower: return "read_file" if "write_file" in script_lower or "write" in script_lower: return "write_file" if "list_directory" in script_lower or "ls" in script_lower: return "list_directory" if "search_files" in script_lower or "glob" in script_lower: return "search_files" if "web_search" in script_lower: return "web_search" if "fetch_url" in script_lower: return "fetch_url" if "py_get" in script_lower: return "py_get_skeleton" return "other" def resolve_pending_action(self, action_id: str, approved: bool) -> bool: with self._pending_dialog_lock: if action_id in self._pending_actions: dialog = self._pending_actions[action_id] with dialog._condition: dialog._approved = approved dialog._done = True dialog._condition.notify_all() return True elif self._pending_dialog and self._pending_dialog._uid == action_id: dialog = self._pending_dialog with dialog._condition: dialog._approved = approved dialog._done = True dialog._condition.notify_all() return True return False @property def current_provider(self) -> str: return self._current_provider @current_provider.setter def current_provider(self, value: str) -> None: if value != self._current_provider: self._current_provider = value ai_client.reset_session() ai_client.set_provider(value, self.current_model) self.available_models = self.all_available_models.get(value, []) if not self.available_models: self._fetch_models(value) self._token_stats = {} self._token_stats_dirty = True @property def current_model(self) -> str: return self._current_model @current_model.setter def current_model(self, value: str) -> None: if value != self._current_model: self._current_model = value ai_client.reset_session() ai_client.set_provider(self.current_provider, value) self._token_stats = {} self._token_stats_dirty = True def create_api(self) -> FastAPI: """Creates and configures the FastAPI application for headless mode.""" api = FastAPI(title="Manual Slop Headless API") API_KEY_NAME = "X-API-KEY" api_key_header = APIKeyHeader(name=API_KEY_NAME, auto_error=False) async def get_api_key(header_key: str = Depends(api_key_header)) -> str: """Validates the API key from the request header against configuration.""" headless_cfg = self.config.get("headless", {}) config_key = headless_cfg.get("api_key", "").strip() env_key = os.environ.get("SLOP_API_KEY", "").strip() target_key = env_key or config_key if not target_key: raise HTTPException(status_code=403, detail="API Key not configured on server") if header_key == target_key: return header_key raise HTTPException(status_code=403, detail="Could not validate API Key") @api.get("/health") def health() -> dict[str, str]: """Returns the health status of the API.""" return {"status": "ok"} @api.get("/api/gui/state", dependencies=[Depends(get_api_key)]) def get_gui_state() -> dict[str, Any]: """Returns the current GUI state for specific fields.""" gettable = getattr(self, "_gettable_fields", {}) state = {} import dataclasses for key, attr in gettable.items(): val = getattr(self, attr, None) if dataclasses.is_dataclass(val): state[key] = dataclasses.asdict(val) else: state[key] = val return state @api.get("/api/gui/mma_status", dependencies=[Depends(get_api_key)]) def get_mma_status() -> dict[str, Any]: """Dedicated endpoint for MMA-related status.""" return { "mma_status": self.mma_status, "ai_status": self.ai_status, "mma_streams": self.mma_streams, "worker_status": self._worker_status, "tool_stats": self._tool_stats, "active_tier": self.active_tier, "active_tickets": self.active_tickets, "proposed_tracks": self.proposed_tracks } @api.post("/api/gui", dependencies=[Depends(get_api_key)]) def post_gui(req: dict) -> dict[str, str]: """Pushes a GUI task to the event queue.""" self.event_queue.put("gui_task", req) return {"status": "queued"} @api.get("/api/session", dependencies=[Depends(get_api_key)]) def get_api_session() -> dict[str, Any]: """Returns current discussion session entries.""" with self._disc_entries_lock: return {"session": {"entries": self.disc_entries}} @api.post("/api/session", dependencies=[Depends(get_api_key)]) def post_api_session(req: dict) -> dict[str, str]: """Updates session entries.""" entries = req.get("entries", []) with self._disc_entries_lock: self.disc_entries = entries return {"status": "updated"} @api.get("/api/project", dependencies=[Depends(get_api_key)]) def get_api_project() -> dict[str, Any]: """Returns current project data.""" return {"project": self.project} @api.get("/api/performance", dependencies=[Depends(get_api_key)]) def get_performance() -> dict[str, Any]: """Returns performance monitor metrics.""" return {"performance": self.perf_monitor.get_metrics()} @api.get("/api/gui/diagnostics", dependencies=[Depends(get_api_key)]) def get_diagnostics() -> dict[str, Any]: """Alias for performance metrics.""" return self.perf_monitor.get_metrics() @api.get("/status", dependencies=[Depends(get_api_key)]) def status() -> dict[str, Any]: """Returns the current status of the application.""" return { "provider": self.current_provider, "model": self.current_model, "status": self.ai_status, "usage": self.session_usage } @api.post("/api/v1/generate", dependencies=[Depends(get_api_key)]) def generate(req: GenerateRequest) -> dict[str, Any]: """Triggers an AI generation request using the current project context.""" if not req.prompt.strip(): raise HTTPException(status_code=400, detail="Prompt cannot be empty") with self._send_thread_lock: start_time = time.time() try: md, path, file_items, stable_md, disc_text = self._do_generate() self._last_stable_md = stable_md self.last_md = md self.last_md_path = path self.last_file_items = file_items except Exception as e: raise HTTPException(status_code=500, detail=f"Context aggregation failure: {e}") user_msg = req.prompt base_dir = self.active_project_root csp = filter(bool, [self.ui_global_system_prompt.strip(), self.ui_project_system_prompt.strip()]) ai_client.set_custom_system_prompt("\n\n".join(csp)) ai_client.set_base_system_prompt(self.ui_base_system_prompt) ai_client.set_use_default_base_prompt(self.ui_use_default_base_prompt) ai_client.set_project_context_marker(self.ui_project_context_marker) temp = req.temperature if req.temperature is not None else self.temperature top_p = req.top_p if req.top_p is not None else self.top_p tokens = req.max_tokens if req.max_tokens is not None else self.max_tokens ai_client.set_model_params(temp, tokens, self.history_trunc_limit, top_p) ai_client.set_agent_tools(self.ui_agent_tools) if req.auto_add_history: with self._pending_history_adds_lock: self._pending_history_adds.append({ "role": "User", "content": user_msg, "collapsed": True, "ts": project_manager.now_ts() }) try: resp = ai_client.send(stable_md, user_msg, base_dir, self.last_file_items, disc_text, rag_engine=self.rag_engine) if req.auto_add_history: with self._pending_history_adds_lock: self._pending_history_adds.append({ "role": "AI", "content": resp, "collapsed": True, "ts": project_manager.now_ts() }) self._recalculate_session_usage() duration = time.time() - start_time return { "text": resp, "metadata": { "provider": self.current_provider, "model": self.current_model, "duration_sec": round(duration, 3), "timestamp": project_manager.now_ts() }, "usage": self.session_usage } except ai_client.ProviderError as e: raise HTTPException(status_code=502, detail=f"AI Provider Error: {e.ui_message()}") except Exception as e: raise HTTPException(status_code=500, detail=f"In-flight AI request failure: {e}") @api.post("/api/v1/stream", dependencies=[Depends(get_api_key)]) async def stream(req: GenerateRequest) -> Any: """Placeholder for streaming AI generation responses (Not yet implemented).""" raise HTTPException(status_code=501, detail="Streaming endpoint (/api/v1/stream) is not yet supported in this version.") @api.get("/api/v1/pending_actions", dependencies=[Depends(get_api_key)]) def pending_actions() -> list[dict[str, Any]]: """Lists all pending PowerShell scripts awaiting confirmation.""" with self._pending_dialog_lock: return [ {"action_id": uid, "script": diag._script, "base_dir": diag._base_dir} for uid, diag in self._pending_actions.items() ] @api.post("/api/v1/confirm/{action_id}", dependencies=[Depends(get_api_key)]) def confirm_action(action_id: str, req: ConfirmRequest) -> dict[str, str]: """Approves or rejects a pending action.""" with self._pending_dialog_lock: if action_id not in self._pending_actions: raise HTTPException(status_code=404, detail="Action not found") dialog = self._pending_actions.pop(action_id) if req.script is not None: dialog._script = req.script with dialog._condition: dialog._approved = req.approved dialog._done = True dialog._condition.notify_all() return {"status": "confirmed" if req.approved else "rejected"} @api.get("/api/v1/sessions", dependencies=[Depends(get_api_key)]) def list_sessions() -> list[str]: """Lists all session IDs.""" log_dir = paths.get_logs_dir() if not log_dir.exists(): return [] return [d.name for d in log_dir.iterdir() if d.is_dir()] @api.get("/api/v1/sessions/{session_id}", dependencies=[Depends(get_api_key)]) def get_session(session_id: str) -> dict[str, Any]: """Returns the content of the comms.log for a specific session.""" log_path = paths.get_logs_dir() / session_id / "comms.log" if not log_path.exists(): raise HTTPException(status_code=404, detail="Session log not found") return {"id": session_id, "content": log_path.read_text(encoding="utf-8", errors="replace")} @api.delete("/api/v1/sessions/{session_id}", dependencies=[Depends(get_api_key)]) def delete_session(session_id: str) -> dict[str, str]: """Deletes a specific session directory.""" log_path = paths.get_logs_dir() / session_id if not log_path.exists() or not log_path.is_dir(): raise HTTPException(status_code=404, detail="Session directory not found") import shutil shutil.rmtree(log_path) return {"status": "deleted"} @api.get("/api/v1/context", dependencies=[Depends(get_api_key)]) def get_context() -> dict[str, Any]: """Returns the current aggregated project context.""" try: md, path, file_items, stable_md, disc_text = self._do_generate() # Pull current screenshots if available in project screenshots = self.project.get("screenshots", {}).get("paths", []) return { "files": [f.get("path") if isinstance(f, dict) else str(f) for f in file_items], "screenshots": screenshots, "files_base_dir": self.active_project_root, "markdown": md, "discussion": disc_text } except Exception as e: raise HTTPException(status_code=500, detail=f"Context aggregation failure: {e}") @api.get("/api/v1/token_stats", dependencies=[Depends(get_api_key)]) def token_stats() -> dict[str, Any]: """Returns current token usage and budget statistics.""" return self._token_stats return api def _cb_new_project_automated(self, user_data: Any) -> None: if user_data: name = Path(user_data).stem proj = project_manager.default_project(name) project_manager.save_project(proj, user_data) if user_data not in self.project_paths: self.project_paths.append(user_data) self._switch_project(user_data) def _cb_project_save(self) -> None: self._flush_to_project() self._flush_to_config() models.save_config(self.config) self._set_status("config saved") def _cb_reset_base_prompt(self, user_data=None) -> None: self.ui_base_system_prompt = ai_client._SYSTEM_PROMPT self.ui_use_default_base_prompt = False def _cb_clear_summary_cache(self, user_data=None) -> None: from src import summarize summarize._summary_cache.clear() self._push_mma_state_update() def _cb_show_base_prompt_diff(self, user_data=None) -> None: self._show_base_prompt_diff_modal = True def _cb_disc_create(self) -> None: nm = self.ui_disc_new_name_input.strip() if nm: self._create_discussion(nm) self.ui_disc_new_name_input = "" def _switch_project(self, path: str) -> None: if not Path(path).exists(): self._set_status(f"project file not found: {path}") return self._flush_to_project() try: self.project = project_manager.load_project(path) self.active_project_path = path new_root = Path(path).parent self.preset_manager = presets.PresetManager(new_root) self.tool_preset_manager = tool_presets.ToolPresetManager(new_root) from src.personas import PersonaManager self.persona_manager = PersonaManager(new_root) except Exception as e: self._set_status(f"failed to load project: {e}") return self._refresh_from_project() self._set_status(f"switched to: {Path(path).stem}") def _refresh_from_project(self) -> None: # Deserialize FileItems in files.paths raw_paths = self.project.get("files", {}).get("paths", []) self.files = [] for p in raw_paths: if isinstance(p, models.FileItem): self.files.append(p) elif isinstance(p, dict): self.files.append(models.FileItem.from_dict(p)) else: self.files.append(models.FileItem(path=str(p))) self.screenshots = list(self.project.get("screenshots", {}).get("paths", [])) disc_sec = self.project.get("discussion", {}) self.disc_roles = list(disc_sec.get("roles", ["User", "AI", "Vendor API", "System"])) self.active_discussion = disc_sec.get("active", "main") disc_data = disc_sec.get("discussions", {}).get(self.active_discussion, {}) with self._disc_entries_lock: self.disc_entries = models.parse_history_entries(disc_data.get("history", []), self.disc_roles) proj = self.project self.ui_output_dir = proj.get("output", {}).get("output_dir", "./md_gen") self.ui_files_base_dir = proj.get("files", {}).get("base_dir", ".") self.ui_shots_base_dir = proj.get("screenshots", {}).get("base_dir", ".") proj_meta = self.project.get("project", {}) self.ui_project_git_dir = proj_meta.get("git_dir", "") self.ui_project_system_prompt = proj_meta.get("system_prompt", "") self.ui_project_main_context = proj_meta.get("main_context", "") self.ui_project_preset_name = proj_meta.get("active_preset") self.ui_gemini_cli_path = self.project.get("gemini_cli", {}).get("binary_path", "gemini") self.ui_auto_add_history = proj.get("discussion", {}).get("auto_add", False) self.ui_auto_scroll_comms = proj.get("project", {}).get("auto_scroll_comms", True) self.ui_auto_scroll_tool_calls = proj.get("project", {}).get("auto_scroll_tool_calls", True) self.ui_word_wrap = proj.get("project", {}).get("word_wrap", True) agent_tools_cfg = proj.get("agent", {}).get("tools", {}) self.ui_agent_tools = {t: agent_tools_cfg.get(t, True) for t in models.AGENT_TOOL_NAMES} # MMA Tracks self.tracks = project_manager.get_all_tracks(self.active_project_root) # Restore MMA state mma_sec = proj.get("mma", {}) self.ui_epic_input = mma_sec.get("epic", "") tier_models = mma_sec.get("tier_models", {}) for tier, data in tier_models.items(): if tier in self.mma_tier_usage: self.mma_tier_usage[tier]["model"] = data.get("model", self.mma_tier_usage[tier]["model"]) self.mma_tier_usage[tier]["provider"] = data.get("provider", self.mma_tier_usage[tier]["provider"]) self.mma_tier_usage[tier]["tool_preset"] = data.get("tool_preset", self.mma_tier_usage[tier].get("tool_preset")) at_data = mma_sec.get("active_track") if at_data: try: tickets = [] for t_data in at_data.get("tickets", []): tickets.append(models.Ticket(**t_data)) self.active_track = models.Track( id=at_data.get("id"), description=at_data.get("description"), tickets=tickets ) self.active_tickets = at_data.get("tickets", []) # Keep dicts for UI table except Exception as e: print(f"Failed to deserialize active track: {e}") self.active_track = None else: self.active_track = None self.active_tickets = [] # Load track-scoped history if track is active if self.active_track: track_history = project_manager.load_track_history(self.active_track.id, self.active_project_root) if track_history: with self._disc_entries_lock: self.disc_entries = models.parse_history_entries(track_history, self.disc_roles) self.preset_manager.project_root = Path(self.active_project_root) self.presets = self.preset_manager.load_all() self.tool_preset_manager.project_root = Path(self.active_project_root) self.tool_presets = self.tool_preset_manager.load_all_presets() self.bias_profiles = self.tool_preset_manager.load_all_bias_profiles() if self.rag_config and self.rag_config.enabled: self._rebuild_rag_index() def _cb_save_workspace_profile(self, name: str, scope: str = 'project') -> None: if not hasattr(self, '_app') or not self._app: return profile = self._app._capture_workspace_profile(name) self.workspace_manager.save_profile(profile, scope=scope) self.workspace_profiles = self.workspace_manager.load_all_profiles() self._app.workspace_profiles = self.workspace_profiles def _cb_delete_workspace_profile(self, name: str, scope: str = 'project') -> None: self.workspace_manager.delete_profile(name, scope=scope) self.workspace_profiles = self.workspace_manager.load_all_profiles() if hasattr(self, '_app') and self._app: self._app.workspace_profiles = self.workspace_profiles def _cb_load_workspace_profile(self, name: str) -> None: if name in self.workspace_profiles: profile = self.workspace_profiles[name] if hasattr(self, '_app') and self._app: self._app._apply_workspace_profile(profile) def _apply_preset(self, name: str, scope: str) -> None: print(f"[DEBUG] _apply_preset: name={name}, scope={scope}") if name == "None": if scope == "global": self.ui_global_preset_name = "" else: self.ui_project_preset_name = "" return preset = self.presets.get(name) if not preset: print(f"[DEBUG] _apply_preset: preset {name} not found in {list(self.presets.keys())}") return if scope == "global": self.ui_global_system_prompt = preset.system_prompt self.ui_global_preset_name = name else: self.ui_project_system_prompt = preset.system_prompt self.ui_project_preset_name = name def _cb_save_preset(self, name, content, scope): print(f"[DEBUG] _cb_save_preset: name={name}, scope={scope}") if not name or not name.strip(): raise ValueError("Preset name cannot be empty or whitespace.") preset = models.Preset( name=name, system_prompt=content ) self.preset_manager.save_preset(preset, scope) self.presets = self.preset_manager.load_all() print(f"[DEBUG] _cb_save_preset: saved {name}, total presets now {len(self.presets)}") def _cb_delete_preset(self, name, scope): self.preset_manager.delete_preset(name, scope) self.presets = self.preset_manager.load_all() def _cb_save_tool_preset(self, name, categories, scope): preset = models.ToolPreset(name=name, categories=categories) self.tool_preset_manager.save_preset(preset, scope) self.tool_presets = self.tool_preset_manager.load_all_presets() def _cb_delete_tool_preset(self, name, scope): self.tool_preset_manager.delete_preset(name, scope) self.tool_presets = self.tool_preset_manager.load_all_presets() def _cb_save_bias_profile(self, profile: models.BiasProfile, scope: str = "project"): self.tool_preset_manager.save_bias_profile(profile, scope) self.bias_profiles = self.tool_preset_manager.load_all_bias_profiles() def _cb_delete_bias_profile(self, name: str, scope: str = "project"): self.tool_preset_manager.delete_bias_profile(name, scope) self.bias_profiles = self.tool_preset_manager.load_all_bias_profiles() def _cb_save_persona(self, persona: models.Persona, scope: str = "project") -> None: self.persona_manager.save_persona(persona, scope) self.personas = self.persona_manager.load_all() def _cb_delete_persona(self, name: str, scope: str = "project") -> None: self.persona_manager.delete_persona(name, scope) self.personas = self.persona_manager.load_all() def _cb_load_track(self, track_id: str) -> None: state = project_manager.load_track_state(track_id, self.active_project_root) if state: try: # Convert list[Ticket] or list[dict] to list[Ticket] for Track object tickets = [] for t in state.tasks: if isinstance(t, dict): tickets.append(models.Ticket(**t)) else: tickets.append(t) self.active_track = models.Track( id=state.metadata.id, description=state.metadata.name, tickets=tickets ) # Keep dicts for UI table self._load_active_tickets() # Load track-scoped history history = project_manager.load_track_history(track_id, self.active_project_root) with self._disc_entries_lock: if history: self.disc_entries = models.parse_history_entries(history, self.disc_roles) else: self.disc_entries = [] self._recalculate_session_usage() self._set_status(f"Loaded track: {state.metadata.name}") except Exception as e: self._set_status(f"Load track error: {e}") print(f"Error loading track {track_id}: {e}") def _save_active_project(self) -> None: if self.active_project_path: try: cleaned = project_manager.clean_nones(self.project) project_manager.save_project(cleaned, self.active_project_path) except Exception as e: self._set_status(f"save error: {e}") def _get_discussion_names(self) -> list[str]: disc_sec = self.project.get("discussion", {}) discussions = disc_sec.get("discussions", {}) return sorted(discussions.keys()) def _switch_discussion(self, name: str) -> None: self._flush_disc_entries_to_project() disc_sec = self.project.get("discussion", {}) discussions = disc_sec.get("discussions", {}) if name not in discussions: self._set_status(f"discussion not found: {name}") return self.active_discussion = name self._track_discussion_active = False disc_sec["active"] = name disc_data = discussions[name] with self._disc_entries_lock: self.disc_entries = models.parse_history_entries(disc_data.get("history", []), self.disc_roles) self._set_status(f"discussion: {name}") def _flush_disc_entries_to_project(self) -> None: history_strings = [project_manager.entry_to_str(e) for e in self.disc_entries] if self.active_track and self._track_discussion_active: project_manager.save_track_history(self.active_track.id, history_strings, self.active_project_root) return disc_sec = self.project.setdefault("discussion", {}) discussions = disc_sec.setdefault("discussions", {}) disc_data = discussions.setdefault(self.active_discussion, project_manager.default_discussion()) disc_data["history"] = history_strings disc_data["last_updated"] = project_manager.now_ts() def _create_discussion(self, name: str) -> None: disc_sec = self.project.setdefault("discussion", {}) discussions = disc_sec.setdefault("discussions", {}) if name in discussions: self._set_status(f"discussion '{name}' already exists") return discussions[name] = project_manager.default_discussion() self._switch_discussion(name) def _branch_discussion(self, index: int) -> None: self._flush_disc_entries_to_project() # Generate a unique branch name base_name = self.active_discussion.split("_take_")[0] counter = 1 new_name = f"{base_name}_take_{counter}" disc_sec = self.project.get("discussion", {}) discussions = disc_sec.get("discussions", {}) while new_name in discussions: counter += 1 new_name = f"{base_name}_take_{counter}" project_manager.branch_discussion(self.project, self.active_discussion, new_name, index) self._switch_discussion(new_name) def _rename_discussion(self, old_name: str, new_name: str) -> None: disc_sec = self.project.get("discussion", {}) discussions = disc_sec.get("discussions", {}) if old_name not in discussions: return if new_name in discussions: self._set_status(f"discussion '{new_name}' already exists") return discussions[new_name] = discussions.pop(old_name) if self.active_discussion == old_name: self.active_discussion = new_name disc_sec["active"] = new_name def _delete_discussion(self, name: str) -> None: disc_sec = self.project.get("discussion", {}) discussions = disc_sec.get("discussions", {}) if len(discussions) <= 1: self._set_status("cannot delete the last discussion") return if name not in discussions: return del discussions[name] if self.active_discussion == name: remaining = sorted(discussions.keys()) self._switch_discussion(remaining[0]) def _handle_mma_respond(self, approved: bool, payload: str | None = None, abort: bool = False, prompt: str | None = None, context_md: str | None = None) -> None: if self._pending_mma_approval: dlg = self._pending_mma_approval.get("dialog_container", [None])[0] if dlg: with dlg._condition: dlg._approved = approved if payload is not None: dlg._payload = payload dlg._done = True dlg._condition.notify_all() self._pending_mma_approval = None if self._pending_mma_spawn: spawn_dlg = self._pending_mma_spawn.get("dialog_container", [None])[0] if spawn_dlg: with spawn_dlg._condition: spawn_dlg._approved = approved spawn_dlg._abort = abort if prompt is not None: spawn_dlg._prompt = prompt if context_md is not None: spawn_dlg._context_md = context_md spawn_dlg._done = True spawn_dlg._condition.notify_all() self._pending_mma_spawn = None def _handle_approve_ask(self) -> None: """Responds with approval for a pending /api/ask request.""" if not self._ask_request_id: return request_id = self._ask_request_id def do_post() -> None: try: requests.post( "http://127.0.0.1:8999/api/ask/respond", json={"request_id": request_id, "response": {"approved": True}}, timeout=2 ) except Exception as e: print(f"Error responding to ask: {e}") threading.Thread(target=do_post, daemon=True).start() self._pending_ask_dialog = False self._ask_request_id = None self._ask_tool_data = None def _handle_reject_ask(self) -> None: """Responds with rejection for a pending /api/ask request.""" if not self._ask_request_id: return request_id = self._ask_request_id def do_post() -> None: try: requests.post( "http://127.0.0.1:8999/api/ask/respond", json={"request_id": request_id, "response": {"approved": False}}, timeout=2 ) except Exception as e: print(f"Error responding to ask: {e}") threading.Thread(target=do_post, daemon=True).start() self._pending_ask_dialog = False self._ask_request_id = None self._ask_tool_data = None def _handle_reset_session(self) -> None: """Logic for resetting the AI session and GUI state.""" ai_client.reset_session() ai_client.clear_comms_log() self._tool_log.clear() self._tool_stats.clear() self._comms_log.clear() self.disc_entries.clear() # Clear history in ALL discussions to be safe disc_sec = self.project.get("discussion", {}) discussions = disc_sec.get("discussions", {}) for d_name in discussions: discussions[d_name]["history"] = [] self._set_status("session reset") self.ai_response = "" self.ui_ai_input = "" self.ui_manual_approve = False self.ui_auto_add_history = False self._current_provider = "gemini" self._current_model = "gemini-2.5-flash-lite" ai_client.set_provider(self._current_provider, self._current_model) with self._pending_history_adds_lock: self._pending_history_adds.clear() with self._api_event_queue_lock: self._api_event_queue.clear() with self._pending_gui_tasks_lock: self._pending_gui_tasks.clear() def _handle_md_only(self) -> None: """Logic for the 'MD Only' action.""" def worker(): try: md, path, *_ = self._do_generate() self.last_md = md self.last_md_path = path self._set_status(f"md written: {path.name}") # Refresh token budget metrics with CURRENT md self._refresh_api_metrics({}, md_content=md) except Exception as e: self._set_status(f"error: {e}") threading.Thread(target=worker, daemon=True).start() def _handle_generate_send(self) -> None: """Logic for the 'Gen + Send' action.""" def worker(): sys.stderr.write("[DEBUG] _handle_generate_send worker started\n") sys.stderr.flush() try: md, path, file_items, stable_md, disc_text = self._do_generate() self._last_stable_md = stable_md self.last_md = md self.last_md_path = path self.last_file_items = file_items self._set_status("sending...") user_msg = self.ui_ai_input # RAG Retrieval if self.rag_engine and self.rag_config and self.rag_config.enabled: chunks = self.rag_engine.search(user_msg) if chunks: context_block = "## Retrieved Context\n\n" for i, chunk in enumerate(chunks): path = chunk.get("metadata", {}).get("path", "unknown") context_block += f"### Chunk {i+1} (Source: {path})\n{chunk.get('document', '')}\n\n" user_msg = context_block + user_msg symbols = parse_symbols(user_msg) file_paths = [f['path'] for f in file_items] for symbol in symbols: res = get_symbol_definition(symbol, file_paths) if res: file_path, definition, line = res user_msg += f'\n\n[Definition: {symbol} from {file_path} (line {line})]\n```python\n{definition}\n```' base_dir = self.active_project_root sys.stderr.write(f"[DEBUG] _do_generate success. Prompt: {user_msg[:50]}...\n") sys.stderr.flush() # Prepare event payload event_payload = events.UserRequestEvent( prompt=user_msg, stable_md=stable_md, file_items=file_items, disc_text=disc_text, base_dir=base_dir ) # Push to async queue self.event_queue.put("user_request", event_payload) sys.stderr.write("[DEBUG] Enqueued user_request event\n") sys.stderr.flush() except Exception as e: sys.stderr.write(f"[DEBUG] _do_generate ERROR: {e}\n{traceback.format_exc()}\n") sys.stderr.flush() self._set_status(f"generate error: {e}") threading.Thread(target=worker, daemon=True).start() def _recalculate_session_usage(self) -> None: usage = {"input_tokens": 0, "output_tokens": 0, "cache_read_input_tokens": 0, "cache_creation_input_tokens": 0, "total_tokens": 0, "last_latency": 0.0, "percentage": self.session_usage.get("percentage", 0.0)} for entry in ai_client.get_comms_log(): if entry.get("kind") == "response" and "usage" in entry.get("payload", {}): u = entry["payload"]["usage"] for k in ["input_tokens", "output_tokens", "cache_read_input_tokens", "cache_creation_input_tokens", "total_tokens"]: if k in usage: usage[k] += u.get(k, 0) or 0 self.session_usage = usage # Update cached files list stats = ai_client.get_gemini_cache_stats() self._cached_files = stats.get("cached_files", []) def _refresh_api_metrics(self, payload: dict[str, Any], md_content: str | None = None) -> None: if "latency" in payload: self.session_usage["last_latency"] = payload["latency"] if "usage" in payload and "percentage" in payload["usage"]: self.session_usage["percentage"] = payload["usage"]["percentage"] self._recalculate_session_usage() if md_content is not None: stats = ai_client.get_token_stats(md_content) # Ensure compatibility if keys are named differently if "total_tokens" in stats and "estimated_prompt_tokens" not in stats: stats["estimated_prompt_tokens"] = stats["total_tokens"] self._token_stats = stats cache_stats = payload.get("cache_stats") if cache_stats: count = cache_stats.get("cache_count", 0) size_bytes = cache_stats.get("total_size_bytes", 0) self._gemini_cache_text = f"Gemini Caches: {count} ({size_bytes / 1024:.1f} KB)" self._update_cached_stats() def _update_cached_stats(self) -> None: from src import ai_client self._cached_cache_stats = ai_client.get_gemini_cache_stats() self._cached_tool_stats = dict(self._tool_stats) def clear_cache(self) -> None: from src import ai_client ai_client.cleanup() self._update_cached_stats() def get_session_insights(self) -> Dict[str, Any]: from src import cost_tracker total_input = sum(e["input"] for e in self._token_history) total_output = sum(e["output"] for e in self._token_history) total_tokens = total_input + total_output elapsed_min = (time.time() - self._session_start_time) / 60.0 if self._token_history else 0 burn_rate = total_tokens / elapsed_min if elapsed_min > 0 else 0 session_cost = cost_tracker.estimate_cost("gemini-2.5-flash", total_input, total_output) completed = sum(1 for t in self.active_tickets if t.get("status") == "complete") efficiency = total_tokens / completed if completed > 0 else 0 return { "total_tokens": total_tokens, "total_input": total_input, "total_output": total_output, "elapsed_min": elapsed_min, "burn_rate": burn_rate, "session_cost": session_cost, "completed_tickets": completed, "efficiency": efficiency, "call_count": len(self._token_history) } def _flush_to_project(self) -> None: proj = self.project proj.setdefault("output", {})["output_dir"] = self.ui_output_dir proj.setdefault("files", {})["base_dir"] = self.ui_files_base_dir proj["files"]["paths"] = self.files proj.setdefault("screenshots", {})["base_dir"] = self.ui_shots_base_dir proj["screenshots"]["paths"] = self.screenshots proj.setdefault("project", {}) proj["project"]["git_dir"] = self.ui_project_git_dir proj.setdefault("conductor", {})["dir"] = self.ui_project_conductor_dir proj["project"]["system_prompt"] = self.ui_project_system_prompt proj["project"]["main_context"] = self.ui_project_main_context proj["project"]["active_preset"] = self.ui_project_preset_name proj["project"]["word_wrap"] = self.ui_word_wrap proj["project"]["auto_scroll_comms"] = self.ui_auto_scroll_comms proj["project"]["auto_scroll_tool_calls"] = self.ui_auto_scroll_tool_calls proj.setdefault("gemini_cli", {})["binary_path"] = self.ui_gemini_cli_path proj.setdefault("agent", {}).setdefault("tools", {}) for t_name in models.AGENT_TOOL_NAMES: proj["agent"]["tools"][t_name] = self.ui_agent_tools.get(t_name, True) self._flush_disc_entries_to_project() disc_sec = proj.setdefault("discussion", {}) disc_sec["roles"] = self.disc_roles disc_sec["active"] = self.active_discussion disc_sec["auto_add"] = self.ui_auto_add_history # Save MMA State mma_sec = proj.setdefault("mma", {}) mma_sec["epic"] = self.ui_epic_input mma_sec["tier_models"] = {t: {"model": d["model"], "provider": d.get("provider", "gemini"), "tool_preset": d.get("tool_preset")} for t, d in self.mma_tier_usage.items()} if self.active_track: mma_sec["active_track"] = asdict(self.active_track) else: mma_sec["active_track"] = None cleaned_proj = project_manager.clean_nones(proj) project_manager.save_project(cleaned_proj, self.active_project_path) def _flush_to_config(self) -> None: self.config["ai"] = { "provider": self.current_provider, "model": self.current_model, "temperature": self.temperature, "top_p": self.top_p, "max_tokens": self.max_tokens, "history_trunc_limit": self.history_trunc_limit, "active_preset": self.ui_global_preset_name, } self.config["ai"]["system_prompt"] = self.ui_global_system_prompt self.config["ai"]["base_system_prompt"] = self.ui_base_system_prompt self.config["ai"]["use_default_base_prompt"] = self.ui_use_default_base_prompt if self.rag_config: self.config["rag"] = self.rag_config.to_dict() self.config["projects"] = {"paths": self.project_paths, "active": self.active_project_path} from src import bg_shader # Update gui section while preserving other keys like bg_shader_enabled gui_cfg = self.config.get("gui", {}) gui_cfg.update({ "show_windows": self.show_windows, "separate_message_panel": getattr(self, "ui_separate_message_panel", False), "separate_response_panel": getattr(self, "ui_separate_response_panel", False), "separate_tool_calls_panel": getattr(self, "ui_separate_tool_calls_panel", False), "separate_external_tools": getattr(self, "ui_separate_external_tools", False), "separate_task_dag": self.ui_separate_task_dag, "separate_usage_analytics": self.ui_separate_usage_analytics, "separate_tier1": self.ui_separate_tier1, "separate_tier2": self.ui_separate_tier2, "separate_tier3": self.ui_separate_tier3, "separate_tier4": self.ui_separate_tier4, "bg_shader_enabled": bg_shader.get_bg().enabled }) self.config["gui"] = gui_cfg # Explicitly save theme state into the config dict theme.save_to_config(self.config) def _do_generate(self) -> tuple[str, Path, list[dict[str, Any]], str, str]: """Returns (full_md, output_path, file_items, stable_md, discussion_text).""" self._flush_to_project() self._flush_to_config() models.save_config(self.config) track_id = self.active_track.id if self.active_track else None flat = project_manager.flat_config(self.project, self.active_discussion, track_id=track_id) persona = self.personas.get(self.ui_active_persona) strategy = persona.aggregation_strategy if persona else "auto" full_md, path, file_items = aggregate.run(flat, aggregation_strategy=strategy) # Build stable markdown (no history) for Gemini caching screenshot_base_dir = Path(flat.get("screenshots", {}).get("base_dir", ".")) screenshots = flat.get("screenshots", {}).get("paths", []) summary_only = flat.get("project", {}).get("summary_only", False) stable_md = aggregate.build_markdown_no_history(file_items, screenshot_base_dir, screenshots, summary_only=summary_only, aggregation_strategy=strategy) # Build discussion history text separately history = flat.get("discussion", {}).get("history", []) discussion_text = aggregate.build_discussion_text(history) csp = filter(bool, [self.ui_global_system_prompt.strip(), self.ui_project_system_prompt.strip()]) ai_client.set_custom_system_prompt("\n\n".join(csp)) ai_client.set_base_system_prompt(self.ui_base_system_prompt) ai_client.set_use_default_base_prompt(self.ui_use_default_base_prompt) ai_client.set_project_context_marker(self.ui_project_context_marker) self.last_resolved_system_prompt = ai_client.get_combined_system_prompt() self.last_aggregate_markdown = full_md return full_md, path, file_items, stable_md, discussion_text def _cb_plan_epic(self) -> None: def _bg_task() -> None: sys.stderr.write("[DEBUG] _cb_plan_epic _bg_task started\n") sys.stderr.flush() try: self._set_status("Planning Epic (Tier 1)...") history = orchestrator_pm.get_track_history_summary() sys.stderr.write(f"[DEBUG] History summary length: {len(history)}\n") sys.stderr.flush() proj = project_manager.load_project(self.active_project_path) flat = project_manager.flat_config(self.project) file_items = aggregate.build_file_items(Path(self.active_project_root), flat.get("files", {}).get("paths", [])) _t1_baseline = len(ai_client.get_comms_log()) tracks = orchestrator_pm.generate_tracks(self.ui_epic_input, flat, file_items, history_summary=history) _t1_new = ai_client.get_comms_log()[_t1_baseline:] _t1_resp = [e for e in _t1_new if e.get("direction") == "IN" and e.get("kind") == "response"] _t1_in = sum(e.get("payload", {}).get("usage", {}).get("input_tokens", 0) for e in _t1_resp) _t1_out = sum(e.get("payload", {}).get("usage", {}).get("output_tokens", 0) for e in _t1_resp) def _push_t1_usage(i: int, o: int) -> None: self.mma_tier_usage["Tier 1"]["input"] += i self.mma_tier_usage["Tier 1"]["output"] += o with self._pending_gui_tasks_lock: self._pending_gui_tasks.append({ "action": "custom_callback", "callback": _push_t1_usage, "args": [_t1_in, _t1_out] }) self._pending_gui_tasks.append({ "action": "handle_ai_response", "payload": { "text": json.dumps(tracks, indent=2), "stream_id": "Tier 1", "status": "Epic tracks generated." } }) self._pending_gui_tasks.append({ "action": "show_track_proposal", "payload": tracks }) except Exception as e: self._set_status(f"Epic plan error: {e}") print(f"ERROR in _cb_plan_epic background task: {e}") threading.Thread(target=_bg_task, daemon=True).start() def _cb_accept_tracks(self) -> None: self._show_track_proposal_modal = False def _bg_task() -> None: sys.stderr.write("[DEBUG] _cb_accept_tracks _bg_task started\n") # Generate skeletons once self._set_status("Phase 2: Generating skeletons for all tracks...") sys.stderr.write("[DEBUG] Creating ASTParser...\n") parser = ASTParser(language="python") generated_skeletons = "" try: # Use a local copy of files to avoid concurrent modification issues files_to_scan = list(self.files) sys.stderr.write(f"[DEBUG] Scanning {len(files_to_scan)} files for skeletons...\n") for i, file_path in enumerate(files_to_scan): try: self._set_status(f"Phase 2: Scanning files ({i+1}/{len(files_to_scan)})...") abs_path = Path(self.active_project_root) / file_path if abs_path.exists() and abs_path.suffix == ".py": with open(abs_path, "r", encoding="utf-8") as f: code = f.read() generated_skeletons += f"\nFile: {file_path}\n{parser.get_skeleton(code)}\n" except Exception as e: sys.stderr.write(f"[DEBUG] Error parsing skeleton for {file_path}: {e}\n") except Exception as e: sys.stderr.write(f"[DEBUG] Error in scan loop: {e}\n") self._set_status(f"Error generating skeletons: {e}") return # Exit if skeleton generation fails sys.stderr.write("[DEBUG] Skeleton generation complete. Starting tracks...\n") # Now loop through tracks and call _start_track_logic with generated skeletons total_tracks = len(self.proposed_tracks) for i, track_data in enumerate(self.proposed_tracks): title = track_data.get("title") or track_data.get("goal", "Untitled Track") self._set_status(f"Processing track {i+1} of {total_tracks}: '{title}'...") self._start_track_logic(track_data, skeletons_str=generated_skeletons) # Pass skeletons sys.stderr.write("[DEBUG] All tracks started. Refreshing...\n") with self._pending_gui_tasks_lock: self._pending_gui_tasks.append({'action': 'refresh_from_project'}) # Ensure UI refresh after tracks are started self._set_status(f"All {total_tracks} tracks accepted and execution started.") threading.Thread(target=_bg_task, daemon=True).start() def _cb_start_track(self, user_data: Any = None) -> None: if isinstance(user_data, str): # If track_id is provided directly track_id = user_data # Ensure it's loaded as active if not self.active_track or self.active_track.id != track_id: self._cb_load_track(track_id) if self.active_track: # Use the active track object directly to start execution self._set_mma_status("running") engine = multi_agent_conductor.ConductorEngine(self.active_track, self.event_queue, auto_queue=not self.mma_step_mode) self.engine = engine flat = project_manager.flat_config(self.project, self.active_discussion, track_id=self.active_track.id) full_md, _, _ = aggregate.run(flat) threading.Thread(target=engine.run, kwargs={"md_content": full_md}, daemon=True).start() self._set_status(f"Track '{self.active_track.description}' started.") return idx = 0 if isinstance(user_data, int): idx = user_data elif isinstance(user_data, dict): idx = user_data.get("index", 0) if 0 <= idx < len(self.proposed_tracks): track_data = self.proposed_tracks[idx] title = track_data.get("title") or track_data.get("goal", "Untitled Track") threading.Thread(target=lambda: self._start_track_logic(track_data), daemon=True).start() self._set_status(f"Track '{title}' started.") def _start_track_logic(self, track_data: dict[str, Any], skeletons_str: str | None = None) -> None: try: goal = track_data.get("goal", "") title = track_data.get("title") or track_data.get("goal", "Untitled Track") self._set_status(f"Phase 2: Generating tickets for {title}...") skeletons = skeletons_str or "" # Use provided skeletons or empty self._set_status("Phase 2: Calling Tech Lead...") _t2_baseline = len(ai_client.get_comms_log()) raw_tickets = conductor_tech_lead.generate_tickets(goal, skeletons) _t2_new = ai_client.get_comms_log()[_t2_baseline:] _t2_resp = [e for e in _t2_new if e.get("direction") == "IN" and e.get("kind") == "response"] _t2_in = sum(e.get("payload", {}).get("usage", {}).get("input_tokens", 0) for e in _t2_resp) _t2_out = sum(e.get("payload", {}).get("usage", {}).get("output_tokens", 0) for e in _t2_resp) def _push_t2_usage(i: int, o: int) -> None: self.mma_tier_usage["Tier 2"]["input"] += i self.mma_tier_usage["Tier 2"]["output"] += o with self._pending_gui_tasks_lock: self._pending_gui_tasks.append({ "action": "custom_callback", "callback": _push_t2_usage, "args": [_t2_in, _t2_out] }) if not raw_tickets: self._set_status(f"Error: No tickets generated for track: {title}") print(f"Warning: No tickets generated for track: {title}") return self._set_status("Phase 2: Sorting tickets...") try: sorted_tickets_data = conductor_tech_lead.topological_sort(raw_tickets) except ValueError as e: print(f"Dependency error in track '{title}': {e}") sorted_tickets_data = raw_tickets # 3. Create Track and Ticket objects tickets = [] for t_data in sorted_tickets_data: ticket = models.Ticket( id=t_data["id"], description=t_data.get("description") or t_data.get("goal", "No description"), status=t_data.get("status", "todo"), assigned_to=t_data.get("assigned_to", "unassigned"), depends_on=t_data.get("depends_on", []), step_mode=t_data.get("step_mode", False) ) tickets.append(ticket) track_id = f"track_{uuid.uuid5(uuid.NAMESPACE_DNS, f'{self.active_project_path}_{title}').hex[:12]}" track = models.Track(id=track_id, description=title, tickets=tickets) # Initialize track state in the filesystem meta = models.Metadata(id=track_id, name=title, status="todo", created_at=datetime.now(), updated_at=datetime.now()) state = models.TrackState(metadata=meta, discussion=[], tasks=tickets) project_manager.save_track_state(track_id, state, self.active_project_root) # Add to memory and notify UI self.tracks.append({"id": track_id, "title": title, "status": "todo"}) with self._pending_gui_tasks_lock: self._pending_gui_tasks.append({'action': 'refresh_from_project'}) # 4. Initialize ConductorEngine and run loop engine = multi_agent_conductor.ConductorEngine(track, self.event_queue, auto_queue=not self.mma_step_mode) self.engine = engine # Use current full markdown context for the track execution track_id_param = track.id flat = project_manager.flat_config(self.project, self.active_discussion, track_id=track_id_param) full_md, _, _ = aggregate.run(flat) # Start the engine in a separate thread threading.Thread(target=engine.run, kwargs={"md_content": full_md}, daemon=True).start() except Exception as e: self._set_status(f"Track start error: {e}") print(f"ERROR in _start_track_logic: {e}") def _cb_ticket_retry(self, ticket_id: str) -> None: for t in self.active_tickets: if t.get('id') == ticket_id: t['status'] = 'todo' break self.event_queue.put("mma_retry", {"ticket_id": ticket_id}) def _cb_ticket_skip(self, ticket_id: str) -> None: for t in self.active_tickets: if t.get('id') == ticket_id: t['status'] = 'skipped' break self.event_queue.put("mma_skip", {"ticket_id": ticket_id}) def _spawn_worker(self, ticket_id: str, data: dict = None) -> None: """Manually initiates a sub-agent execution for a ticket.""" if self.engine: for t in self.active_track.tickets: if t.id == ticket_id: t.status = "todo" t.step_mode = False break self.engine.engine.auto_queue = True self.event_queue.put("mma_retry", {"ticket_id": ticket_id}) def kill_worker(self, worker_id: str) -> None: """Aborts a running worker.""" if self.engine: self.engine.kill_worker(worker_id) def pause_mma(self) -> None: """Pauses the global MMA loop.""" self.mma_step_mode = True if self.engine: self.engine.pause() def resume_mma(self) -> None: """Resumes the global MMA loop.""" self.mma_step_mode = False if self.engine: self.engine.resume() def inject_context(self, data: dict) -> None: """Programmatic context injection.""" file_path = data.get("file_path") if file_path: if not os.path.isabs(file_path): file_path = os.path.relpath(file_path, self.active_project_root) existing = next((f for f in self.files if (f.path if hasattr(f, "path") else str(f)) == file_path), None) if not existing: item = models.FileItem(path=file_path) self.files.append(item) self._refresh_from_project() def approve_ticket(self, ticket_id: str) -> None: """Manually approves a ticket for execution.""" if self.engine and self.engine.engine: self.engine.engine.approve_task(ticket_id) else: # Fallback if engine not running for t in self.active_tickets: if t.get('id') == ticket_id: t['status'] = 'in_progress' break self._push_mma_state_update() def mutate_dag(self, data: dict) -> None: """Modifies task dependencies.""" ticket_id = data.get("ticket_id") depends_on = data.get("depends_on") if ticket_id and depends_on is not None: for t in self.active_tickets: if t.get("id") == ticket_id: t["depends_on"] = depends_on break if self.active_track: for t in self.active_track.tickets: if t.id == ticket_id: t.depends_on = depends_on break if self.engine: from src.dag_engine import TrackDAG, ExecutionEngine self.engine.dag = TrackDAG(self.active_track.tickets) self.engine.engine = ExecutionEngine(self.engine.dag, auto_queue=self.engine.engine.auto_queue) self._push_mma_state_update() def _cb_run_conductor_setup(self) -> None: base = paths.get_conductor_dir(project_path=self.active_project_root) if not base.exists(): self.ui_conductor_setup_summary = f"Error: {base}/ directory not found." return files = list(base.glob("**/*")) files = [f for f in files if f.is_file()] summary = [f"Conductor Directory: {base.absolute()}"] summary.append(f"Total Files: {len(files)}") total_lines = 0 for f in files: try: with open(f, "r", encoding="utf-8") as fd: lines = len(fd.readlines()) total_lines += lines summary.append(f"- {f.relative_to(base)}: {lines} lines") except Exception: summary.append(f"- {f.relative_to(base)}: Error reading") summary.append(f"Total Line Count: {total_lines}") tracks_dir = base / "tracks" if tracks_dir.exists(): tracks = [d for d in tracks_dir.iterdir() if d.is_dir()] summary.append(f"Total Tracks Found: {len(tracks)}") else: summary.append("Tracks Directory: Not found") self.ui_conductor_setup_summary = "\n".join(summary) def _cb_create_track(self, name: str, desc: str, track_type: str) -> None: if not name: return date_suffix = datetime.now().strftime("%Y%m%d") track_id = f"{name.lower().replace(' ', '_')}_{date_suffix}" track_dir = paths.get_track_state_dir(track_id, project_path=self.active_project_root) track_dir.mkdir(parents=True, exist_ok=True) spec_file = track_dir / "spec.md" with open(spec_file, "w", encoding="utf-8") as f: f.write(f"# Specification: {name}\n\nType: {track_type}\n\nDescription: {desc}\n") plan_file = track_dir / "plan.md" with open(plan_file, "w", encoding="utf-8") as f: f.write(f"# Implementation Plan: {name}\n\n- [ ] Task 1: Initialize\n") meta_file = track_dir / "metadata.json" with open(meta_file, "w", encoding="utf-8") as f: json.dump({ "id": track_id, "title": name, "description": desc, "type": track_type, "status": "new", "progress": 0.0 }, f, indent=1) # Refresh tracks from disk self.tracks = project_manager.get_all_tracks(self.active_project_root) def _push_mma_state_update(self) -> None: if not self.active_track: return # Sync active_tickets (list of dicts) back to active_track.tickets (list of models.Ticket objects) self.active_track.tickets = [models.Ticket.from_dict(t) for t in self.active_tickets] # Save the state to disk existing = project_manager.load_track_state(self.active_track.id, self.active_project_root) meta = models.Metadata( id=self.active_track.id, name=self.active_track.description, status=self.mma_status, created_at=existing.metadata.created_at if existing else datetime.now(), updated_at=datetime.now() ) state = models.TrackState( metadata=meta, discussion=existing.discussion if existing else [], tasks=self.active_track.tickets ) project_manager.save_track_state(self.active_track.id, state, self.active_project_root) def _load_active_tickets(self) -> None: """Populates self.active_tickets based on the current execution mode.""" if getattr(self, "ui_project_execution_mode", "native") == "beads": from src import beads_client bclient = beads_client.BeadsClient(Path(self.active_project_root)) beads = bclient.list_beads() self.active_tickets = [] for b in beads: self.active_tickets.append({ "id": b.id, "title": b.title, "description": b.description, "status": b.status, "assigned_to": "tier3-worker", "target_file": "", "depends_on": [] }) else: if self.active_track: self.active_tickets = [asdict(t) if not isinstance(t, dict) else t for t in self.active_track.tickets] else: self.active_tickets = []