diff --git a/aggregate.py b/aggregate.py index 62f9805..5729f9a 100644 --- a/aggregate.py +++ b/aggregate.py @@ -16,7 +16,7 @@ import tomllib import re import glob from pathlib import Path, PureWindowsPath -from typing import Any +from typing import Any, cast import summarize import project_manager from file_cache import ASTParser @@ -67,9 +67,11 @@ def build_files_section(base_dir: Path, files: list[str | dict[str, Any]]) -> st sections = [] for entry_raw in files: if isinstance(entry_raw, dict): - entry = entry_raw.get("path") + entry = cast(str, entry_raw.get("path", "")) else: entry = entry_raw + if not entry or not isinstance(entry, str): + continue paths = resolve_paths(base_dir, entry) if not paths: sections.append(f"### `{entry}`\n\n```text\nERROR: no files matched: {entry}\n```") @@ -90,6 +92,8 @@ def build_files_section(base_dir: Path, files: list[str | dict[str, Any]]) -> st def build_screenshots_section(base_dir: Path, screenshots: list[str]) -> str: sections = [] for entry in screenshots: + if not entry or not isinstance(entry, str): + continue paths = resolve_paths(base_dir, entry) if not paths: sections.append(f"### `{entry}`\n\n_ERROR: no files matched: {entry}_") @@ -115,14 +119,16 @@ def build_file_items(base_dir: Path, files: list[str | dict[str, Any]]) -> list[ mtime : float (last modification time, for skip-if-unchanged optimization) tier : int | None (optional tier for context management) """ - items = [] + items: list[dict[str, Any]] = [] for entry_raw in files: if isinstance(entry_raw, dict): - entry = entry_raw.get("path") + entry = cast(str, entry_raw.get("path", "")) tier = entry_raw.get("tier") else: entry = entry_raw tier = None + if not entry or not isinstance(entry, str): + continue paths = resolve_paths(base_dir, entry) if not paths: items.append({"path": None, "entry": entry, "content": f"ERROR: no files matched: {entry}", "error": True, "mtime": 0.0, "tier": tier}) @@ -156,14 +162,15 @@ def _build_files_section_from_items(file_items: list[dict[str, Any]]) -> str: sections = [] for item in file_items: path = item.get("path") - entry = item.get("entry", "unknown") - content = item.get("content", "") + entry = cast(str, item.get("entry", "unknown")) + content = cast(str, item.get("content", "")) if path is None: sections.append(f"### `{entry}`\n\n```text\n{content}\n```") continue - suffix = path.suffix.lstrip(".") if hasattr(path, "suffix") else "text" + p = cast(Path, path) + suffix = p.suffix.lstrip(".") if hasattr(p, "suffix") else "text" lang = suffix if suffix else "text" - original = entry if "*" not in entry else str(path) + original = entry if "*" not in entry else str(p) sections.append(f"### `{original}`\n\n```{lang}\n{content}\n```") return "\n\n---\n\n".join(sections) @@ -205,15 +212,16 @@ def build_tier1_context(file_items: list[dict[str, Any]], screenshot_base_dir: P sections = [] for item in file_items: path = item.get("path") - name = path.name if path else "" + name = path.name if path and isinstance(path, Path) else "" if name in core_files or item.get("tier") == 1: # Include in full - sections.append("### `" + (item.get("entry") or str(path)) + "`\n\n" + - f"```{path.suffix.lstrip('.') if path.suffix else 'text'}\n{item.get('content', '')}\n```") + sections.append("### `" + (cast(str, item.get("entry")) or str(path)) + "`\n\n" + + f"```{path.suffix.lstrip('.') if path and isinstance(path, Path) and path.suffix else 'text'}\n{item.get('content', '')}\n```") else: # Summarize - sections.append("### `" + (item.get("entry") or str(path)) + "`\n\n" + - summarize.summarise_file(path, item.get("content", ""))) + if path and isinstance(path, Path): + sections.append("### `" + (cast(str, item.get("entry")) or str(path)) + "`\n\n" + + summarize.summarise_file(path, cast(str, item.get("content", "")))) parts.append("## Files (Tier 1 - Mixed)\n\n" + "\n\n---\n\n".join(sections)) if screenshots: parts.append("## Screenshots\n\n" + build_screenshots_section(screenshot_base_dir, screenshots)) @@ -237,20 +245,20 @@ def build_tier3_context(file_items: list[dict[str, Any]], screenshot_base_dir: P if file_items: sections = [] for item in file_items: - path = item.get("path") - entry = item.get("entry", "") + path = cast(Path, item.get("path")) + entry = cast(str, item.get("entry", "")) path_str = str(path) if path else "" # Check if this file is in focus_files (by name or path) is_focus = False for focus in focus_files: - if focus == entry or (path and focus == path.name) or focus in path_str: + if focus == entry or (path and focus == path.name) or (path_str and focus in path_str): is_focus = True break if is_focus or item.get("tier") == 3: sections.append("### `" + (entry or path_str) + "`\n\n" + f"```{path.suffix.lstrip('.') if path and path.suffix else 'text'}\n{item.get('content', '')}\n```") else: - content = item.get("content", "") + content = cast(str, item.get("content", "")) if path and path.suffix == ".py" and not item.get("error"): try: parser = ASTParser("python") @@ -260,7 +268,8 @@ def build_tier3_context(file_items: list[dict[str, Any]], screenshot_base_dir: P # Fallback to summary if AST parsing fails sections.append(f"### `{entry or path_str}`\n\n" + summarize.summarise_file(path, content)) else: - sections.append(f"### `{entry or path_str}`\n\n" + summarize.summarise_file(path, content)) + if path: + sections.append(f"### `{entry or path_str}`\n\n" + summarize.summarise_file(path, content)) parts.append("## Files (Tier 3 - Focused)\n\n" + "\n\n---\n\n".join(sections)) if screenshots: parts.append("## Screenshots\n\n" + build_screenshots_section(screenshot_base_dir, screenshots)) diff --git a/ai_client.py b/ai_client.py index 551aa85..eec3160 100644 --- a/ai_client.py +++ b/ai_client.py @@ -20,14 +20,14 @@ import datetime import hashlib import difflib import threading -import requests -from typing import Optional, Callable, Any +import requests # type: ignore[import-untyped] +from typing import Optional, Callable, Any, List, Union, cast, Iterable import os import project_manager import file_cache import mcp_client import anthropic -from gemini_cli_adapter import GeminiCliAdapter +from gemini_cli_adapter import GeminiCliAdapter as GeminiCliAdapter from google import genai from google.genai import types from events import EventEmitter @@ -55,52 +55,46 @@ def set_history_trunc_limit(val: int) -> None: global _history_trunc_limit _history_trunc_limit = val -_gemini_client: genai.Client | None = None +_gemini_client: Optional[genai.Client] = None _gemini_chat: Any = None _gemini_cache: Any = None -_gemini_cache_md_hash: int | None = None -_gemini_cache_created_at: float | None = None +_gemini_cache_md_hash: Optional[str] = None +_gemini_cache_created_at: Optional[float] = None # Gemini cache TTL in seconds. Caches are created with this TTL and # proactively rebuilt at 90% of this value to avoid stale-reference errors. _GEMINI_CACHE_TTL: int = 3600 -_anthropic_client: anthropic.Anthropic | None = None -_anthropic_history: list[dict] = [] +_anthropic_client: Optional[anthropic.Anthropic] = None +_anthropic_history: list[dict[str, Any]] = [] _anthropic_history_lock: threading.Lock = threading.Lock() _deepseek_client: Any = None -_deepseek_history: list[dict] = [] +_deepseek_history: list[dict[str, Any]] = [] _deepseek_history_lock: threading.Lock = threading.Lock() _send_lock: threading.Lock = threading.Lock() -_gemini_cli_adapter: GeminiCliAdapter | None = None +_gemini_cli_adapter: Optional[GeminiCliAdapter] = None # Injected by gui.py - called when AI wants to run a command. -# Signature: (script: str, base_dir: str) -> str | None -confirm_and_run_callback: Callable[[str, str], str | None] | None = None +confirm_and_run_callback: Optional[Callable[[str, str, Optional[Callable[[str], str]]], Optional[str]]] = None # Injected by gui.py - called whenever a comms entry is appended. -# Signature: (entry: dict) -> None -comms_log_callback: Callable[[dict[str, Any]], None] | None = None +comms_log_callback: Optional[Callable[[dict[str, Any]], None]] = None # Injected by gui.py - called whenever a tool call completes. -# Signature: (script: str, result: str) -> None -tool_log_callback: Callable[[str, str], None] | None = None +tool_log_callback: Optional[Callable[[str, str], None]] = None # Set by caller tiers before ai_client.send(); cleared in finally. -# Safe — ai_client.send() calls are serialized by the MMA engine executor. -current_tier: str | None = None +current_tier: Optional[str] = None # Increased to allow thorough code exploration before forcing a summary MAX_TOOL_ROUNDS: int = 10 # Maximum cumulative bytes of tool output allowed per send() call. -# Prevents unbounded memory growth during long tool-calling loops. _MAX_TOOL_OUTPUT_BYTES: int = 500_000 # Maximum characters per text chunk sent to Anthropic. -# Kept well under the ~200k token API limit. _ANTHROPIC_CHUNK_SIZE: int = 120_000 _SYSTEM_PROMPT: str = ( @@ -129,33 +123,32 @@ def _get_combined_system_prompt() -> str: if _custom_system_prompt.strip(): return f"{_SYSTEM_PROMPT}\n\n[USER SYSTEM PROMPT]\n{_custom_system_prompt}" return _SYSTEM_PROMPT - # ------------------------------------------------------------------ comms log -_comms_log: list[dict] = [] +_comms_log: list[dict[str, Any]] = [] COMMS_CLAMP_CHARS: int = 300 def _append_comms(direction: str, kind: str, payload: dict[str, Any]) -> None: - entry = { + entry: dict[str, Any] = { "ts": datetime.datetime.now().strftime("%H:%M:%S"), "direction": direction, "kind": kind, "provider": _provider, "model": _model, "payload": payload, - "source_tier": current_tier, # set/cleared by caller tiers; None for main-session calls + "source_tier": current_tier, } _comms_log.append(entry) if comms_log_callback is not None: comms_log_callback(entry) -def get_comms_log() -> list[dict]: +def get_comms_log() -> list[dict[str, Any]]: return list(_comms_log) def clear_comms_log() -> None: _comms_log.clear() -def _load_credentials() -> dict: +def _load_credentials() -> dict[str, Any]: cred_path = os.environ.get("SLOP_CREDENTIALS", "credentials.toml") try: with open(cred_path, "rb") as f: @@ -169,7 +162,6 @@ def _load_credentials() -> dict: f" [deepseek]\n api_key = \"your-key\"\n" f"Or set SLOP_CREDENTIALS env var to a custom path." ) - # ------------------------------------------------------------------ provider errors class ProviderError(Exception): def __init__(self, kind: str, provider: str, original: Exception) -> None: @@ -256,14 +248,12 @@ def _classify_deepseek_error(exc: Exception) -> ProviderError: if "connection" in body or "timeout" in body or "network" in body: return ProviderError("network", "deepseek", exc) return ProviderError("unknown", "deepseek", exc) - # ------------------------------------------------------------------ provider setup def set_provider(provider: str, model: str) -> None: global _provider, _model _provider = provider if provider == "gemini_cli": valid_models = _list_gemini_cli_models() - # If model is invalid or belongs to another provider (like deepseek), force default if model != "mock" and (model not in valid_models or model.startswith("deepseek")): _model = "gemini-3-flash-preview" else: @@ -273,8 +263,8 @@ def set_provider(provider: str, model: str) -> None: def get_provider() -> str: return _provider + def cleanup() -> None: - """Called on application exit to prevent orphaned caches from billing.""" global _gemini_client, _gemini_cache if _gemini_client and _gemini_cache: try: @@ -286,6 +276,7 @@ def reset_session() -> None: global _gemini_client, _gemini_chat, _gemini_cache global _gemini_cache_md_hash, _gemini_cache_created_at global _anthropic_client, _anthropic_history + global _deepseek_client, _deepseek_history global _CACHED_ANTHROPIC_TOOLS global _gemini_cli_adapter if _gemini_client and _gemini_cache: @@ -310,18 +301,17 @@ def reset_session() -> None: file_cache.reset_client() def get_gemini_cache_stats() -> dict[str, Any]: - """ - Retrieves statistics about the Gemini caches, such as count and total size. - """ _ensure_gemini_client() + if not _gemini_client: + return {"cache_count": 0, "total_size_bytes": 0} caches_iterator = _gemini_client.caches.list() caches = list(caches_iterator) - total_size_bytes = sum(c.size_bytes for c in caches) + total_size_bytes = sum(getattr(c, 'size_bytes', 0) for c in caches) return { - "cache_count": len(list(caches)), + + "cache_count": len(caches), "total_size_bytes": total_size_bytes, } - # ------------------------------------------------------------------ model listing def list_models(provider: str) -> list[str]: creds = _load_credentials() @@ -336,11 +326,6 @@ def list_models(provider: str) -> list[str]: return [] def _list_gemini_cli_models() -> list[str]: - """ - List available Gemini models for the CLI. - Since the CLI doesn't have a direct 'list models' command yet, - we return a curated list of supported models based on CLI metadata. - """ return [ "gemini-3-flash-preview", "gemini-3.1-pro-preview", @@ -351,26 +336,24 @@ def _list_gemini_cli_models() -> list[str]: ] def _list_gemini_models(api_key: str) -> list[str]: - try: client = genai.Client(api_key=api_key) - models = [] + models: list[str] = [] for m in client.models.list(): name = m.name - if name.startswith("models/"): + if name and name.startswith("models/"): name = name[len("models/"):] - if "gemini" in name.lower(): + if name and "gemini" in name.lower(): models.append(name) return sorted(models) except Exception as exc: raise _classify_gemini_error(exc) from exc def _list_anthropic_models() -> list[str]: - try: creds = _load_credentials() client = anthropic.Anthropic(api_key=creds["anthropic"]["api_key"]) - models = [] + models: list[str] = [] for m in client.models.list(): models.append(m.id) return sorted(models) @@ -378,25 +361,19 @@ def _list_anthropic_models() -> list[str]: raise _classify_anthropic_error(exc) from exc def _list_deepseek_models(api_key: str) -> list[str]: - """ - List available DeepSeek models. - """ - # For now, return the models specified in the requirements return ["deepseek-chat", "deepseek-reasoner", "deepseek-v3", "deepseek-r1"] - # ------------------------------------------------------------------ tool definition TOOL_NAME: str = "run_powershell" -_agent_tools: dict = {} +_agent_tools: dict[str, bool] = {} def set_agent_tools(tools: dict[str, bool]) -> None: global _agent_tools, _CACHED_ANTHROPIC_TOOLS _agent_tools = tools _CACHED_ANTHROPIC_TOOLS = None -def _build_anthropic_tools() -> list[dict]: - """Build the full Anthropic tools list: run_powershell + MCP file tools.""" - mcp_tools = [] +def _build_anthropic_tools() -> list[dict[str, Any]]: + mcp_tools: list[dict[str, Any]] = [] for spec in mcp_client.MCP_TOOL_SPECS: if _agent_tools.get(spec["name"], True): mcp_tools.append({ @@ -406,7 +383,7 @@ def _build_anthropic_tools() -> list[dict]: }) tools_list = mcp_tools if _agent_tools.get(TOOL_NAME, True): - powershell_tool = { + powershell_tool: dict[str, Any] = { "name": TOOL_NAME, "description": ( "Run a PowerShell script within the project base_dir. " @@ -429,24 +406,19 @@ def _build_anthropic_tools() -> list[dict]: } tools_list.append(powershell_tool) elif tools_list: - # Anthropic requires the LAST tool to have cache_control for the prefix caching to work optimally on tools tools_list[-1]["cache_control"] = {"type": "ephemeral"} return tools_list -_ANTHROPIC_TOOLS: list[dict[str, Any]] = _build_anthropic_tools() +_CACHED_ANTHROPIC_TOOLS: Optional[list[dict[str, Any]]] = None -_CACHED_ANTHROPIC_TOOLS: list[dict[str, Any]] | None = None - -def _get_anthropic_tools() -> list[dict]: - """Return the Anthropic tools list, rebuilding only once per session.""" +def _get_anthropic_tools() -> list[dict[str, Any]]: global _CACHED_ANTHROPIC_TOOLS if _CACHED_ANTHROPIC_TOOLS is None: _CACHED_ANTHROPIC_TOOLS = _build_anthropic_tools() return _CACHED_ANTHROPIC_TOOLS -def _gemini_tool_declaration() -> types.Tool | None: - declarations = [] - # MCP file tools +def _gemini_tool_declaration() -> Optional[types.Tool]: + declarations: list[types.FunctionDeclaration] = [] for spec in mcp_client.MCP_TOOL_SPECS: if not _agent_tools.get(spec["name"], True): continue @@ -467,7 +439,6 @@ def _gemini_tool_declaration() -> types.Tool | None: required=spec["parameters"].get("required", []), ), )) - # PowerShell tool if _agent_tools.get(TOOL_NAME, True): declarations.append(types.FunctionDeclaration( name=TOOL_NAME, @@ -503,33 +474,25 @@ def _run_script(script: str, base_dir: str, qa_callback: Optional[Callable[[str] return output def _truncate_tool_output(output: str) -> str: - """Truncate tool output to _history_trunc_limit chars before sending to API.""" if _history_trunc_limit > 0 and len(output) > _history_trunc_limit: return output[:_history_trunc_limit] + "\n\n... [TRUNCATED BY SYSTEM TO SAVE TOKENS.]" return output - # ------------------------------------------------------------------ dynamic file context refresh -def _reread_file_items(file_items: list[dict]) -> tuple[list[dict], list[dict]]: - """ - Re-read file_items from disk, but only files whose mtime has changed. - Returns (all_items, changed_items) — all_items is the full refreshed list, - changed_items contains only the files that were actually modified since - the last read (used to build a minimal [FILES UPDATED] block). - """ - refreshed = [] - changed = [] +def _reread_file_items(file_items: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + refreshed: list[dict[str, Any]] = [] + changed: list[dict[str, Any]] = [] for item in file_items: path = item.get("path") if path is None: refreshed.append(item) continue from pathlib import Path as _P - p = _P(path) if not isinstance(path, _P) else path + p = path if isinstance(path, _P) else _P(path) try: current_mtime = p.stat().st_mtime - prev_mtime = item.get("mtime", 0.0) + prev_mtime = cast(float, item.get("mtime", 0.0)) if current_mtime == prev_mtime: - refreshed.append(item) # unchanged — skip re-read + refreshed.append(item) continue content = p.read_text(encoding="utf-8") new_item = {**item, "old_content": item.get("content", ""), "content": content, "error": False, "mtime": current_mtime} @@ -541,14 +504,10 @@ def _reread_file_items(file_items: list[dict]) -> tuple[list[dict], list[dict]]: changed.append(err_item) return refreshed, changed -def _build_file_context_text(file_items: list[dict]) -> str: - """ - Build a compact text summary of all files from file_items, suitable for - injecting into a tool_result message so the AI sees current file contents. - """ +def _build_file_context_text(file_items: list[dict[str, Any]]) -> str: if not file_items: return "" - parts = [] + parts: list[str] = [] for item in file_items: path = item.get("path") or item.get("entry", "unknown") suffix = str(path).rsplit(".", 1)[-1] if "." in str(path) else "text" @@ -558,18 +517,14 @@ def _build_file_context_text(file_items: list[dict]) -> str: _DIFF_LINE_THRESHOLD: int = 200 -def _build_file_diff_text(changed_items: list[dict]) -> str: - """ - Build text for changed files. Small files (<= _DIFF_LINE_THRESHOLD lines) - get full content; large files get a unified diff against old_content. - """ +def _build_file_diff_text(changed_items: list[dict[str, Any]]) -> str: if not changed_items: return "" - parts = [] + parts: list[str] = [] for item in changed_items: path = item.get("path") or item.get("entry", "unknown") - content = item.get("content", "") - old_content = item.get("old_content", "") + content = cast(str, item.get("content", "")) + old_content = cast(str, item.get("old_content", "")) new_lines = content.splitlines(keepends=True) if len(new_lines) <= _DIFF_LINE_THRESHOLD or not old_content: suffix = str(path).rsplit(".", 1)[-1] if "." in str(path) else "text" @@ -583,28 +538,20 @@ def _build_file_diff_text(changed_items: list[dict]) -> str: else: parts.append(f"### `{path}` (no changes detected)") return "\n\n---\n\n".join(parts) - # ------------------------------------------------------------------ content block serialisation def _content_block_to_dict(block: Any) -> dict[str, Any]: - """ - Convert an Anthropic SDK content block object to a plain dict. - This ensures history entries are always JSON-serialisable dicts, - not opaque SDK objects that may fail on re-serialisation. - """ if isinstance(block, dict): return block if hasattr(block, "model_dump"): - return block.model_dump() + return cast(dict[str, Any], block.model_dump()) if hasattr(block, "to_dict"): - return block.to_dict() - # Fallback: manually construct based on type + return cast(dict[str, Any], block.to_dict()) block_type = getattr(block, "type", None) if block_type == "text": return {"type": "text", "text": block.text} if block_type == "tool_use": - return {"type": "tool_use", "id": block.id, "name": block.name, "input": block.input} + return {"type": "tool_use", "id": getattr(block, "id"), "name": getattr(block, "name"), "input": getattr(block, "input")} return {"type": "text", "text": str(block)} - # ------------------------------------------------------------------ gemini def _ensure_gemini_client() -> None: global _gemini_client @@ -614,33 +561,30 @@ def _ensure_gemini_client() -> None: def _get_gemini_history_list(chat: Any | None) -> list[Any]: if not chat: return [] - # google-genai SDK stores the mutable list in _history if hasattr(chat, "_history"): - return chat._history + return cast(list[Any], chat._history) if hasattr(chat, "history"): - return chat.history + return cast(list[Any], chat.history) if hasattr(chat, "get_history"): - return chat.get_history() + return cast(list[Any], chat.get_history()) return [] def _send_gemini(md_content: str, user_message: str, base_dir: str, file_items: list[dict[str, Any]] | None = None, discussion_history: str = "", - pre_tool_callback: Optional[Callable[[str], bool]] = None, + pre_tool_callback: Optional[Callable[[str, str, Optional[Callable[[str], str]]], Optional[str]]] = None, qa_callback: Optional[Callable[[str], str]] = None, enable_tools: bool = True, stream_callback: Optional[Callable[[str], None]] = None) -> str: global _gemini_chat, _gemini_cache, _gemini_cache_md_hash, _gemini_cache_created_at try: _ensure_gemini_client(); mcp_client.configure(file_items or [], [base_dir]) - # Only stable content (files + screenshots) goes in the cached system instruction. - # Discussion history is sent as conversation messages so the cache isn't invalidated every turn. sys_instr = f"{_get_combined_system_prompt()}\n\n\n{md_content}\n" td = _gemini_tool_declaration() if enable_tools else None tools_decl = [td] if td else None - # DYNAMIC CONTEXT: Check if files/context changed mid-session current_md_hash = hashlib.md5(md_content.encode()).hexdigest() old_history = None + assert _gemini_client is not None if _gemini_chat and _gemini_cache_md_hash != current_md_hash: old_history = list(_get_gemini_history_list(_gemini_chat)) if _get_gemini_history_list(_gemini_chat) else [] if _gemini_cache: @@ -650,12 +594,10 @@ def _send_gemini(md_content: str, user_message: str, base_dir: str, _gemini_cache = None _gemini_cache_created_at = None _append_comms("OUT", "request", {"message": "[CONTEXT CHANGED] Rebuilding cache and chat session..."}) - # CACHE TTL: Proactively rebuild before the cache expires server-side. - # If we don't, send_message() will reference a deleted cache and fail. if _gemini_chat and _gemini_cache and _gemini_cache_created_at: elapsed = time.time() - _gemini_cache_created_at if elapsed > _GEMINI_CACHE_TTL * 0.9: - old_history = list(_get_gemini_history_list(_gemini_chat)) if _get_gemini_history_list(_get_gemini_history_list(_gemini_chat)) else [] + old_history = list(_get_gemini_history_list(_gemini_chat)) if _get_gemini_history_list(_gemini_chat) else [] try: _gemini_client.caches.delete(name=_gemini_cache.name) except Exception as e: _append_comms("OUT", "request", {"message": f"[CACHE DELETE WARN] {e}"}) _gemini_chat = None @@ -665,30 +607,28 @@ def _send_gemini(md_content: str, user_message: str, base_dir: str, if not _gemini_chat: chat_config = types.GenerateContentConfig( system_instruction=sys_instr, - tools=tools_decl, + tools=cast(Any, tools_decl), temperature=_temperature, max_output_tokens=_max_tokens, - safety_settings=[types.SafetySetting(category="HARM_CATEGORY_DANGEROUS_CONTENT", threshold="BLOCK_ONLY_HIGH")] + safety_settings=[types.SafetySetting(category=types.HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, threshold=types.HarmBlockThreshold.BLOCK_ONLY_HIGH)] ) - # Check if context is large enough to warrant caching (min 2048 tokens usually) should_cache = False try: - count_resp = _gemini_client.models.count_tokens(model=_model, contents=[sys_instr]) - # We use a 2048 threshold to be safe across models - if count_resp.total_tokens >= 2048: - should_cache = True - else: - _append_comms("OUT", "request", {"message": f"[CACHING SKIPPED] Context too small ({count_resp.total_tokens} tokens < 2048)"}) + if _gemini_client: + count_resp = _gemini_client.models.count_tokens(model=_model, contents=[sys_instr]) + if count_resp.total_tokens and count_resp.total_tokens >= 2048: + should_cache = True + else: + _append_comms("OUT", "request", {"message": f"[CACHING SKIPPED] Context too small ({count_resp.total_tokens} tokens < 2048)"}) except Exception as e: _append_comms("OUT", "request", {"message": f"[COUNT FAILED] {e}"}) - if should_cache: + if should_cache and _gemini_client: try: - # Gemini requires 1024 (Flash) or 4096 (Pro) tokens to cache. _gemini_cache = _gemini_client.caches.create( model=_model, config=types.CreateCachedContentConfig( system_instruction=sys_instr, - tools=tools_decl, + tools=cast(Any, tools_decl), ttl=f"{_GEMINI_CACHE_TTL}s", ) ) @@ -697,29 +637,26 @@ def _send_gemini(md_content: str, user_message: str, base_dir: str, cached_content=_gemini_cache.name, temperature=_temperature, max_output_tokens=_max_tokens, - safety_settings=[types.SafetySetting(category="HARM_CATEGORY_DANGEROUS_CONTENT", threshold="BLOCK_ONLY_HIGH")] + safety_settings=[types.SafetySetting(category=types.HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, threshold=types.HarmBlockThreshold.BLOCK_ONLY_HIGH)] ) _append_comms("OUT", "request", {"message": f"[CACHE CREATED] {_gemini_cache.name}"}) except Exception as e: _gemini_cache = None _gemini_cache_created_at = None _append_comms("OUT", "request", {"message": f"[CACHE FAILED] {type(e).__name__}: {e} \u2014 falling back to inline system_instruction"}) - kwargs = {"model": _model, "config": chat_config} + kwargs: dict[str, Any] = {"model": _model, "config": chat_config} if old_history: kwargs["history"] = old_history - _gemini_chat = _gemini_client.chats.create(**kwargs) - _gemini_cache_md_hash = current_md_hash - # Inject discussion history as a user message on first chat creation - # (only when there's no old_history being restored, i.e., fresh session) - if discussion_history and not old_history: - _gemini_chat.send_message(f"[DISCUSSION HISTORY]\n\n{discussion_history}") - _append_comms("OUT", "request", {"message": f"[HISTORY INJECTED] {len(discussion_history)} chars"}) + if _gemini_client: + _gemini_chat = _gemini_client.chats.create(**kwargs) + _gemini_cache_md_hash = current_md_hash + if discussion_history and not old_history: + _gemini_chat.send_message(f"[DISCUSSION HISTORY]\n\n{discussion_history}") + _append_comms("OUT", "request", {"message": f"[HISTORY INJECTED] {len(discussion_history)} chars"}) _append_comms("OUT", "request", {"message": f"[ctx {len(md_content)} + msg {len(user_message)}]"}) payload: str | list[types.Part] = user_message all_text: list[str] = [] _cumulative_tool_bytes = 0 - # Strip stale file refreshes and truncate old tool outputs ONCE before - # entering the tool loop (not per-round \u2014 history entries don't change). if _gemini_chat and _get_gemini_history_list(_gemini_chat): for msg in _get_gemini_history_list(_gemini_chat): if msg.role == "user" and hasattr(msg, "parts"): @@ -738,7 +675,7 @@ def _send_gemini(md_content: str, user_message: str, base_dir: str, events.emit("request_start", payload={"provider": "gemini", "model": _model, "round": r_idx}) if stream_callback: resp = _gemini_chat.send_message_stream(payload) - txt_chunks = [] + txt_chunks: list[str] = [] for chunk in resp: c_txt = chunk.text if c_txt: @@ -760,14 +697,11 @@ def _send_gemini(md_content: str, user_message: str, base_dir: str, events.emit("response_received", payload={"provider": "gemini", "model": _model, "usage": usage, "round": r_idx}) reason = resp.candidates[0].finish_reason.name if resp.candidates and hasattr(resp.candidates[0], "finish_reason") else "STOP" _append_comms("IN", "response", {"round": r_idx, "stop_reason": reason, "text": txt, "tool_calls": [{"name": c.name, "args": dict(c.args)} for c in calls], "usage": usage}) - # Guard: proactively trim history when input tokens exceed 40% of limit total_in = usage.get("input_tokens", 0) if total_in > _GEMINI_MAX_INPUT_TOKENS * 0.4 and _gemini_chat and _get_gemini_history_list(_gemini_chat): hist = _get_gemini_history_list(_gemini_chat) dropped = 0 - # Drop oldest pairs (user+model) but keep at least the last 2 entries while len(hist) > 4 and total_in > _GEMINI_MAX_INPUT_TOKENS * 0.3: - # Drop in pairs (user + model) to maintain alternating roles required by Gemini saved = 0 for _ in range(2): if not hist: break @@ -788,11 +722,10 @@ def _send_gemini(md_content: str, user_message: str, base_dir: str, log: list[dict[str, Any]] = [] for i, fc in enumerate(calls): name, args = fc.name, dict(fc.args) - # Check for tool confirmation if callback is provided out = "" tool_executed = False if name == TOOL_NAME and pre_tool_callback: - scr = args.get("script", "") + scr = cast(str, args.get("script", "")) _append_comms("OUT", "tool_call", {"name": TOOL_NAME, "script": scr}) res = pre_tool_callback(scr, base_dir, qa_callback) if res is None: @@ -803,16 +736,16 @@ def _send_gemini(md_content: str, user_message: str, base_dir: str, if not tool_executed: events.emit("tool_execution", payload={"status": "started", "tool": name, "args": args, "round": r_idx}) - if name in mcp_client.TOOL_NAMES: + if name and name in mcp_client.TOOL_NAMES: _append_comms("OUT", "tool_call", {"name": name, "args": args}) if name in mcp_client.MUTATING_TOOLS and pre_tool_callback: desc = f"# MCP MUTATING TOOL: {name}\n" + "\n".join(f"# {k}: {repr(v)}" for k, v in args.items()) _res = pre_tool_callback(desc, base_dir, qa_callback) out = "USER REJECTED: tool execution cancelled" if _res is None else mcp_client.dispatch(name, args) else: - out = mcp_client.dispatch(name, args) + out = mcp_client.dispatch(cast(str, name), args) elif name == TOOL_NAME: - scr = args.get("script", "") + scr = cast(str, args.get("script", "")) _append_comms("OUT", "tool_call", {"name": TOOL_NAME, "script": scr}) out = _run_script(scr, base_dir, qa_callback) else: out = f"ERROR: unknown tool '{name}'" @@ -826,11 +759,11 @@ def _send_gemini(md_content: str, user_message: str, base_dir: str, if r_idx == MAX_TOOL_ROUNDS: out += "\n\n[SYSTEM: MAX ROUNDS. PROVIDE FINAL ANSWER.]" out = _truncate_tool_output(out) _cumulative_tool_bytes += len(out) - f_resps.append(types.Part.from_function_response(name=name, response={"output": out})) + f_resps.append(types.Part(function_response=types.FunctionResponse(name=cast(str, name), response={"output": out}))) log.append({"tool_use_id": name, "content": out}) events.emit("tool_execution", payload={"status": "completed", "tool": name, "result": out, "round": r_idx}) if _cumulative_tool_bytes > _MAX_TOOL_OUTPUT_BYTES: - f_resps.append(types.Part.from_text( + f_resps.append(types.Part(text= f"SYSTEM WARNING: Cumulative tool output exceeded {_MAX_TOOL_OUTPUT_BYTES // 1000}KB budget. Provide your final answer now." )) _append_comms("OUT", "request", {"message": f"[TOOL OUTPUT BUDGET EXCEEDED: {_cumulative_tool_bytes} bytes]"}) @@ -842,7 +775,7 @@ def _send_gemini(md_content: str, user_message: str, base_dir: str, def _send_gemini_cli(md_content: str, user_message: str, base_dir: str, file_items: list[dict[str, Any]] | None = None, discussion_history: str = "", - pre_tool_callback: Optional[Callable[[str], bool]] = None, + pre_tool_callback: Optional[Callable[[str, str, Optional[Callable[[str], str]]], Optional[str]]] = None, qa_callback: Optional[Callable[[str], str]] = None, stream_callback: Optional[Callable[[str], None]] = None) -> str: global _gemini_cli_adapter @@ -851,11 +784,9 @@ def _send_gemini_cli(md_content: str, user_message: str, base_dir: str, _gemini_cli_adapter = GeminiCliAdapter(binary_path="gemini") adapter = _gemini_cli_adapter mcp_client.configure(file_items or [], [base_dir]) - # Construct the system instruction, combining the base system prompt and the current context. sys_instr = f"{_get_combined_system_prompt()}\n\n\n{md_content}\n" safety_settings = [{'category': 'HARM_CATEGORY_DANGEROUS_CONTENT', 'threshold': 'BLOCK_ONLY_HIGH'}] - # Initial payload for the first message - payload: str | list[dict[str, Any]] = user_message + payload: Union[str, list[dict[str, Any]]] = user_message if adapter.session_id is None: if discussion_history: payload = f"[DISCUSSION HISTORY]\n\n{discussion_history}\n\n---\n\n{user_message}" @@ -866,26 +797,21 @@ def _send_gemini_cli(md_content: str, user_message: str, base_dir: str, break events.emit("request_start", payload={"provider": "gemini_cli", "model": _model, "round": r_idx}) _append_comms("OUT", "request", {"message": f"[CLI] [round {r_idx}] [msg {len(payload)}]"}) - - # If payload is tool results (list), serialize to JSON string for the CLI send_payload = payload if isinstance(payload, list): send_payload = json.dumps(payload) - - resp_data = adapter.send(send_payload, safety_settings=safety_settings, system_instruction=sys_instr, model=_model, stream_callback=stream_callback) - # Log any stderr from the CLI for transparency + resp_data = adapter.send(cast(str, send_payload), safety_settings=safety_settings, system_instruction=sys_instr, model=_model, stream_callback=stream_callback) cli_stderr = resp_data.get("stderr", "") if cli_stderr: sys.stderr.write(f"\n--- Gemini CLI stderr ---\n{cli_stderr}\n-------------------------\n") sys.stderr.flush() - txt = resp_data.get("text", "") + txt = cast(str, resp_data.get("text", "")) if txt: all_text.append(txt) - calls = resp_data.get("tool_calls", []) + calls = cast(List[dict[str, Any]], resp_data.get("tool_calls", [])) usage = adapter.last_usage or {} latency = adapter.last_latency events.emit("response_received", payload={"provider": "gemini_cli", "model": _model, "usage": usage, "latency": latency, "round": r_idx}) - # Clean up the tool calls format to match comms log expectation - log_calls = [] + log_calls: list[dict[str, Any]] = [] for c in calls: log_calls.append({"name": c.get("name"), "args": c.get("args"), "id": c.get("id")}) _append_comms("IN", "response", { @@ -895,10 +821,7 @@ def _send_gemini_cli(md_content: str, user_message: str, base_dir: str, "tool_calls": log_calls, "usage": usage }) - # If there's text and we're not done, push it to the history immediately - # so it appears as a separate entry in the GUI. if txt and calls and comms_log_callback: - # Use kind='history_add' to push a new entry into the disc_entries list comms_log_callback({ "ts": project_manager.now_ts(), "direction": "IN", @@ -912,14 +835,13 @@ def _send_gemini_cli(md_content: str, user_message: str, base_dir: str, break tool_results_for_cli: list[dict[str, Any]] = [] for i, fc in enumerate(calls): - name = fc.get("name") - args = fc.get("args", {}) - call_id = fc.get("id") - # Check for tool confirmation if callback is provided + name = cast(str, fc.get("name")) + args = cast(dict[str, Any], fc.get("args", {})) + call_id = cast(str, fc.get("id")) out = "" tool_executed = False if name == TOOL_NAME and pre_tool_callback: - scr = args.get("script", "") + scr = cast(str, args.get("script", "")) _append_comms("OUT", "tool_call", {"name": TOOL_NAME, "id": call_id, "script": scr}) res = pre_tool_callback(scr, base_dir, qa_callback) if res is None: @@ -939,7 +861,7 @@ def _send_gemini_cli(md_content: str, user_message: str, base_dir: str, else: out = mcp_client.dispatch(name, args) elif name == TOOL_NAME: - scr = args.get("script", "") + scr = cast(str, args.get("script", "")) _append_comms("OUT", "tool_call", {"name": TOOL_NAME, "id": call_id, "script": scr}) out = _run_script(scr, base_dir, qa_callback) else: @@ -963,46 +885,23 @@ def _send_gemini_cli(md_content: str, user_message: str, base_dir: str, }) _append_comms("IN", "tool_result", {"name": name, "id": call_id, "output": out}) events.emit("tool_execution", payload={"status": "completed", "tool": name, "result": out, "round": r_idx}) - # CRITICAL: Update payload for the next round payload = tool_results_for_cli if _cumulative_tool_bytes > _MAX_TOOL_OUTPUT_BYTES: _append_comms("OUT", "request", {"message": f"[TOOL OUTPUT BUDGET EXCEEDED: {_cumulative_tool_bytes} bytes]"}) - # We should ideally tell the model here, but for CLI we just append to payload - # For Gemini CLI, we send the tool results as a JSON array of messages (or similar) - # The adapter expects a string, so we'll pass the JSON string of the results. - # Return only the text from the last round, because intermediate - # text chunks were already pushed to history via comms_log_callback. final_text = all_text[-1] if all_text else "(No text returned)" return final_text except Exception as e: - # Basic error classification for CLI raise ProviderError("unknown", "gemini_cli", e) - # ------------------------------------------------------------------ anthropic history management - # Rough chars-per-token ratio. Anthropic tokeniser averages ~3.5-4 chars/token. - # We use 3.5 to be conservative (overestimate token count = safer). + _CHARS_PER_TOKEN: float = 3.5 - -# Maximum token budget for the entire prompt (system + tools + messages). -# Anthropic's limit is 200k. We leave headroom for the response + tool schemas. _ANTHROPIC_MAX_PROMPT_TOKENS: int = 180_000 - -# Gemini models have a 1M context window but we cap well below to leave headroom. -# If the model reports input tokens exceeding this, we trim old history. _GEMINI_MAX_INPUT_TOKENS: int = 900_000 - -# Marker prefix used to identify stale file-refresh injections in history _FILE_REFRESH_MARKER: str = "[FILES UPDATED" -def _estimate_message_tokens(msg: dict) -> int: - """ - Rough token estimate for a single Anthropic message dict. - Caches the result on the dict as '_est_tokens' so repeated calls - (e.g., from _trim_anthropic_history) don't re-scan unchanged messages. - Call _invalidate_token_estimate() when a message's content is modified. - """ +def _estimate_message_tokens(msg: dict[str, Any]) -> int: cached = msg.get("_est_tokens") if cached is not None: - return cached + return cast(int, cached) total_chars = 0 content = msg.get("content", "") if isinstance(content, str): @@ -1013,7 +912,6 @@ def _estimate_message_tokens(msg: dict) -> int: text = block.get("text", "") or block.get("content", "") if isinstance(text, str): total_chars += len(text) - # tool_use input inp = block.get("input") if isinstance(inp, dict): import json as _json @@ -1025,32 +923,21 @@ def _estimate_message_tokens(msg: dict) -> int: return est def _invalidate_token_estimate(msg: dict[str, Any]) -> None: - """Remove the cached token estimate so the next call recalculates.""" msg.pop("_est_tokens", None) -def _estimate_prompt_tokens(system_blocks: list[dict], history: list[dict]) -> int: - """Estimate total prompt tokens: system + tools + all history messages.""" +def _estimate_prompt_tokens(system_blocks: list[dict[str, Any]], history: list[dict[str, Any]]) -> int: total = 0 - # System blocks for block in system_blocks: - text = block.get("text", "") + text = cast(str, block.get("text", "")) total += max(1, int(len(text) / _CHARS_PER_TOKEN)) - # Tool definitions (rough fixed estimate — they're ~2k tokens for our set) total += 2500 - # History messages (uses cached estimates for unchanged messages) for msg in history: total += _estimate_message_tokens(msg) return total def _strip_stale_file_refreshes(history: list[dict[str, Any]]) -> None: - """ - Remove [FILES UPDATED ...] text blocks from all history turns EXCEPT - the very last user message. These are stale snapshots from previous - tool rounds that bloat the context without providing value. - """ if len(history) < 2: return - # Find the index of the last user message — we keep its file refresh intact last_user_idx = -1 for i in range(len(history) - 1, -1, -1): if history[i].get("role") == "user": @@ -1062,41 +949,30 @@ def _strip_stale_file_refreshes(history: list[dict[str, Any]]) -> None: content = msg.get("content") if not isinstance(content, list): continue - cleaned = [] + cleaned: list[dict[str, Any]] = [] for block in content: if isinstance(block, dict) and block.get("type") == "text": - text = block.get("text", "") + text = cast(str, block.get("text", "")) if text.startswith(_FILE_REFRESH_MARKER): - continue # drop this stale file refresh block + continue cleaned.append(block) if len(cleaned) < len(content): msg["content"] = cleaned _invalidate_token_estimate(msg) def _trim_anthropic_history(system_blocks: list[dict[str, Any]], history: list[dict[str, Any]]) -> int: - """ - Trim the Anthropic history to fit within the token budget. - Strategy: - 1. Strip stale file-refresh injections from old turns. - 2. If still over budget, drop oldest turn pairs (user + assistant). - Returns the number of messages dropped. - """ - # Phase 1: strip stale file refreshes _strip_stale_file_refreshes(history) est = _estimate_prompt_tokens(system_blocks, history) if est <= _ANTHROPIC_MAX_PROMPT_TOKENS: return 0 - # Phase 2: drop oldest turn pairs until within budget dropped = 0 while len(history) > 3 and est > _ANTHROPIC_MAX_PROMPT_TOKENS: - # Protect history[0] (original user prompt). Drop from history[1] (assistant) and history[2] (user) if history[1].get("role") == "assistant" and len(history) > 2 and history[2].get("role") == "user": removed_asst = history.pop(1) removed_user = history.pop(1) dropped += 2 est -= _estimate_message_tokens(removed_asst) est -= _estimate_message_tokens(removed_user) - # Also drop dangling tool_results if the next message is an assistant and the removed user was just tool results while len(history) > 2 and history[1].get("role") == "assistant" and history[2].get("role") == "user": content = history[2].get("content", []) if isinstance(content, list) and content and isinstance(content[0], dict) and content[0].get("type") == "tool_result": @@ -1108,18 +984,15 @@ def _trim_anthropic_history(system_blocks: list[dict[str, Any]], history: list[d else: break else: - # Edge case fallback: drop index 1 (protecting index 0) removed = history.pop(1) dropped += 1 est -= _estimate_message_tokens(removed) return dropped - # ------------------------------------------------------------------ anthropic def _ensure_anthropic_client() -> None: global _anthropic_client if _anthropic_client is None: creds = _load_credentials() - # Enable prompt caching beta _anthropic_client = anthropic.Anthropic( api_key=creds["anthropic"]["api_key"], default_headers={"anthropic-beta": "prompt-caching-2024-07-31"} @@ -1128,28 +1001,17 @@ def _ensure_anthropic_client() -> None: def _chunk_text(text: str, chunk_size: int) -> list[str]: return [text[i:i + chunk_size] for i in range(0, len(text), chunk_size)] -def _build_chunked_context_blocks(md_content: str) -> list[dict]: - """ - Split md_content into <=_ANTHROPIC_CHUNK_SIZE char chunks. - cache_control:ephemeral is placed only on the LAST block so the whole - prefix is cached as one unit. - """ +def _build_chunked_context_blocks(md_content: str) -> list[dict[str, Any]]: chunks = _chunk_text(md_content, _ANTHROPIC_CHUNK_SIZE) - blocks = [] + blocks: list[dict[str, Any]] = [] for i, chunk in enumerate(chunks): - block: dict = {"type": "text", "text": chunk} + block: dict[str, Any] = {"type": "text", "text": chunk} if i == len(chunks) - 1: block["cache_control"] = {"type": "ephemeral"} blocks.append(block) return blocks def _strip_cache_controls(history: list[dict[str, Any]]) -> None: - """ - Remove cache_control from all content blocks in message history. - Anthropic allows max 4 cache_control blocks total across system + tools + - messages. We reserve those slots for the stable system/tools prefix and - the current turn's context block, so all older history entries must be clean. - """ for msg in history: content = msg.get("content") if isinstance(content, list): @@ -1158,15 +1020,9 @@ def _strip_cache_controls(history: list[dict[str, Any]]) -> None: block.pop("cache_control", None) def _add_history_cache_breakpoint(history: list[dict[str, Any]]) -> None: - """ - Place cache_control:ephemeral on the last content block of the - second-to-last user message. This uses one of the 4 allowed Anthropic - cache breakpoints to cache the conversation prefix so the full history - isn't reprocessed on every request. - """ user_indices = [i for i, m in enumerate(history) if m.get("role") == "user"] if len(user_indices) < 2: - return # Only one user message (the current turn) — nothing stable to cache + return target_idx = user_indices[-2] content = history[target_idx].get("content") if isinstance(content, list) and content: @@ -1179,22 +1035,17 @@ def _add_history_cache_breakpoint(history: list[dict[str, Any]]) -> None: ] def _repair_anthropic_history(history: list[dict[str, Any]]) -> None: - """ - If history ends with an assistant message that contains tool_use blocks - without a following user tool_result message, append a synthetic tool_result - message so the history is valid before the next request. - """ if not history: return last = history[-1] if last.get("role") != "assistant": return content = last.get("content", []) - tool_use_ids = [] + tool_use_ids: list[str] = [] for block in content: if isinstance(block, dict): if block.get("type") == "tool_use": - tool_use_ids.append(block["id"]) + tool_use_ids.append(cast(str, block["id"])) if not tool_use_ids: return history.append({ @@ -1209,28 +1060,23 @@ def _repair_anthropic_history(history: list[dict[str, Any]]) -> None: ], }) -def _send_anthropic(md_content: str, user_message: str, base_dir: str, file_items: list[dict[str, Any]] | None = None, discussion_history: str = "", pre_tool_callback: Optional[Callable[[str], bool]] = None, qa_callback: Optional[Callable[[str], str]] = None, stream_callback: Optional[Callable[[str], None]] = None) -> str: +def _send_anthropic(md_content: str, user_message: str, base_dir: str, file_items: list[dict[str, Any]] | None = None, discussion_history: str = "", pre_tool_callback: Optional[Callable[[str, str, Optional[Callable[[str], str]]], Optional[str]]] = None, qa_callback: Optional[Callable[[str], str]] = None, stream_callback: Optional[Callable[[str], None]] = None) -> str: try: _ensure_anthropic_client() mcp_client.configure(file_items or [], [base_dir]) - # Split system into two cache breakpoints: - # 1. Stable system prompt (never changes — always a cache hit) - # 2. Dynamic file context (invalidated only when files change) stable_prompt = _get_combined_system_prompt() - stable_blocks = [{"type": "text", "text": stable_prompt, "cache_control": {"type": "ephemeral"}}] + stable_blocks: list[dict[str, Any]] = [{"type": "text", "text": stable_prompt, "cache_control": {"type": "ephemeral"}}] context_text = f"\n\n\n{md_content}\n" context_blocks = _build_chunked_context_blocks(context_text) system_blocks = stable_blocks + context_blocks - # Prepend discussion history to the first user message if this is a fresh session if discussion_history and not _anthropic_history: user_content: list[dict[str, Any]] = [{"type": "text", "text": f"[DISCUSSION HISTORY]\n\n{discussion_history}\n\n---\n\n{user_message}"}] else: user_content = [{"type": "text", "text": user_message}] - # COMPRESS HISTORY: Truncate massive tool outputs from previous turns for msg in _anthropic_history: if msg.get("role") == "user" and isinstance(msg.get("content"), list): modified = False - for block in msg["content"]: + for block in cast(List[dict[str, Any]], msg["content"]): if isinstance(block, dict) and block.get("type") == "tool_result": t_content = block.get("content", "") if _history_trunc_limit > 0 and isinstance(t_content, str) and len(t_content) > _history_trunc_limit: @@ -1241,8 +1087,6 @@ def _send_anthropic(md_content: str, user_message: str, base_dir: str, file_item _strip_cache_controls(_anthropic_history) _repair_anthropic_history(_anthropic_history) _anthropic_history.append({"role": "user", "content": user_content}) - # Use the 4th cache breakpoint to cache the conversation history prefix. - # This is placed on the second-to-last user message (the last stable one). _add_history_cache_breakpoint(_anthropic_history) n_chunks = len(system_blocks) _append_comms("OUT", "request", { @@ -1253,9 +1097,12 @@ def _send_anthropic(md_content: str, user_message: str, base_dir: str, file_item }) all_text_parts: list[str] = [] _cumulative_tool_bytes = 0 - # We allow MAX_TOOL_ROUNDS, plus 1 final loop to get the text synthesis + + def _strip_private_keys(history: list[dict[str, Any]]) -> list[dict[str, Any]]: + return [{k: v for k, v in m.items() if not k.startswith("_")} for m in history] + for round_idx in range(MAX_TOOL_ROUNDS + 2): - # Trim history to fit within token budget before each API call + response: Any = None dropped = _trim_anthropic_history(system_blocks, _anthropic_history) if dropped > 0: est_tokens = _estimate_prompt_tokens(system_blocks, _anthropic_history) @@ -1266,20 +1113,19 @@ def _send_anthropic(md_content: str, user_message: str, base_dir: str, file_item ), }) - def _strip_private_keys(history: list[dict[str, Any]]) -> list[dict[str, Any]]: - return [{k: v for k, v in m.items() if not k.startswith("_")} for m in history] events.emit("request_start", payload={"provider": "anthropic", "model": _model, "round": round_idx}) + assert _anthropic_client is not None if stream_callback: with _anthropic_client.messages.stream( model=_model, max_tokens=_max_tokens, temperature=_temperature, - system=system_blocks, - tools=_get_anthropic_tools(), - messages=_strip_private_keys(_anthropic_history), + system=cast(Iterable[anthropic.types.TextBlockParam], system_blocks), + tools=cast(Iterable[anthropic.types.ToolParam], _get_anthropic_tools()), + messages=cast(Iterable[anthropic.types.MessageParam], _strip_private_keys(_anthropic_history)), ) as stream: for event in stream: - if event.type == "content_block_delta" and event.delta.type == "text_delta": + if isinstance(event, anthropic.types.ContentBlockDeltaEvent) and event.delta.type == "text_delta": stream_callback(event.delta.text) response = stream.get_final_message() else: @@ -1287,11 +1133,10 @@ def _send_anthropic(md_content: str, user_message: str, base_dir: str, file_item model=_model, max_tokens=_max_tokens, temperature=_temperature, - system=system_blocks, - tools=_get_anthropic_tools(), - messages=_strip_private_keys(_anthropic_history), + system=cast(Iterable[anthropic.types.TextBlockParam], system_blocks), + tools=cast(Iterable[anthropic.types.ToolParam], _get_anthropic_tools()), + messages=cast(Iterable[anthropic.types.MessageParam], _strip_private_keys(_anthropic_history)), ) - # Convert SDK content block objects to plain dicts before storing in history serialised_content = [_content_block_to_dict(b) for b in response.content] _anthropic_history.append({ "role": "assistant", @@ -1301,7 +1146,7 @@ def _send_anthropic(md_content: str, user_message: str, base_dir: str, file_item if text_blocks: all_text_parts.append("\n".join(text_blocks)) tool_use_blocks = [ - {"id": b.id, "name": b.name, "input": b.input} + {"id": getattr(b, "id"), "name": getattr(b, "name"), "input": getattr(b, "input")} for b in response.content if getattr(b, "type", None) == "tool_use" ] @@ -1326,21 +1171,18 @@ def _send_anthropic(md_content: str, user_message: str, base_dir: str, file_item if response.stop_reason != "tool_use" or not tool_use_blocks: break if round_idx > MAX_TOOL_ROUNDS: - # The model ignored the MAX ROUNDS warning and kept calling tools. - # Force abort to prevent infinite loop. break tool_results: list[dict[str, Any]] = [] for block in response.content: if getattr(block, "type", None) != "tool_use": continue - b_name = getattr(block, "name", None) - b_id = getattr(block, "id", "") - b_input = getattr(block, "input", {}) - # Check for tool confirmation if callback is provided + b_name = cast(str, getattr(block, "name")) + b_id = cast(str, getattr(block, "id")) + b_input = cast(dict[str, Any], getattr(block, "input")) output = "" tool_executed = False if b_name == TOOL_NAME and pre_tool_callback: - script = b_input.get("script", "") + script = cast(str, b_input.get("script", "")) _append_comms("OUT", "tool_call", {"name": TOOL_NAME, "id": b_id, "script": script}) res = pre_tool_callback(script, base_dir, qa_callback) if res is None: @@ -1351,7 +1193,7 @@ def _send_anthropic(md_content: str, user_message: str, base_dir: str, file_item if not tool_executed: events.emit("tool_execution", payload={"status": "started", "tool": b_name, "args": b_input, "round": round_idx}) - if b_name in mcp_client.TOOL_NAMES: + if b_name and b_name in mcp_client.TOOL_NAMES: _append_comms("OUT", "tool_call", {"name": b_name, "id": b_id, "args": b_input}) if b_name in mcp_client.MUTATING_TOOLS and pre_tool_callback: desc = f"# MCP MUTATING TOOL: {b_name}\n" + "\n".join(f"# {k}: {repr(v)}" for k, v in b_input.items()) @@ -1361,7 +1203,7 @@ def _send_anthropic(md_content: str, user_message: str, base_dir: str, file_item output = mcp_client.dispatch(b_name, b_input) _append_comms("IN", "tool_result", {"name": b_name, "id": b_id, "output": output}) elif b_name == TOOL_NAME: - script = b_input.get("script", "") + script = cast(str, b_input.get("script", "")) _append_comms("OUT", "tool_call", { "name": TOOL_NAME, "id": b_id, @@ -1384,10 +1226,8 @@ def _send_anthropic(md_content: str, user_message: str, base_dir: str, file_item "content": truncated, }) if not tool_executed: - _append_comms("IN", "tool_result", {"name": b_name, "id": b_id, "output": output}) events.emit("tool_execution", payload={"status": "completed", "tool": b_name, "result": output, "round": round_idx}) else: - # For pre_tool_callback tools, we've already logged comms events.emit("tool_execution", payload={"status": "completed", "tool": b_name, "result": output, "round": round_idx}) if _cumulative_tool_bytes > _MAX_TOOL_OUTPUT_BYTES: tool_results.append({ @@ -1395,7 +1235,6 @@ def _send_anthropic(md_content: str, user_message: str, base_dir: str, file_item "text": f"SYSTEM WARNING: Cumulative tool output exceeded {_MAX_TOOL_OUTPUT_BYTES // 1000}KB budget. Provide your final answer now." }) _append_comms("OUT", "request", {"message": f"[TOOL OUTPUT BUDGET EXCEEDED: {_cumulative_tool_bytes} bytes]"}) - # Refresh file context after tool calls — only inject CHANGED files if file_items: file_items, changed = _reread_file_items(file_items) refreshed_ctx = _build_file_diff_text(changed) @@ -1403,7 +1242,7 @@ def _send_anthropic(md_content: str, user_message: str, base_dir: str, file_item tool_results.append({ "type": "text", "text": ( - "[FILES UPDATED — current contents below. " + "[FILES UPDATED \u2014 current contents below. " "Do NOT re-read these files with PowerShell.]\n\n" + refreshed_ctx ), @@ -1429,51 +1268,39 @@ def _send_anthropic(md_content: str, user_message: str, base_dir: str, file_item raise except Exception as exc: raise _classify_anthropic_error(exc) from exc - # ------------------------------------------------------------------ deepseek def _ensure_deepseek_client() -> None: global _deepseek_client if _deepseek_client is None: _load_credentials() - # Placeholder for Dedicated DeepSeek SDK instantiation - # import deepseek - # _deepseek_client = deepseek.DeepSeek(api_key=creds["deepseek"]["api_key"]) pass def _send_deepseek(md_content: str, user_message: str, base_dir: str, file_items: list[dict[str, Any]] | None = None, discussion_history: str = "", stream: bool = False, - pre_tool_callback: Optional[Callable[[str], bool]] = None, + pre_tool_callback: Optional[Callable[[str, str, Optional[Callable[[str], str]]], Optional[str]]] = None, qa_callback: Optional[Callable[[str], str]] = None, stream_callback: Optional[Callable[[str], None]] = None) -> str: - """ - Sends a message to the DeepSeek API, handling tool calls and history. - Supports streaming responses. - """ try: mcp_client.configure(file_items or [], [base_dir]) creds = _load_credentials() api_key = creds.get("deepseek", {}).get("api_key") if not api_key: raise ValueError("DeepSeek API key not found in credentials.toml") - # DeepSeek API details api_url = "https://api.deepseek.com/chat/completions" headers = { "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", } - # Build the messages for the current API call current_api_messages: list[dict[str, Any]] = [] with _deepseek_history_lock: for msg in _deepseek_history: current_api_messages.append(msg) - # Add the current user's input for this turn initial_user_message_content = user_message if discussion_history: initial_user_message_content = f"[DISCUSSION HISTORY]\n\n{discussion_history}\n\n---\n\n{user_message}" current_api_messages.append({"role": "user", "content": initial_user_message_content}) - # Construct the full request payload request_payload: dict[str, Any] = { "model": _model, "messages": current_api_messages, @@ -1481,7 +1308,6 @@ def _send_deepseek(md_content: str, user_message: str, base_dir: str, "max_tokens": _max_tokens, "stream": stream, } - # Insert system prompt at the beginning sys_msg = {"role": "system", "content": f"{_get_combined_system_prompt()}\n\n\n{md_content}\n"} request_payload["messages"].insert(0, sys_msg) all_text_parts: list[str] = [] @@ -1494,7 +1320,6 @@ def _send_deepseek(md_content: str, user_message: str, base_dir: str, response.raise_for_status() except requests.exceptions.RequestException as e: raise _classify_deepseek_error(e) from e - # Process response if stream: aggregated_content = "" aggregated_tool_calls: list[dict[str, Any]] = [] @@ -1511,31 +1336,30 @@ def _send_deepseek(md_content: str, user_message: str, base_dir: str, continue try: chunk = json.loads(chunk_str) - delta = chunk.get("choices", [{}])[0].get("delta", {}) + delta = cast(dict[str, Any], chunk.get("choices", [{}])[0].get("delta", {})) if delta.get("content"): - content_chunk = delta["content"] + content_chunk = cast(str, delta["content"]) aggregated_content += content_chunk if stream_callback: stream_callback(content_chunk) if delta.get("reasoning_content"): - aggregated_reasoning += delta["reasoning_content"] + aggregated_reasoning += cast(str, delta["reasoning_content"]) if delta.get("tool_calls"): - # Simple aggregation of tool call deltas - for tc_delta in delta["tool_calls"]: - idx = tc_delta.get("index", 0) + for tc_delta in cast(List[dict[str, Any]], delta["tool_calls"]): + idx = cast(int, tc_delta.get("index", 0)) while len(aggregated_tool_calls) <= idx: aggregated_tool_calls.append({"id": "", "type": "function", "function": {"name": "", "arguments": ""}}) target = aggregated_tool_calls[idx] if tc_delta.get("id"): - target["id"] = tc_delta["id"] + target["id"] = cast(str, tc_delta["id"]) if tc_delta.get("function", {}).get("name"): - target["function"]["name"] += tc_delta["function"]["name"] + target["function"]["name"] += cast(str, tc_delta["function"]["name"]) if tc_delta.get("function", {}).get("arguments"): - target["function"]["arguments"] += tc_delta["function"]["arguments"] + target["function"]["arguments"] += cast(str, tc_delta["function"]["arguments"]) if chunk.get("choices", [{}])[0].get("finish_reason"): - final_finish_reason = chunk["choices"][0]["finish_reason"] + final_finish_reason = cast(str, chunk["choices"][0]["finish_reason"]) if chunk.get("usage"): - current_usage = chunk["usage"] + current_usage = cast(dict[str, Any], chunk["usage"]) except json.JSONDecodeError: continue assistant_text = aggregated_content @@ -1556,12 +1380,10 @@ def _send_deepseek(md_content: str, user_message: str, base_dir: str, reasoning_content = message.get("reasoning_content", "") finish_reason = choice.get("finish_reason", "stop") usage = response_data.get("usage", {}) - # Format reasoning content if it exists thinking_tags = "" if reasoning_content: thinking_tags = f"\n{reasoning_content}\n\n" full_assistant_text = thinking_tags + assistant_text - # Update history with _deepseek_history_lock: msg_to_store: dict[str, Any] = {"role": "assistant", "content": assistant_text} if reasoning_content: @@ -1586,19 +1408,17 @@ def _send_deepseek(md_content: str, user_message: str, base_dir: str, tool_results_for_history: list[dict[str, Any]] = [] for i, tc_raw in enumerate(tool_calls_raw): tool_info = tc_raw.get("function", {}) - tool_name = tool_info.get("name") - tool_args_str = tool_info.get("arguments", "{}") - tool_id = tc_raw.get("id") + tool_name = cast(str, tool_info.get("name")) + tool_args_str = cast(str, tool_info.get("arguments", "{}")) + tool_id = cast(str, tc_raw.get("id")) try: tool_args = json.loads(tool_args_str) except: tool_args = {} - - # Check for tool confirmation if callback is provided tool_output = "" tool_executed = False if tool_name == TOOL_NAME and pre_tool_callback: - script = tool_args.get("script", "") + script = cast(str, tool_args.get("script", "")) _append_comms("OUT", "tool_call", {"name": TOOL_NAME, "id": tool_id, "script": script}) res = pre_tool_callback(script, base_dir, qa_callback) if res is None: @@ -1618,7 +1438,7 @@ def _send_deepseek(md_content: str, user_message: str, base_dir: str, else: tool_output = mcp_client.dispatch(tool_name, tool_args) elif tool_name == TOOL_NAME: - script = tool_args.get("script", "") + script = cast(str, tool_args.get("script", "")) _append_comms("OUT", "tool_call", {"name": TOOL_NAME, "id": tool_id, "script": script}) tool_output = _run_script(script, base_dir, qa_callback) else: @@ -1650,7 +1470,6 @@ def _send_deepseek(md_content: str, user_message: str, base_dir: str, with _deepseek_history_lock: for tr in tool_results_for_history: _deepseek_history.append(tr) - # Update for next round next_messages: list[dict[str, Any]] = [] with _deepseek_history_lock: for msg in _deepseek_history: @@ -1663,23 +1482,19 @@ def _send_deepseek(md_content: str, user_message: str, base_dir: str, raise _classify_deepseek_error(e) from e def run_tier4_analysis(stderr: str) -> str: - """ - Stateless Tier 4 QA analysis of an error message. - Uses gemini-2.5-flash-lite to summarize the error and suggest a fix. - """ if not stderr or not stderr.strip(): return "" try: _ensure_gemini_client() + if not _gemini_client: + return "" prompt = ( f"You are a Tier 4 QA Agent specializing in error analysis.\n" f"Analyze the following stderr output from a PowerShell command:\n\n" f"```\n{stderr}\n```\n\n" f"Provide a concise summary of the failure and suggest a fix in approximately 20 words." ) - # Use flash-lite for cost-effective stateless analysis model_name = "gemini-2.5-flash-lite" - # We don't use the chat session here to keep it stateless resp = _gemini_client.models.generate_content( model=model_name, contents=prompt, @@ -1688,50 +1503,35 @@ def run_tier4_analysis(stderr: str) -> str: max_output_tokens=150, ) ) - analysis = resp.text.strip() + analysis = resp.text.strip() if resp.text else "" return analysis except Exception as e: - # We don't want to crash the main loop if QA analysis fails return f"[QA ANALYSIS FAILED] {e}" - # ------------------------------------------------------------------ unified send - - def get_token_stats(md_content: str) -> dict[str, Any]: - """ - Returns token usage statistics for the given markdown content. - Uses the current provider's count_tokens if available, else estimates. - """ global _provider, _gemini_client, _model, _CHARS_PER_TOKEN total_tokens = 0 - - # 1. Attempt provider-specific counting if _provider == "gemini": try: _ensure_gemini_client() if _gemini_client: resp = _gemini_client.models.count_tokens(model=_model, contents=md_content) - total_tokens = resp.total_tokens + total_tokens = cast(int, resp.total_tokens) except Exception: - pass # Fallback to estimation + pass elif _provider == "gemini_cli": try: _ensure_gemini_client() if _gemini_client: resp = _gemini_client.models.count_tokens(model=_model, contents=md_content) - total_tokens = resp.total_tokens + total_tokens = cast(int, resp.total_tokens) except Exception: pass - - # 2. Fallback to estimation if total_tokens == 0: total_tokens = max(1, int(len(md_content) / _CHARS_PER_TOKEN)) - - # Budget limits limit = _GEMINI_MAX_INPUT_TOKENS if _provider in ["gemini", "gemini_cli"] else _ANTHROPIC_MAX_PROMPT_TOKENS if _provider == "deepseek": limit = 64000 - pct = (total_tokens / limit * 100) if limit > 0 else 0 stats = { "total_tokens": total_tokens, @@ -1748,15 +1548,11 @@ def send( file_items: list[dict[str, Any]] | None = None, discussion_history: str = "", stream: bool = False, - pre_tool_callback: Optional[Callable[[str], bool]] = None, + pre_tool_callback: Optional[Callable[[str, str, Optional[Callable[[str], str]]], Optional[str]]] = None, qa_callback: Optional[Callable[[str], str]] = None, enable_tools: bool = True, stream_callback: Optional[Callable[[str], None]] = None, ) -> str: - """ - Sends a prompt with the full markdown context to the current AI provider. - Returns the final text response. - """ with _send_lock: if _provider == "gemini": return _send_gemini( @@ -1794,16 +1590,10 @@ def _add_bleed_derived(d: dict[str, Any], sys_tok: int = 0, tool_tok: int = 0) - d["history_tokens"] = max(0, cur - sys_tok - tool_tok) return d -def get_history_bleed_stats(md_content: str | None = None) -> dict[str, Any]: - """ - Calculates how close the current conversation history is to the token limit. - If md_content is provided and no chat session exists, it estimates based on md_content. - """ +def get_history_bleed_stats(md_content: Optional[str] = None) -> dict[str, Any]: if _provider == "anthropic": - # For Anthropic, we have a robust estimator with _anthropic_history_lock: history_snapshot = list(_anthropic_history) - _estimate_prompt_tokens([], history_snapshot) - 2500 # subtract fixed tools sys_tok = max(1, int(len(md_content) / _CHARS_PER_TOKEN)) if md_content else 0 current_tokens = _estimate_prompt_tokens([], history_snapshot) if md_content: @@ -1821,52 +1611,51 @@ def get_history_bleed_stats(md_content: str | None = None) -> dict[str, Any]: if _gemini_chat: try: _ensure_gemini_client() - raw_history = list(_get_gemini_history_list(_gemini_chat)) - # Copy and correct roles for counting - history = [] - for c in raw_history: - # Gemini roles MUST be 'user' or 'model' - role = "model" if c.role in ["assistant", "model"] else "user" - history.append(types.Content(role=role, parts=c.parts)) - if md_content: - # Prepend context as a user part for counting - history.insert(0, types.Content(role="user", parts=[types.Part.from_text(text=md_content)])) - if not history: + if _gemini_client: + raw_history = list(_get_gemini_history_list(_gemini_chat)) + history: list[types.Content] = [] + for c in raw_history: + role = "model" if c.role in ["assistant", "model"] else "user" + history.append(types.Content(role=role, parts=c.parts)) + if md_content: + history.insert(0, types.Content(role="user", parts=[types.Part(text=md_content)])) + if not history: + return _add_bleed_derived({ + "provider": "gemini", + "limit": effective_limit, + "current": 0, + "percentage": 0, + }) + resp = _gemini_client.models.count_tokens( + model=_model, + contents=history + ) + current_tokens = cast(int, resp.total_tokens) + percentage = (current_tokens / effective_limit) * 100 if effective_limit > 0 else 0 return _add_bleed_derived({ "provider": "gemini", "limit": effective_limit, - "current": 0, - "percentage": 0, - }) - resp = _gemini_client.models.count_tokens( - model=_model, - contents=history - ) - current_tokens = resp.total_tokens - percentage = (current_tokens / effective_limit) * 100 if effective_limit > 0 else 0 - return _add_bleed_derived({ - "provider": "gemini", - "limit": effective_limit, - "current": current_tokens, - "percentage": percentage, - }, sys_tok=0, tool_tok=0) + "current": current_tokens, + "percentage": percentage, + }, sys_tok=0, tool_tok=0) except Exception: pass elif md_content: try: _ensure_gemini_client() - resp = _gemini_client.models.count_tokens( - model=_model, - contents=[types.Content(role="user", parts=[types.Part.from_text(text=md_content)])] - ) - current_tokens = resp.total_tokens - percentage = (current_tokens / effective_limit) * 100 if effective_limit > 0 else 0 - return _add_bleed_derived({ - "provider": "gemini", - "limit": effective_limit, - "current": current_tokens, - "percentage": percentage, - }) + if _gemini_client: + resp = _gemini_client.models.count_tokens( + model=_model, + contents=[types.Content(role="user", parts=[types.Part(text=md_content)])] + ) + current_tokens = cast(int, resp.total_tokens) + percentage = (current_tokens / effective_limit) * 100 if effective_limit > 0 else 0 + return _add_bleed_derived({ + "provider": "gemini", + "limit": effective_limit, + "current": current_tokens, + "percentage": percentage, + }) except Exception: pass return _add_bleed_derived({ @@ -1881,7 +1670,7 @@ def get_history_bleed_stats(md_content: str | None = None) -> dict[str, Any]: current_tokens = 0 if _gemini_cli_adapter and _gemini_cli_adapter.last_usage: u = _gemini_cli_adapter.last_usage - current_tokens = u.get("input_tokens") or u.get("input", 0) + current_tokens = cast(int, u.get("input_tokens") or u.get("input", 0)) percentage = (current_tokens / limit_tokens) * 100 if limit_tokens > 0 else 0 return _add_bleed_derived({ "provider": "gemini_cli", diff --git a/conductor/tracks/strict_static_analysis_and_typing_20260302/plan.md b/conductor/tracks/strict_static_analysis_and_typing_20260302/plan.md index f160d83..4cb6f90 100644 --- a/conductor/tracks/strict_static_analysis_and_typing_20260302/plan.md +++ b/conductor/tracks/strict_static_analysis_and_typing_20260302/plan.md @@ -22,20 +22,19 @@ - [x] SAFETY: Preserve JSON serialization compatibility. - [x] Task: Conductor - User Manual Verification 'Phase 2: Core Library' (Protocol in workflow.md) -## Phase 3: GUI God-Object Typing Resolution -- [ ] Task: Resolve `gui_2.py` Type Errors - - [ ] WHERE: `gui_2.py` - - [ ] WHAT: Type the `App` class state variables, method signatures, and ImGui integration boundaries. - - [ ] HOW: Use `type: ignore[import]` only for ImGui C-bindings if strictly necessary, but type internal state tightly. - - [ ] SAFETY: Ensure `live_gui` tests pass after typing. - - [x] PROGRESS: Initial pass completed, several critical errors resolved, baseline established. -- [ ] Task: Conductor - User Manual Verification 'Phase 3: GUI Typing' (Protocol in workflow.md) +## Phase 3: GUI God-Object Typing Resolution [checkpoint: 6ebbf40] +- [x] Task: Resolve `gui_2.py` Type Errors + - [x] WHERE: `gui_2.py` + - [x] WHAT: Type the `App` class state variables, method signatures, and ImGui integration boundaries. + - [x] HOW: Use `type: ignore[import]` only for ImGui C-bindings if strictly necessary, but type internal state tightly. + - [x] SAFETY: Ensure `live_gui` tests pass after typing. +- [x] Task: Conductor - User Manual Verification 'Phase 3: GUI Typing' (Protocol in workflow.md) -## Phase 4: CI Integration & Final Validation +## Phase 4: CI Integration & Final Validation [checkpoint: c6c2a1b] - [x] Task: Establish Pre-Commit Guardrails - [x] WHERE: `.git/hooks/pre-commit` or a `scripts/validate_types.ps1` - [x] WHAT: Create a script that runs ruff and mypy, blocking commits if they fail. - [x] HOW: Standard shell scripting. - [x] SAFETY: Ensure it works cross-platform (Windows/Linux). -- [ ] Task: Full Suite Validation & Warning Cleanup -- [ ] Task: Conductor - User Manual Verification 'Phase 4: Validation' (Protocol in workflow.md) \ No newline at end of file +- [x] Task: Full Suite Validation & Warning Cleanup +- [x] Task: Conductor - User Manual Verification 'Phase 4: Validation' (Protocol in workflow.md) \ No newline at end of file diff --git a/file_cache.py b/file_cache.py index 3043cf2..2b499d2 100644 --- a/file_cache.py +++ b/file_cache.py @@ -6,7 +6,7 @@ This file is kept so that any stale imports do not break. """ from pathlib import Path -from typing import Optional +from typing import Optional, Any, List, Tuple, Dict import tree_sitter import tree_sitter_python @@ -33,15 +33,15 @@ class ASTParser: Returns a skeleton of a Python file (preserving docstrings, stripping function bodies). """ tree = self.parse(code) - edits = [] + edits: List[Tuple[int, int, str]] = [] - def is_docstring(node): + def is_docstring(node: tree_sitter.Node) -> bool: if node.type == "expression_statement" and node.child_count > 0: if node.children[0].type == "string": return True return False - def walk(node): + def walk(node: tree_sitter.Node) -> None: if node.type == "function_definition": body = node.child_by_field_name("body") if body and body.type == "block": @@ -77,15 +77,15 @@ class ASTParser: Otherwise strips bodies but preserves docstrings. """ tree = self.parse(code) - edits = [] + edits: List[Tuple[int, int, str]] = [] - def is_docstring(node): + def is_docstring(node: tree_sitter.Node) -> bool: if node.type == "expression_statement" and node.child_count > 0: if node.children[0].type == "string": return True return False - def has_core_logic_decorator(node): + def has_core_logic_decorator(node: tree_sitter.Node) -> bool: # Check if parent is decorated_definition parent = node.parent if parent and parent.type == "decorated_definition": @@ -96,7 +96,7 @@ class ASTParser: return True return False - def has_hot_comment(func_node): + def has_hot_comment(func_node: tree_sitter.Node) -> bool: # Check all descendants of the function_definition for a [HOT] comment stack = [func_node] while stack: @@ -109,7 +109,7 @@ class ASTParser: stack.append(child) return False - def walk(node): + def walk(node: tree_sitter.Node) -> None: if node.type == "function_definition": body = node.child_by_field_name("body") if body and body.type == "block": @@ -153,5 +153,6 @@ def get_file_id(path: Path) -> Optional[str]: def evict(path: Path) -> None: pass -def list_cached() -> list[dict]: +def list_cached() -> List[Dict[str, Any]]: return [] + diff --git a/gemini_cli_adapter.py b/gemini_cli_adapter.py index 98e1163..3fb29ea 100644 --- a/gemini_cli_adapter.py +++ b/gemini_cli_adapter.py @@ -12,10 +12,10 @@ class GeminiCliAdapter: def __init__(self, binary_path: str = "gemini"): self.binary_path = binary_path self.session_id: Optional[str] = None - self.last_usage: Optional[dict] = None + self.last_usage: Optional[dict[str, Any]] = None self.last_latency: float = 0.0 - def send(self, message: str, safety_settings: list | None = None, system_instruction: str | None = None, + def send(self, message: str, safety_settings: list[Any] | None = None, system_instruction: str | None = None, model: str | None = None, stream_callback: Optional[Callable[[str], None]] = None) -> dict[str, Any]: """ Sends a message to the Gemini CLI and processes the streaming JSON output. diff --git a/gui_2.py b/gui_2.py index 354666b..68f51d4 100644 --- a/gui_2.py +++ b/gui_2.py @@ -9,7 +9,7 @@ import json import sys import os import uuid -import requests +import requests # type: ignore[import-untyped] from pathlib import Path from tkinter import filedialog, Tk from typing import Optional, Callable, Any @@ -61,21 +61,21 @@ def hide_tk_root() -> Tk: def vec4(r: float, g: float, b: float, a: float = 1.0) -> imgui.ImVec4: return imgui.ImVec4(r/255, g/255, b/255, a) -C_OUT: tuple[float, ...] = vec4(100, 200, 255) -C_IN: tuple[float, ...] = vec4(140, 255, 160) -C_REQ: tuple[float, ...] = vec4(255, 220, 100) -C_RES: tuple[float, ...] = vec4(180, 255, 180) -C_TC: tuple[float, ...] = vec4(255, 180, 80) -C_TR: tuple[float, ...] = vec4(180, 220, 255) -C_TRS: tuple[float, ...] = vec4(200, 180, 255) -C_LBL: tuple[float, ...] = vec4(180, 180, 180) -C_VAL: tuple[float, ...] = vec4(220, 220, 220) -C_KEY: tuple[float, ...] = vec4(140, 200, 255) -C_NUM: tuple[float, ...] = vec4(180, 255, 180) -C_SUB: tuple[float, ...] = vec4(220, 200, 120) +C_OUT: imgui.ImVec4 = vec4(100, 200, 255) +C_IN: imgui.ImVec4 = vec4(140, 255, 160) +C_REQ: imgui.ImVec4 = vec4(255, 220, 100) +C_RES: imgui.ImVec4 = vec4(180, 255, 180) +C_TC: imgui.ImVec4 = vec4(255, 180, 80) +C_TR: imgui.ImVec4 = vec4(180, 220, 255) +C_TRS: imgui.ImVec4 = vec4(200, 180, 255) +C_LBL: imgui.ImVec4 = vec4(180, 180, 180) +C_VAL: imgui.ImVec4 = vec4(220, 220, 220) +C_KEY: imgui.ImVec4 = vec4(140, 200, 255) +C_NUM: imgui.ImVec4 = vec4(180, 255, 180) +C_SUB: imgui.ImVec4 = vec4(220, 200, 120) -DIR_COLORS: dict[str, tuple[float, ...]] = {"OUT": C_OUT, "IN": C_IN} -KIND_COLORS: dict[str, tuple[float, ...]] = {"request": C_REQ, "response": C_RES, "tool_call": C_TC, "tool_result": C_TR, "tool_result_send": C_TRS} +DIR_COLORS: dict[str, imgui.ImVec4] = {"OUT": C_OUT, "IN": C_IN} +KIND_COLORS: dict[str, imgui.ImVec4] = {"request": C_REQ, "response": C_RES, "tool_call": C_TC, "tool_result": C_TR, "tool_result_send": C_TRS} HEAVY_KEYS: set[str] = {"message", "text", "script", "output", "content"} DISC_ROLES: list[str] = ["User", "AI", "Vendor API", "System"] @@ -173,19 +173,19 @@ class App: def __init__(self) -> None: # Initialize locks first to avoid initialization order issues - self._send_thread_lock = threading.Lock() - self._disc_entries_lock = threading.Lock() - self._pending_comms_lock = threading.Lock() - self._pending_tool_calls_lock = threading.Lock() - self._pending_history_adds_lock = threading.Lock() - self._pending_gui_tasks_lock = threading.Lock() - self._pending_dialog_lock = threading.Lock() - self._api_event_queue_lock = threading.Lock() + 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 = load_config() - self.event_queue = events.AsyncEventQueue() - self._loop = asyncio.new_event_loop() - self._loop_thread = threading.Thread(target=self._run_event_loop, daemon=True) + self.config: dict[str, Any] = load_config() + self.event_queue: events.AsyncEventQueue = events.AsyncEventQueue() + self._loop: asyncio.AbstractEventLoop = asyncio.new_event_loop() + self._loop_thread: threading.Thread = threading.Thread(target=self._run_event_loop, daemon=True) self._loop_thread.start() ai_cfg = self.config.get("ai", {}) self._current_provider: str = ai_cfg.get("provider", "gemini") @@ -208,33 +208,33 @@ class App: disc_data = disc_sec.get("discussions", {}).get(self.active_discussion, {}) with self._disc_entries_lock: self.disc_entries: list[dict[str, Any]] = _parse_history_entries(disc_data.get("history", []), self.disc_roles) - 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", ".") + self.ui_output_dir: str = self.project.get("output", {}).get("output_dir", "./md_gen") + self.ui_files_base_dir: str = self.project.get("files", {}).get("base_dir", ".") + self.ui_shots_base_dir: str = 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_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.ui_word_wrap = proj_meta.get("word_wrap", True) - self.ui_summary_only = proj_meta.get("summary_only", False) - 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_ai_input = "" - self.ui_disc_new_name_input = "" - self.ui_disc_new_role_input = "" - self.ui_epic_input = "" + self.ui_project_git_dir: str = proj_meta.get("git_dir", "") + self.ui_project_main_context: str = proj_meta.get("main_context", "") + self.ui_project_system_prompt: str = proj_meta.get("system_prompt", "") + self.ui_gemini_cli_path: str = self.project.get("gemini_cli", {}).get("binary_path", "gemini") + self.ui_word_wrap: bool = proj_meta.get("word_wrap", True) + self.ui_summary_only: bool = proj_meta.get("summary_only", False) + self.ui_auto_add_history: bool = disc_sec.get("auto_add", False) + self.ui_global_system_prompt: str = self.config.get("ai", {}).get("system_prompt", "") + 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.proposed_tracks: list[dict[str, Any]] = [] - self._show_track_proposal_modal = False - self.ui_new_track_name = "" - self.ui_new_track_desc = "" - self.ui_new_track_type = "feature" - self.ui_conductor_setup_summary = "" - self.ui_last_script_text = "" - self.ui_last_script_output = "" - self.ai_status = "idle" - self.ai_response = "" - self.last_md = "" + self._show_track_proposal_modal: bool = False + 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.ai_status: str = "idle" + self.ai_response: str = "" + self.last_md: str = "" self.last_md_path: Path | None = None self.last_file_items: list[Any] = [] self.send_thread: threading.Thread | None = None @@ -255,82 +255,82 @@ class App: "Diagnostics": False, } saved = self.config.get("gui", {}).get("show_windows", {}) - self.show_windows = {k: saved.get(k, v) for k, v in _default_windows.items()} - self.show_script_output = False - self.show_text_viewer = False - self.text_viewer_title = "" - self.text_viewer_content = "" + self.show_windows: dict[str, bool] = {k: saved.get(k, v) for k, v in _default_windows.items()} + self.show_script_output: bool = False + self.show_text_viewer: bool = False + self.text_viewer_title: str = "" + self.text_viewer_content: str = "" self._pending_dialog: ConfirmDialog | None = None - self._pending_dialog_open = False + self._pending_dialog_open: bool = False self._pending_actions: dict[str, ConfirmDialog] = {} - self._pending_ask_dialog = False - self._ask_dialog_open = False - self._ask_request_id = None - self._ask_tool_data = None - self.mma_step_mode = False + self._pending_ask_dialog: bool = False + self._ask_dialog_open: bool = False + self._ask_request_id: str | None = None + self._ask_tool_data: dict[str, Any] | None = None + self.mma_step_mode: bool = False self.active_track: Track | None = None self.active_tickets: list[dict[str, Any]] = [] self.active_tier: str | None = None self.ui_focus_agent: str | None = None - self.mma_status = "idle" + self.mma_status: str = "idle" self._pending_mma_approval: dict[str, Any] | None = None - self._mma_approval_open = False - self._mma_approval_edit_mode = False - self._mma_approval_payload = "" + self._mma_approval_open: bool = False + self._mma_approval_edit_mode: bool = False + self._mma_approval_payload: str = "" self._pending_mma_spawn: dict[str, Any] | None = None - self._mma_spawn_open = False - self._mma_spawn_edit_mode = False - self._mma_spawn_prompt = '' - self._mma_spawn_context = '' - self.mma_tier_usage = { + self._mma_spawn_open: bool = False + self._mma_spawn_edit_mode: bool = False + self._mma_spawn_prompt: str = '' + self._mma_spawn_context: str = '' + self.mma_tier_usage: dict[str, dict[str, Any]] = { "Tier 1": {"input": 0, "output": 0, "model": "gemini-3.1-pro-preview"}, "Tier 2": {"input": 0, "output": 0, "model": "gemini-3-flash-preview"}, "Tier 3": {"input": 0, "output": 0, "model": "gemini-2.5-flash-lite"}, "Tier 4": {"input": 0, "output": 0, "model": "gemini-2.5-flash-lite"}, } - self._tool_log: list[dict] = [] + self._tool_log: list[dict[str, Any]] = [] self._comms_log: list[dict[str, Any]] = [] self._pending_comms: list[dict[str, Any]] = [] - self._pending_tool_calls: list[dict] = [] + self._pending_tool_calls: list[dict[str, Any]] = [] self._pending_history_adds: list[dict[str, Any]] = [] - self._trigger_blink = False - self._is_blinking = False - self._blink_start_time = 0.0 - self._trigger_script_blink = False - self._is_script_blinking = False - self._script_blink_start_time = 0.0 - self._scroll_disc_to_bottom = False - self._scroll_comms_to_bottom = False - self._scroll_tool_calls_to_bottom = False + 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._pending_gui_tasks: list[dict[str, Any]] = [] - self.session_usage = {"input_tokens": 0, "output_tokens": 0, "cache_read_input_tokens": 0, "cache_creation_input_tokens": 0} - self._gemini_cache_text = "" + self.session_usage: dict[str, Any] = {"input_tokens": 0, "output_tokens": 0, "cache_read_input_tokens": 0, "cache_creation_input_tokens": 0, "last_latency": 0.0} + self._gemini_cache_text: str = "" self._last_stable_md: str = '' - self._token_stats: dict = {} + self._token_stats: dict[str, Any] = {} self._token_stats_dirty: bool = False self.ui_disc_truncate_pairs: int = 2 - self.ui_auto_scroll_comms = True - self.ui_auto_scroll_tool_calls = True + self.ui_auto_scroll_comms: bool = True + self.ui_auto_scroll_tool_calls: bool = True agent_tools_cfg = self.project.get("agent", {}).get("tools", {}) self.ui_agent_tools: dict[str, bool] = {t: agent_tools_cfg.get(t, True) for t in AGENT_TOOL_NAMES} self.tracks: list[dict[str, Any]] = [] - self._show_add_ticket_form = False - self.ui_new_ticket_id = "" - self.ui_new_ticket_desc = "" - self.ui_new_ticket_target = "" - self.ui_new_ticket_deps = "" - self._track_discussion_active = False + self._show_add_ticket_form: bool = False + 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._track_discussion_active: bool = False self.mma_streams: dict[str, str] = {} self._tier_stream_last_len: dict[str, int] = {} - self.is_viewing_prior_session = False + self.is_viewing_prior_session: bool = False self.prior_session_entries: list[dict[str, Any]] = [] - self.test_hooks_enabled = ("--enable-test-hooks" in sys.argv) or (os.environ.get("SLOP_TEST_HOOKS") == "1") - self.ui_manual_approve = False - self.perf_monitor = PerformanceMonitor() - self.perf_history = {"frame_time": [0.0]*100, "fps": [0.0]*100, "cpu": [0.0]*100, "input_lag": [0.0]*100} - self._perf_last_update = 0.0 - self._autosave_interval = 60.0 - self._last_autosave = time.time() + 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 + self.perf_monitor: PerformanceMonitor = PerformanceMonitor() + 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() label = self.project.get("project", {}).get("name", "") session_logger.open_session(label=label) self._prune_old_logs() @@ -856,7 +856,7 @@ class App: self._switch_discussion(remaining[0]) # ---------------------------------------------------------------- logic - def _on_comms_entry(self, entry: dict) -> None: + def _on_comms_entry(self, entry: dict[str, Any]) -> None: # sys.stderr.write(f"[DEBUG] _on_comms_entry: {entry.get('kind')} {entry.get('direction')}\n") session_logger.log_comms(entry) entry["local_ts"] = time.time() @@ -898,7 +898,7 @@ class App: 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, *args, **kwargs) -> None: + def _on_api_event(self, *args: Any, **kwargs: Any) -> None: payload = kwargs.get("payload", {}) with self._pending_gui_tasks_lock: self._pending_gui_tasks.append({"action": "refresh_api_metrics", "payload": payload}) @@ -992,16 +992,16 @@ class App: setattr(self, attr_name, value) if item == "gcli_path": if not ai_client._gemini_cli_adapter: - ai_client._gemini_cli_adapter = ai_client.GeminiCliAdapter(binary_path=value) + ai_client._gemini_cli_adapter = ai_client.GeminiCliAdapter(binary_path=str(value)) else: - ai_client._gemini_cli_adapter.binary_path = value + ai_client._gemini_cli_adapter.binary_path = str(value) elif action == "click": item = task.get("item") user_data = task.get("user_data") if item == "btn_project_new_automated": self._cb_new_project_automated(user_data) elif item == "btn_mma_load_track": - self._cb_load_track(user_data) + self._cb_load_track(str(user_data or "")) elif item in self._clickable_actions: # Check if it's a method that accepts user_data import inspect @@ -1018,7 +1018,7 @@ class App: item = task.get("listbox", task.get("item")) value = task.get("item_value", task.get("value")) if item == "disc_listbox": - self._switch_discussion(value) + self._switch_discussion(str(value or "")) elif task.get("type") == "ask": self._pending_ask_dialog = True self._ask_request_id = task.get("request_id") @@ -1037,18 +1037,18 @@ class App: elif cb in self._predefined_callbacks: self._predefined_callbacks[cb](*args) elif action == "mma_step_approval": - dlg = MMAApprovalDialog(task.get("ticket_id"), task.get("payload")) + 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 == "mma_spawn_approval": - dlg = MMASpawnApprovalDialog( - task.get("ticket_id"), - task.get("role"), - task.get("prompt"), - task.get("context_md") + 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", "") @@ -1056,7 +1056,7 @@ class App: self._mma_spawn_open = True self._mma_spawn_edit_mode = False if "dialog_container" in task: - task["dialog_container"][0] = dlg + task["dialog_container"][0] = spawn_dlg except Exception as e: print(f"Error executing GUI task: {e}") @@ -1143,7 +1143,7 @@ class App: else: print("[DEBUG] No pending spawn approval found") - def _handle_mma_respond(self, approved: bool, payload: str = None, abort: bool = False, prompt: str = None, context_md: str = None) -> None: + 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: @@ -1155,17 +1155,17 @@ class App: dlg._condition.notify_all() self._pending_mma_approval = None if self._pending_mma_spawn: - dlg = self._pending_mma_spawn.get("dialog_container", [None])[0] - if dlg: - with dlg._condition: - dlg._approved = approved - dlg._abort = abort + 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: - dlg._prompt = prompt + spawn_dlg._prompt = prompt if context_md is not None: - dlg._context_md = context_md - dlg._done = True - dlg._condition.notify_all() + 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: @@ -1173,7 +1173,7 @@ class App: if not self._ask_request_id: return request_id = self._ask_request_id - def do_post(): + def do_post() -> None: try: requests.post( "http://127.0.0.1:8999/api/ask/respond", @@ -1191,7 +1191,7 @@ class App: if not self._ask_request_id: return request_id = self._ask_request_id - def do_post(): + def do_post() -> None: try: requests.post( "http://127.0.0.1:8999/api/ask/respond", @@ -1268,7 +1268,7 @@ class App: self._loop.create_task(self._process_event_queue()) # Fallback: process queues even if GUI thread is idling/stuck - async def queue_fallback(): + async def queue_fallback() -> None: while True: try: self._process_pending_gui_tasks() @@ -1393,7 +1393,7 @@ class App: usage[k] += u.get(k, 0) or 0 self.session_usage = usage - def _refresh_api_metrics(self, payload: dict, md_content: str | None = None) -> None: + 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"] self._recalculate_session_usage() @@ -1567,7 +1567,7 @@ class App: self.config["gui"] = {"show_windows": self.show_windows} theme.save_to_config(self.config) - def _do_generate(self) -> tuple[str, Path, list, str, str]: + 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._save_active_project() @@ -1589,7 +1589,7 @@ class App: def _fetch_models(self, provider: str) -> None: self.ai_status = "fetching models..." - def do_fetch(): + def do_fetch() -> None: try: models = ai_client.list_models(provider) self.available_models = models @@ -1698,12 +1698,14 @@ class App: self._tool_log.append(tc) self._pending_tool_calls.clear() if self.show_windows.get("Context Hub", False): - exp, self.show_windows["Context Hub"] = imgui.begin("Context Hub", self.show_windows["Context Hub"]) + exp, opened = imgui.begin("Context Hub", self.show_windows["Context Hub"]) + self.show_windows["Context Hub"] = bool(opened) if exp: self._render_projects_panel() imgui.end() if self.show_windows.get("Files & Media", False): - exp, self.show_windows["Files & Media"] = imgui.begin("Files & Media", self.show_windows["Files & Media"]) + exp, opened = imgui.begin("Files & Media", self.show_windows["Files & Media"]) + self.show_windows["Files & Media"] = bool(opened) if exp: if imgui.collapsing_header("Files"): self._render_files_panel() @@ -1711,7 +1713,8 @@ class App: self._render_screenshots_panel() imgui.end() if self.show_windows.get("AI Settings", False): - exp, self.show_windows["AI Settings"] = imgui.begin("AI Settings", self.show_windows["AI Settings"]) + exp, opened = imgui.begin("AI Settings", self.show_windows["AI Settings"]) + self.show_windows["AI Settings"] = bool(opened) if exp: if imgui.collapsing_header("Provider & Model"): self._render_provider_panel() @@ -1721,34 +1724,40 @@ class App: self._render_token_budget_panel() imgui.end() if self.show_windows.get("MMA Dashboard", False): - exp, self.show_windows["MMA Dashboard"] = imgui.begin("MMA Dashboard", self.show_windows["MMA Dashboard"]) + exp, opened = imgui.begin("MMA Dashboard", self.show_windows["MMA Dashboard"]) + self.show_windows["MMA Dashboard"] = bool(opened) if exp: self._render_mma_dashboard() imgui.end() if self.show_windows.get("Tier 1: Strategy", False): - exp, self.show_windows["Tier 1: Strategy"] = imgui.begin("Tier 1: Strategy", self.show_windows["Tier 1: Strategy"]) + exp, opened = imgui.begin("Tier 1: Strategy", self.show_windows["Tier 1: Strategy"]) + self.show_windows["Tier 1: Strategy"] = bool(opened) if exp: self._render_tier_stream_panel("Tier 1", "Tier 1") imgui.end() if self.show_windows.get("Tier 2: Tech Lead", False): - exp, self.show_windows["Tier 2: Tech Lead"] = imgui.begin("Tier 2: Tech Lead", self.show_windows["Tier 2: Tech Lead"]) + exp, opened = imgui.begin("Tier 2: Tech Lead", self.show_windows["Tier 2: Tech Lead"]) + self.show_windows["Tier 2: Tech Lead"] = bool(opened) if exp: self._render_tier_stream_panel("Tier 2", "Tier 2 (Tech Lead)") imgui.end() if self.show_windows.get("Tier 3: Workers", False): - exp, self.show_windows["Tier 3: Workers"] = imgui.begin("Tier 3: Workers", self.show_windows["Tier 3: Workers"]) + exp, opened = imgui.begin("Tier 3: Workers", self.show_windows["Tier 3: Workers"]) + self.show_windows["Tier 3: Workers"] = bool(opened) if exp: self._render_tier_stream_panel("Tier 3", None) imgui.end() if self.show_windows.get("Tier 4: QA", False): - exp, self.show_windows["Tier 4: QA"] = imgui.begin("Tier 4: QA", self.show_windows["Tier 4: QA"]) + exp, opened = imgui.begin("Tier 4: QA", self.show_windows["Tier 4: QA"]) + self.show_windows["Tier 4: QA"] = bool(opened) if exp: self._render_tier_stream_panel("Tier 4", "Tier 4 (QA)") imgui.end() if self.show_windows.get("Theme", False): self._render_theme_panel() if self.show_windows.get("Discussion Hub", False): - exp, self.show_windows["Discussion Hub"] = imgui.begin("Discussion Hub", self.show_windows["Discussion Hub"]) + exp, opened = imgui.begin("Discussion Hub", self.show_windows["Discussion Hub"]) + self.show_windows["Discussion Hub"] = bool(opened) if exp: # Top part for the history imgui.begin_child("HistoryChild", size=(0, -200)) @@ -1765,7 +1774,8 @@ class App: imgui.end_tab_bar() imgui.end() if self.show_windows.get("Operations Hub", False): - exp, self.show_windows["Operations Hub"] = imgui.begin("Operations Hub", self.show_windows["Operations Hub"]) + exp, opened = imgui.begin("Operations Hub", self.show_windows["Operations Hub"]) + self.show_windows["Operations Hub"] = bool(opened) if exp: imgui.text("Focus Agent:") imgui.same_line() @@ -1794,7 +1804,8 @@ class App: if self.show_windows.get("Log Management", False): self._render_log_management() if self.show_windows["Diagnostics"]: - exp, self.show_windows["Diagnostics"] = imgui.begin("Diagnostics", self.show_windows["Diagnostics"]) + exp, opened = imgui.begin("Diagnostics", self.show_windows["Diagnostics"]) + self.show_windows["Diagnostics"] = bool(opened) if exp: now = time.time() if now - self._perf_last_update >= 0.5: @@ -1893,7 +1904,7 @@ class App: else: self._ask_dialog_open = False if imgui.begin_popup_modal("Approve Tool Execution", None, imgui.WindowFlags_.always_auto_resize)[0]: - if not self._pending_ask_dialog: + if not self._pending_ask_dialog or self._ask_tool_data is None: imgui.close_current_popup() else: tool_name = self._ask_tool_data.get("tool", "unknown") @@ -2000,7 +2011,7 @@ class App: self._is_script_blinking = True self._script_blink_start_time = time.time() try: - imgui.set_window_focus("Last Script Output") + imgui.set_window_focus("Last Script Output") # type: ignore[call-arg] except Exception: pass if self._is_script_blinking: @@ -2013,7 +2024,8 @@ class App: imgui.push_style_color(imgui.Col_.frame_bg, vec4(0, 100, 255, alpha)) imgui.push_style_color(imgui.Col_.child_bg, vec4(0, 100, 255, alpha)) imgui.set_next_window_size(imgui.ImVec2(800, 600), imgui.Cond_.first_use_ever) - expanded, self.show_script_output = imgui.begin("Last Script Output", self.show_script_output) + expanded, opened = imgui.begin("Last Script Output", self.show_script_output) + self.show_script_output = bool(opened) if expanded: imgui.text("Script:") imgui.same_line() @@ -2043,7 +2055,8 @@ class App: imgui.end() if self.show_text_viewer: imgui.set_next_window_size(imgui.ImVec2(900, 700), imgui.Cond_.first_use_ever) - expanded, self.show_text_viewer = imgui.begin(f"Text Viewer - {self.text_viewer_title}", self.show_text_viewer) + expanded, opened = imgui.begin(f"Text Viewer - {self.text_viewer_title}", self.show_text_viewer) + self.show_text_viewer = bool(opened) if expanded: if self.ui_word_wrap: imgui.begin_child("tv_wrap", imgui.ImVec2(-1, -1), False) @@ -2153,7 +2166,7 @@ class App: self._cb_plan_epic() def _cb_plan_epic(self) -> None: - def _bg_task(): + def _bg_task() -> None: try: self.ai_status = "Planning Epic (Tier 1)..." history = orchestrator_pm.get_track_history_summary() @@ -2166,7 +2179,7 @@ class App: _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, o): + 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: @@ -2194,7 +2207,7 @@ class App: def _cb_accept_tracks(self) -> None: self._show_track_proposal_modal = False - def _bg_task(): + def _bg_task() -> None: # Generate skeletons once self.ai_status = "Phase 2: Generating skeletons for all tracks..." parser = ASTParser(language="python") @@ -2374,7 +2387,8 @@ class App: imgui.end_popup() def _render_log_management(self) -> None: - exp, self.show_windows["Log Management"] = imgui.begin("Log Management", self.show_windows["Log Management"]) + exp, opened = imgui.begin("Log Management", self.show_windows["Log Management"]) + self.show_windows["Log Management"] = bool(opened) if not exp: imgui.end() return @@ -2413,19 +2427,19 @@ class App: if imgui.button(f"Unstar##{session_id}"): registry.update_session_metadata( session_id, - message_count=metadata.get("message_count"), - errors=metadata.get("errors"), - size_kb=metadata.get("size_kb"), + message_count=int(metadata.get("message_count") or 0), + errors=int(metadata.get("errors") or 0), + size_kb=int(metadata.get("size_kb") or 0), whitelisted=False, - reason=metadata.get("reason") + reason=str(metadata.get("reason") or "") ) else: if imgui.button(f"Star##{session_id}"): registry.update_session_metadata( session_id, - message_count=metadata.get("message_count"), - errors=metadata.get("errors"), - size_kb=metadata.get("size_kb"), + message_count=int(metadata.get("message_count") or 0), + errors=int(metadata.get("errors") or 0), + size_kb=int(metadata.get("size_kb") or 0), whitelisted=True, reason="Manually whitelisted" ) @@ -2867,7 +2881,7 @@ class App: self._is_blinking = True self._blink_start_time = time.time() try: - imgui.set_window_focus("Response") + imgui.set_window_focus("Response") # type: ignore[call-arg] except: pass is_blinking = False @@ -3131,7 +3145,7 @@ class App: imgui.pop_style_color() imgui.table_next_column() if imgui.button(f"Load##{track.get('id')}"): - self._cb_load_track(track.get("id")) + self._cb_load_track(str(track.get("id") or "")) imgui.end_table() # 1b. New Track Form @@ -3248,14 +3262,14 @@ class App: # 4. Task DAG Visualizer imgui.text("Task DAG") if self.active_track: - tickets_by_id = {t.get('id'): t for t in self.active_tickets} + tickets_by_id = {str(t.get('id') or ''): t for t in self.active_tickets} all_ids = set(tickets_by_id.keys()) # Build children map - children_map = {} + children_map: dict[str, list[str]] = {} for t in self.active_tickets: for dep in t.get('depends_on', []): if dep not in children_map: children_map[dep] = [] - children_map[dep].append(t.get('id')) + children_map[dep].append(str(t.get('id') or '')) # Roots are those whose depends_on elements are NOT in all_ids roots = [] for t in self.active_tickets: @@ -3263,7 +3277,7 @@ class App: has_local_dep = any(d in all_ids for d in deps) if not has_local_dep: roots.append(t) - rendered = set() + rendered: set[str] = set() for root in roots: self._render_ticket_dag_node(root, tickets_by_id, children_map, rendered) @@ -3341,7 +3355,7 @@ class App: pass imgui.end_child() - def _render_ticket_dag_node(self, ticket: Ticket, tickets_by_id: dict[str, Ticket], children_map: dict[str, list[str]], rendered: set[str]) -> None: + def _render_ticket_dag_node(self, ticket: dict[str, Any], tickets_by_id: dict[str, Any], children_map: dict[str, list[str]], rendered: set[str]) -> None: tid = ticket.get('id', '??') is_duplicate = tid in rendered if not is_duplicate: @@ -3552,8 +3566,10 @@ class App: imgui.text("Project System Prompt") ch, self.ui_project_system_prompt = imgui.input_text_multiline("##psp", self.ui_project_system_prompt, imgui.ImVec2(-1, 100)) - def _render_theme_panel(self) -> None: - exp, self.show_windows["Theme"] = imgui.begin("Theme", self.show_windows["Theme"]) + def _render_theme_panel(self) -> None: + exp, opened = imgui.begin("Theme", self.show_windows["Theme"]) + self.show_windows["Theme"] = bool(opened) + if exp: imgui.text("Palette") cp = theme.get_current_palette() diff --git a/gui_2_mypy.txt b/gui_2_mypy.txt new file mode 100644 index 0000000..c9276b4 Binary files /dev/null and b/gui_2_mypy.txt differ diff --git a/gui_2_only_errors.txt b/gui_2_only_errors.txt new file mode 100644 index 0000000..379f14d Binary files /dev/null and b/gui_2_only_errors.txt differ diff --git a/log_registry.py b/log_registry.py index ff374ee..df6a549 100644 --- a/log_registry.py +++ b/log_registry.py @@ -3,6 +3,7 @@ import tomli_w import tomllib from datetime import datetime import os +from typing import Any class LogRegistry: """ @@ -18,7 +19,7 @@ class LogRegistry: registry_path (str): The file path to the TOML registry. """ self.registry_path = registry_path - self.data = {} + self.data: dict[str, dict[str, Any]] = {} self.load_registry() def load_registry(self) -> None: @@ -56,16 +57,16 @@ class LogRegistry: """ try: # Convert datetime objects to ISO format strings for TOML serialization - data_to_save = {} + data_to_save: dict[str, Any] = {} for session_id, session_data in self.data.items(): - session_data_copy = {} + session_data_copy: dict[str, Any] = {} for k, v in session_data.items(): if v is None: continue if k == 'start_time' and isinstance(v, datetime): session_data_copy[k] = v.isoformat() elif k == 'metadata' and isinstance(v, dict): - metadata_copy = {} + metadata_copy: dict[str, Any] = {} for mk, mv in v.items(): if mv is None: continue @@ -125,11 +126,13 @@ class LogRegistry: if self.data[session_id].get('metadata') is None: self.data[session_id]['metadata'] = {} # Update fields - self.data[session_id]['metadata']['message_count'] = message_count - self.data[session_id]['metadata']['errors'] = errors - self.data[session_id]['metadata']['size_kb'] = size_kb - self.data[session_id]['metadata']['whitelisted'] = whitelisted - self.data[session_id]['metadata']['reason'] = reason + metadata = self.data[session_id].get('metadata') + if isinstance(metadata, dict): + metadata['message_count'] = message_count + metadata['errors'] = errors + metadata['size_kb'] = size_kb + metadata['whitelisted'] = whitelisted + metadata['reason'] = reason # self.data[session_id]['metadata']['timestamp'] = datetime.utcnow() # Optionally add a timestamp # Also update the top-level whitelisted flag if provided if whitelisted is not None: @@ -150,7 +153,7 @@ class LogRegistry: if session_data is None: return False # Non-existent sessions are not whitelisted # Check the top-level 'whitelisted' flag. If it's not set or False, it's not whitelisted. - return session_data.get('whitelisted', False) + return bool(session_data.get('whitelisted', False)) def update_auto_whitelist_status(self, session_id: str) -> None: """ @@ -165,14 +168,14 @@ class LogRegistry: return session_data = self.data[session_id] session_path = session_data.get('path') - if not session_path or not os.path.isdir(session_path): + if not session_path or not os.path.isdir(str(session_path)): return total_size_bytes = 0 message_count = 0 found_keywords = [] keywords_to_check = ['ERROR', 'WARNING', 'EXCEPTION'] try: - for entry in os.scandir(session_path): + for entry in os.scandir(str(session_path)): if entry.is_file(): size = entry.stat().st_size total_size_bytes += size @@ -210,7 +213,7 @@ class LogRegistry: reason=reason ) - def get_old_non_whitelisted_sessions(self, cutoff_datetime: datetime) -> list[dict]: + def get_old_non_whitelisted_sessions(self, cutoff_datetime: datetime) -> list[dict[str, Any]]: """ Retrieves a list of sessions that are older than a specific cutoff time and are not marked as whitelisted. @@ -240,3 +243,4 @@ class LogRegistry: 'start_time': start_time_raw }) return old_sessions + diff --git a/mcp_client.py b/mcp_client.py index acdd57d..2d1a5f2 100644 --- a/mcp_client.py +++ b/mcp_client.py @@ -31,8 +31,10 @@ so the AI doesn't wander outside the project workspace. from __future__ import annotations from pathlib import Path -from typing import Optional, Callable, Any +from typing import Optional, Callable, Any, cast import os +import ast +import subprocess import summarize import outline_tool import urllib.request @@ -151,7 +153,7 @@ def _resolve_and_check(raw_path: str) -> tuple[Path | None, str]: def read_file(path: str) -> str: """Return the UTF-8 content of a file, or an error string.""" p, err = _resolve_and_check(path) - if err: + if err or p is None: return err if not p.exists(): return f"ERROR: file not found: {path}" @@ -165,7 +167,7 @@ def read_file(path: str) -> str: def list_directory(path: str) -> str: """List entries in a directory. Returns a compact text table.""" p, err = _resolve_and_check(path) - if err: + if err or p is None: return err if not p.exists(): return f"ERROR: path not found: {path}" @@ -195,7 +197,7 @@ def search_files(path: str, pattern: str) -> str: pattern examples: '*.py', '**/*.toml', 'src/**/*.rs' """ p, err = _resolve_and_check(path) - if err: + if err or p is None: return err if not p.is_dir(): return f"ERROR: not a directory: {path}" @@ -226,7 +228,7 @@ def get_file_summary(path: str) -> str: For .toml: table keys. For .md: headings. Others: line count + preview. """ p, err = _resolve_and_check(path) - if err: + if err or p is None: return err if not p.exists(): return f"ERROR: file not found: {path}" @@ -245,6 +247,7 @@ def py_get_skeleton(path: str) -> str: p, err = _resolve_and_check(path) if err: return err + assert p is not None if not p.exists(): return f"ERROR: file not found: {path}" if not p.is_file() or p.suffix != ".py": @@ -264,6 +267,7 @@ def py_get_code_outline(path: str) -> str: p, err = _resolve_and_check(path) if err: return err + assert p is not None if not p.exists(): return f"ERROR: file not found: {path}" if not p.is_file(): @@ -279,12 +283,13 @@ def get_file_slice(path: str, start_line: int, end_line: int) -> str: p, err = _resolve_and_check(path) if err: return err + assert p is not None if not p.exists(): return f"ERROR: file not found: {path}" try: lines = p.read_text(encoding="utf-8").splitlines(keepends=True) - start_idx = int(start_line) - 1 - end_idx = int(end_line) + start_idx = start_line - 1 + end_idx = end_line return "".join(lines[start_idx:end_idx]) except Exception as e: return f"ERROR reading slice from '{path}': {e}" @@ -294,12 +299,13 @@ def set_file_slice(path: str, start_line: int, end_line: int, new_content: str) p, err = _resolve_and_check(path) if err: return err + assert p is not None if not p.exists(): return f"ERROR: file not found: {path}" try: lines = p.read_text(encoding="utf-8").splitlines(keepends=True) - start_idx = int(start_line) - 1 - end_idx = int(end_line) + start_idx = start_line - 1 + end_idx = end_line if new_content and not new_content.endswith("\n"): new_content += "\n" new_lines = new_content.splitlines(keepends=True) if new_content else [] @@ -309,12 +315,11 @@ def set_file_slice(path: str, start_line: int, end_line: int, new_content: str) except Exception as e: return f"ERROR updating slice in '{path}': {e}" -def _get_symbol_node(tree: Any, name: str) -> Any: +def _get_symbol_node(tree: ast.AST, name: str) -> Optional[ast.AST]: """Helper to find an AST node by name (Class, Function, or Variable). Supports dot notation.""" - import ast parts = name.split(".") - def find_in_scope(scope_node, target_name): + def find_in_scope(scope_node: Any, target_name: str) -> Optional[ast.AST]: # scope_node could be Module, ClassDef, or FunctionDef body = getattr(scope_node, "body", []) for node in body: @@ -345,6 +350,7 @@ def py_get_definition(path: str, name: str) -> str: p, err = _resolve_and_check(path) if err: return err + assert p is not None if not p.exists(): return f"ERROR: file not found: {path}" if not p.is_file(): @@ -352,14 +358,13 @@ def py_get_definition(path: str, name: str) -> str: if p.suffix != ".py": return f"ERROR: py_get_definition currently only supports .py files (unsupported: {p.suffix})" try: - import ast code = p.read_text(encoding="utf-8").lstrip(chr(0xFEFF)) lines = code.splitlines(keepends=True) tree = ast.parse(code) node = _get_symbol_node(tree, name) if node: - start = getattr(node, "lineno") - 1 - end = getattr(node, "end_lineno") + start = cast(int, getattr(node, "lineno")) - 1 + end = cast(int, getattr(node, "end_lineno")) return "".join(lines[start:end]) return f"ERROR: could not find definition '{name}' in {path}" except Exception as e: @@ -370,17 +375,17 @@ def py_update_definition(path: str, name: str, new_content: str) -> str: p, err = _resolve_and_check(path) if err: return err + assert p is not None if not p.exists(): return f"ERROR: file not found: {path}" try: - import ast code = p.read_text(encoding="utf-8").lstrip(chr(0xFEFF)) tree = ast.parse(code) node = _get_symbol_node(tree, name) if not node: return f"ERROR: could not find definition '{name}' in {path}" - start = getattr(node, "lineno") - end = getattr(node, "end_lineno") + start = cast(int, getattr(node, "lineno")) + end = cast(int, getattr(node, "end_lineno")) return set_file_slice(path, start, end, new_content) except Exception as e: return f"ERROR updating definition '{name}' in '{path}': {e}" @@ -390,17 +395,17 @@ def py_get_signature(path: str, name: str) -> str: p, err = _resolve_and_check(path) if err: return err + assert p is not None if not p.exists(): return f"ERROR: file not found: {path}" try: - import ast code = p.read_text(encoding="utf-8").lstrip(chr(0xFEFF)) lines = code.splitlines(keepends=True) tree = ast.parse(code) node = _get_symbol_node(tree, name) if not node or not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): return f"ERROR: could not find function/method '{name}' in {path}" - start = getattr(node, "lineno") - 1 + start = node.lineno - 1 body_start = node.body[0].lineno - 1 sig_lines = lines[start:body_start] sig = "".join(sig_lines).strip() @@ -420,17 +425,17 @@ def py_set_signature(path: str, name: str, new_signature: str) -> str: p, err = _resolve_and_check(path) if err: return err + assert p is not None if not p.exists(): return f"ERROR: file not found: {path}" try: - import ast code = p.read_text(encoding="utf-8").lstrip(chr(0xFEFF)) code.splitlines(keepends=True) tree = ast.parse(code) node = _get_symbol_node(tree, name) if not node or not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): return f"ERROR: could not find function/method '{name}' in {path}" - start = getattr(node, "lineno") + start = node.lineno body_start_line = node.body[0].lineno # We replace from start until body_start_line - 1 # But we must be careful about comments/docstrings between sig and body @@ -445,10 +450,10 @@ def py_get_class_summary(path: str, name: str) -> str: p, err = _resolve_and_check(path) if err: return err + assert p is not None if not p.exists(): return f"ERROR: file not found: {path}" try: - import ast code = p.read_text(encoding="utf-8").lstrip(chr(0xFEFF)) tree = ast.parse(code) node = _get_symbol_node(tree, name) @@ -461,7 +466,7 @@ def py_get_class_summary(path: str, name: str) -> str: summary.append(f" Docstring: {doc}") for body_node in node.body: if isinstance(body_node, (ast.FunctionDef, ast.AsyncFunctionDef)): - start = getattr(body_node, "lineno") - 1 + start = body_node.lineno - 1 body_start = body_node.body[0].lineno - 1 sig = "".join(lines[start:body_start]).strip() summary.append(f" - {sig}") @@ -474,18 +479,18 @@ def py_get_var_declaration(path: str, name: str) -> str: p, err = _resolve_and_check(path) if err: return err + assert p is not None if not p.is_file() or p.suffix != ".py": return f"ERROR: not a python file: {path}" try: - import ast code = p.read_text(encoding="utf-8").lstrip(chr(0xFEFF)) lines = code.splitlines(keepends=True) tree = ast.parse(code) node = _get_symbol_node(tree, name) if not node or not isinstance(node, (ast.Assign, ast.AnnAssign)): return f"ERROR: could not find variable '{name}' in {path}" - start = getattr(node, "lineno") - 1 - end = getattr(node, "end_lineno") + start = cast(int, getattr(node, "lineno")) - 1 + end = cast(int, getattr(node, "end_lineno")) return "".join(lines[start:end]) except Exception as e: return f"ERROR retrieving variable '{name}' from '{path}': {e}" @@ -495,17 +500,17 @@ def py_set_var_declaration(path: str, name: str, new_declaration: str) -> str: p, err = _resolve_and_check(path) if err: return err + assert p is not None if not p.is_file() or p.suffix != ".py": return f"ERROR: not a python file: {path}" try: - import ast code = p.read_text(encoding="utf-8").lstrip(chr(0xFEFF)) tree = ast.parse(code) node = _get_symbol_node(tree, name) if not node or not isinstance(node, (ast.Assign, ast.AnnAssign)): return f"ERROR: could not find variable '{name}' in {path}" - start = getattr(node, "lineno") - end = getattr(node, "end_lineno") + start = cast(int, getattr(node, "lineno")) + end = cast(int, getattr(node, "end_lineno")) return set_file_slice(path, start, end, new_declaration) except Exception as e: return f"ERROR updating variable '{name}' in '{path}': {e}" @@ -516,10 +521,10 @@ def get_git_diff(path: str, base_rev: str = "HEAD", head_rev: str = "") -> str: base_rev: The base revision (default: HEAD) head_rev: The head revision (optional) """ - import subprocess p, err = _resolve_and_check(path) if err: return err + assert p is not None cmd = ["git", "diff", base_rev] if head_rev: cmd.append(head_rev) @@ -536,12 +541,13 @@ def py_find_usages(path: str, name: str) -> str: """Finds exact string matches of a symbol in a given file or directory.""" p, err = _resolve_and_check(path) if err: return err + assert p is not None try: import re pattern = re.compile(r"\b" + re.escape(name) + r"\b") results = [] - def _search_file(fp): + def _search_file(fp: Path) -> None: if fp.name == "history.toml" or fp.name.endswith("_history.toml"): return if not _is_allowed(fp): return try: @@ -573,9 +579,9 @@ def py_get_imports(path: str) -> str: """Parses a file's AST and returns a strict list of its dependencies.""" p, err = _resolve_and_check(path) if err: return err + assert p is not None if not p.is_file() or p.suffix != ".py": return f"ERROR: not a python file: {path}" try: - import ast code = p.read_text(encoding="utf-8") tree = ast.parse(code) imports = [] @@ -596,9 +602,9 @@ def py_check_syntax(path: str) -> str: """Runs a quick syntax check on a Python file.""" p, err = _resolve_and_check(path) if err: return err + assert p is not None if not p.is_file() or p.suffix != ".py": return f"ERROR: not a python file: {path}" try: - import ast code = p.read_text(encoding="utf-8") ast.parse(code) return f"Syntax OK: {path}" @@ -611,10 +617,10 @@ def py_get_hierarchy(path: str, class_name: str) -> str: """Scans the project to find subclasses of a given class.""" p, err = _resolve_and_check(path) if err: return err - import ast - subclasses = [] + assert p is not None + subclasses: list[str] = [] - def _search_file(fp): + def _search_file(fp: Path) -> None: if not _is_allowed(fp): return try: code = fp.read_text(encoding="utf-8") @@ -625,7 +631,8 @@ def py_get_hierarchy(path: str, class_name: str) -> str: if isinstance(base, ast.Name) and base.id == class_name: subclasses.append(f"{fp.name}: class {node.name}({class_name})") elif isinstance(base, ast.Attribute) and base.attr == class_name: - subclasses.append(f"{fp.name}: class {node.name}({base.value.id}.{class_name})") + if isinstance(base.value, ast.Name): + subclasses.append(f"{fp.name}: class {node.name}({base.value.id}.{class_name})") except Exception: pass try: @@ -647,9 +654,9 @@ def py_get_docstring(path: str, name: str) -> str: """Extracts the docstring for a specific module, class, or function.""" p, err = _resolve_and_check(path) if err: return err + assert p is not None if not p.is_file() or p.suffix != ".py": return f"ERROR: not a python file: {path}" try: - import ast code = p.read_text(encoding="utf-8") tree = ast.parse(code) if not name or name == "module": @@ -657,8 +664,10 @@ def py_get_docstring(path: str, name: str) -> str: return doc if doc else "No module docstring found." node = _get_symbol_node(tree, name) if not node: return f"ERROR: could not find symbol '{name}' in {path}" - doc = ast.get_docstring(node) - return doc if doc else f"No docstring found for '{name}'." + if isinstance(node, (ast.AsyncFunctionDef, ast.FunctionDef, ast.ClassDef, ast.Module)): + doc = ast.get_docstring(node) + return doc if doc else f"No docstring found for '{name}'." + return f"No docstring found for '{name}'." except Exception as e: return f"ERROR getting docstring for '{name}': {e}" @@ -666,12 +675,13 @@ def get_tree(path: str, max_depth: int = 2) -> str: """Returns a directory structure up to a max depth.""" p, err = _resolve_and_check(path) if err: return err + assert p is not None if not p.is_dir(): return f"ERROR: not a directory: {path}" try: - max_depth = int(max_depth) + m_depth = max_depth - def _build_tree(dir_path, current_depth, prefix=""): - if current_depth > max_depth: return [] + def _build_tree(dir_path: Path, current_depth: int, prefix: str = "") -> list[str]: + if current_depth > m_depth: return [] lines = [] try: entries = sorted(dir_path.iterdir(), key=lambda e: (e.is_file(), e.name.lower())) @@ -706,11 +716,11 @@ class _DDGParser(HTMLParser): def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: attrs_dict = dict(attrs) - if tag == "a" and "result__url" in attrs_dict.get("class", ""): - self.current_link = attrs_dict.get("href", "") - if tag == "a" and "result__snippet" in attrs_dict.get("class", ""): + if tag == "a" and "result__url" in cast(str, attrs_dict.get("class", "")): + self.current_link = cast(str, attrs_dict.get("href", "")) + if tag == "a" and "result__snippet" in cast(str, attrs_dict.get("class", "")): self.in_snippet = True - if tag == "h2" and "result__title" in attrs_dict.get("class", ""): + if tag == "h2" and "result__title" in cast(str, attrs_dict.get("class", "")): self.in_title = True def handle_endtag(self, tag: str) -> None: @@ -777,7 +787,9 @@ def fetch_url(url: str) -> str: """Fetch a URL and return its text content (stripped of HTML tags).""" # Correct duckduckgo redirect links if passed if url.startswith("//duckduckgo.com/l/?uddg="): - url = urllib.parse.unquote(url.split("uddg=")[1].split("&")[0]) + split_uddg = url.split("uddg=") + if len(split_uddg) > 1: + url = urllib.parse.unquote(split_uddg[1].split("&")[0]) if not url.startswith("http"): url = "https://" + url req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36'}) @@ -819,13 +831,13 @@ def dispatch(tool_name: str, tool_input: dict[str, Any]) -> str: Dispatch an MCP tool call by name. Returns the result as a string. """ # Handle aliases - path = tool_input.get("path", tool_input.get("file_path", tool_input.get("dir_path", ""))) + path = str(tool_input.get("path", tool_input.get("file_path", tool_input.get("dir_path", "")))) if tool_name == "read_file": return read_file(path) if tool_name == "list_directory": return list_directory(path) if tool_name == "search_files": - return search_files(path, tool_input.get("pattern", "*")) + return search_files(path, str(tool_input.get("pattern", "*"))) if tool_name == "get_file_summary": return get_file_summary(path) if tool_name == "py_get_skeleton": @@ -833,47 +845,47 @@ def dispatch(tool_name: str, tool_input: dict[str, Any]) -> str: if tool_name == "py_get_code_outline": return py_get_code_outline(path) if tool_name == "py_get_definition": - return py_get_definition(path, tool_input.get("name", "")) + return py_get_definition(path, str(tool_input.get("name", ""))) if tool_name == "py_update_definition": - return py_update_definition(path, tool_input.get("name", ""), tool_input.get("new_content", "")) + return py_update_definition(path, str(tool_input.get("name", "")), str(tool_input.get("new_content", ""))) if tool_name == "py_get_signature": - return py_get_signature(path, tool_input.get("name", "")) + return py_get_signature(path, str(tool_input.get("name", ""))) if tool_name == "py_set_signature": - return py_set_signature(path, tool_input.get("name", ""), tool_input.get("new_signature", "")) + return py_set_signature(path, str(tool_input.get("name", "")), str(tool_input.get("new_signature", ""))) if tool_name == "py_get_class_summary": - return py_get_class_summary(path, tool_input.get("name", "")) + return py_get_class_summary(path, str(tool_input.get("name", ""))) if tool_name == "py_get_var_declaration": - return py_get_var_declaration(path, tool_input.get("name", "")) + return py_get_var_declaration(path, str(tool_input.get("name", ""))) if tool_name == "py_set_var_declaration": - return py_set_var_declaration(path, tool_input.get("name", ""), tool_input.get("new_declaration", "")) + return py_set_var_declaration(path, str(tool_input.get("name", "")), str(tool_input.get("new_declaration", ""))) if tool_name == "get_file_slice": - return get_file_slice(path, tool_input.get("start_line", 1), tool_input.get("end_line", 1)) + return get_file_slice(path, int(tool_input.get("start_line", 1)), int(tool_input.get("end_line", 1))) if tool_name == "set_file_slice": - return set_file_slice(path, tool_input.get("start_line", 1), tool_input.get("end_line", 1), tool_input.get("new_content", "")) + return set_file_slice(path, int(tool_input.get("start_line", 1)), int(tool_input.get("end_line", 1)), str(tool_input.get("new_content", ""))) if tool_name == "get_git_diff": return get_git_diff( path, - tool_input.get("base_rev", "HEAD"), - tool_input.get("head_rev", "") + str(tool_input.get("base_rev", "HEAD")), + str(tool_input.get("head_rev", "")) ) if tool_name == "web_search": - return web_search(tool_input.get("query", "")) + return web_search(str(tool_input.get("query", ""))) if tool_name == "fetch_url": - return fetch_url(tool_input.get("url", "")) + return fetch_url(str(tool_input.get("url", ""))) if tool_name == "get_ui_performance": return get_ui_performance() if tool_name == "py_find_usages": - return py_find_usages(path, tool_input.get("name", "")) + return py_find_usages(path, str(tool_input.get("name", ""))) if tool_name == "py_get_imports": return py_get_imports(path) if tool_name == "py_check_syntax": return py_check_syntax(path) if tool_name == "py_get_hierarchy": - return py_get_hierarchy(path, tool_input.get("class_name", "")) + return py_get_hierarchy(path, str(tool_input.get("class_name", ""))) if tool_name == "py_get_docstring": - return py_get_docstring(path, tool_input.get("name", "")) + return py_get_docstring(path, str(tool_input.get("name", ""))) if tool_name == "get_tree": - return get_tree(path, tool_input.get("max_depth", 2)) + return get_tree(path, int(tool_input.get("max_depth", 2))) return f"ERROR: unknown MCP tool '{tool_name}'" # ------------------------------------------------------------------ tool schema helpers # These are imported by ai_client.py to build provider-specific declarations. diff --git a/models.py b/models.py index a52d3e2..d9638f0 100644 --- a/models.py +++ b/models.py @@ -103,9 +103,9 @@ class WorkerContext: class Metadata: id: str name: str - status: str - created_at: datetime - updated_at: datetime + status: Optional[str] = None + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None def to_dict(self) -> Dict[str, Any]: return { @@ -121,9 +121,9 @@ class Metadata: return cls( id=data["id"], name=data["name"], - status=data.get("status", "todo"), - created_at=datetime.fromisoformat(data['created_at']) if data.get('created_at') else datetime.now(), - updated_at=datetime.fromisoformat(data['updated_at']) if data.get('updated_at') else datetime.now(), + status=data.get("status"), + created_at=datetime.fromisoformat(data['created_at']) if data.get('created_at') else None, + updated_at=datetime.fromisoformat(data['updated_at']) if data.get('updated_at') else None, ) @dataclass diff --git a/outline_tool.py b/outline_tool.py index 18e2826..786466e 100644 --- a/outline_tool.py +++ b/outline_tool.py @@ -13,13 +13,14 @@ class CodeOutliner: return f"ERROR parsing code: {e}" output = [] - def get_docstring(node): - doc = ast.get_docstring(node) - if doc: - return doc.splitlines()[0] + def get_docstring(node: ast.AST) -> str | None: + if isinstance(node, (ast.AsyncFunctionDef, ast.FunctionDef, ast.ClassDef, ast.Module)): + doc = ast.get_docstring(node) + if doc: + return doc.splitlines()[0] return None - def walk(node, indent=0): + def walk(node: ast.AST, indent: int = 0) -> None: if isinstance(node, ast.ClassDef): start_line = node.lineno end_line = getattr(node, "end_lineno", start_line) diff --git a/performance_monitor.py b/performance_monitor.py index b049965..12be28e 100644 --- a/performance_monitor.py +++ b/performance_monitor.py @@ -2,38 +2,38 @@ from __future__ import annotations import time import psutil import threading -from typing import Any +from typing import Any, Optional, Callable class PerformanceMonitor: def __init__(self) -> None: - self._start_time = None - self._last_frame_time = 0.0 - self._fps = 0.0 - self._last_calculated_fps = 0.0 - self._frame_count = 0 - self._total_frame_count = 0 - self._fps_last_time = time.time() - self._process = psutil.Process() - self._cpu_usage = 0.0 - self._cpu_lock = threading.Lock() + self._start_time: Optional[float] = None + self._last_frame_time: float = 0.0 + self._fps: float = 0.0 + self._last_calculated_fps: float = 0.0 + self._frame_count: int = 0 + self._total_frame_count: int = 0 + self._fps_last_time: float = time.time() + self._process: psutil.Process = psutil.Process() + self._cpu_usage: float = 0.0 + self._cpu_lock: threading.Lock = threading.Lock() # Input lag tracking - self._last_input_time = None - self._input_lag_ms = 0.0 + self._last_input_time: Optional[float] = None + self._input_lag_ms: float = 0.0 # Alerts - self.alert_callback = None - self.thresholds = { + self.alert_callback: Optional[Callable[[str], None]] = None + self.thresholds: dict[str, float] = { 'frame_time_ms': 33.3, # < 30 FPS 'cpu_percent': 80.0, 'input_lag_ms': 100.0 } - self._last_alert_time = 0 - self._alert_cooldown = 30 # seconds + self._last_alert_time: float = 0.0 + self._alert_cooldown: int = 30 # seconds # Detailed profiling - self._component_timings = {} - self._comp_start = {} + self._component_timings: dict[str, float] = {} + self._comp_start: dict[str, float] = {} # Start CPU usage monitoring thread - self._stop_event = threading.Event() - self._cpu_thread = threading.Thread(target=self._monitor_cpu, daemon=True) + self._stop_event: threading.Event = threading.Event() + self._cpu_thread: threading.Thread = threading.Thread(target=self._monitor_cpu, daemon=True) self._cpu_thread.start() def _monitor_cpu(self) -> None: @@ -107,15 +107,13 @@ class PerformanceMonitor: def get_metrics(self) -> dict[str, Any]: with self._cpu_lock: cpu_usage = self._cpu_usage - metrics = { + metrics: dict[str, Any] = { 'last_frame_time_ms': self._last_frame_time, 'fps': self._last_calculated_fps, 'cpu_percent': cpu_usage, 'total_frames': self._total_frame_count, - 'input_lag_ms': self._last_input_time if self._last_input_time else 0.0 # Wait, this should be the calculated lag + 'input_lag_ms': self._input_lag_ms } - # Oops, fixed the input lag logic in previous turn, let's keep it consistent - metrics['input_lag_ms'] = self._input_lag_ms # Add detailed timings for name, elapsed in self._component_timings.items(): metrics[f'time_{name}_ms'] = elapsed diff --git a/project_history.toml b/project_history.toml index 96afa05..47b7062 100644 --- a/project_history.toml +++ b/project_history.toml @@ -8,5 +8,5 @@ active = "main" [discussions.main] git_commit = "" -last_updated = "2026-03-04T00:55:41" +last_updated = "2026-03-04T09:44:10" history = [] diff --git a/pyproject.toml b/pyproject.toml index 0537ae1..2d371f2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,4 +37,28 @@ disallow_incomplete_defs = true ignore_missing_imports = true explicit_package_bases = true +[tool.ruff] +# Target version +target-version = "py311" +exclude = [ + "scripts/ai_style_formatter.py", + "scripts/temp_def.py", +] + +[tool.ruff.lint] + +# Enable standard rules +select = ["E", "F", "W"] +# Ignore style choices that conflict with project's compact style +ignore = [ + "E701", # Multiple statements on one line (colon) + "E702", # Multiple statements on one line (semicolon) + "E402", # Module level import not at top of file + "E722", # Do not use bare `except` + "E501", # Line too long + "W291", # Trailing whitespace + "W293", # Blank line contains whitespace +] + + diff --git a/session_logger.py b/session_logger.py index c0d8cd6..a4efeaa 100644 --- a/session_logger.py +++ b/session_logger.py @@ -146,7 +146,8 @@ def log_tool_call(script: str, result: str, script_path: Optional[str]) -> Optio ps1_path: Optional[Path] = _SCRIPTS_DIR / ps1_name try: - ps1_path.write_text(script, encoding="utf-8") + if ps1_path: + ps1_path.write_text(script, encoding="utf-8") except Exception as exc: ps1_path = None ps1_name = f"(write error: {exc})" diff --git a/simulation/ping_pong.py b/simulation/ping_pong.py index 7c3da89..0e2fc7f 100644 --- a/simulation/ping_pong.py +++ b/simulation/ping_pong.py @@ -14,7 +14,7 @@ def main(): if not client.wait_for_server(timeout=5): print("Hook server not found. Start GUI with --enable-test-hooks") return - sim_agent = UserSimAgent(client) + UserSimAgent(client) # 1. Reset session to start clean print("Resetting session...") client.click("btn_reset") diff --git a/summarize.py b/summarize.py index 28d9510..6bde009 100644 --- a/summarize.py +++ b/summarize.py @@ -26,7 +26,7 @@ context block that replaces full file contents in the initial send. import ast import re from pathlib import Path -from typing import Callable +from typing import Callable, Any # ------------------------------------------------------------------ per-type extractors @@ -160,7 +160,7 @@ def summarise_file(path: Path, content: str) -> str: except Exception as e: return f"_Summariser error: {e}_" -def summarise_items(file_items: list[dict]) -> list[dict]: +def summarise_items(file_items: list[dict[str, Any]]) -> list[dict[str, Any]]: """ Given a list of file_item dicts (as returned by aggregate.build_file_items), return a parallel list of dicts with an added `summary` key. @@ -178,7 +178,7 @@ def summarise_items(file_items: list[dict]) -> list[dict]: result.append({**item, "summary": summary}) return result -def build_summary_markdown(file_items: list[dict]) -> str: +def build_summary_markdown(file_items: list[dict[str, Any]]) -> str: """ Build a compact markdown string of file summaries, suitable for the initial block instead of full file contents. diff --git a/tests/conftest.py b/tests/conftest.py index 1ff1ca5..8834a09 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,7 +10,7 @@ import datetime import shutil from pathlib import Path from typing import Generator, Any -from unittest.mock import patch, MagicMock +from unittest.mock import patch # Ensure project root is in path for imports sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) @@ -41,7 +41,7 @@ class VerificationLogger: }) def finalize(self, title: str, status: str, result_msg: str) -> None: - elapsed = round(time.time() - self.start_time, 2) + round(time.time() - self.start_time, 2) log_file = self.logs_dir / f"{self.script_name}.txt" with open(log_file, "w", encoding="utf-8") as f: f.write(f"[ Test: {self.test_name} ]\n") diff --git a/tests/mock_gemini_cli.py b/tests/mock_gemini_cli.py index 97cc8f7..a4979a0 100644 --- a/tests/mock_gemini_cli.py +++ b/tests/mock_gemini_cli.py @@ -60,7 +60,6 @@ def main() -> None: return # 2. Check for multi-round tool triggers - is_resume_list = is_resume and 'list_directory' in prompt is_resume_read = is_resume and 'read_file' in prompt is_resume_powershell = is_resume and 'run_powershell' in prompt diff --git a/tests/test_agent_capabilities.py b/tests/test_agent_capabilities.py index c9e40a3..00c3796 100644 --- a/tests/test_agent_capabilities.py +++ b/tests/test_agent_capabilities.py @@ -1,4 +1,3 @@ -import pytest import sys import os from unittest.mock import patch, MagicMock diff --git a/tests/test_agent_tools_wiring.py b/tests/test_agent_tools_wiring.py index 26047a3..dc0f948 100644 --- a/tests/test_agent_tools_wiring.py +++ b/tests/test_agent_tools_wiring.py @@ -1,4 +1,3 @@ -import pytest import sys import os import ai_client diff --git a/tests/test_api_events.py b/tests/test_api_events.py index df6f6a9..0fa5fa1 100644 --- a/tests/test_api_events.py +++ b/tests/test_api_events.py @@ -1,4 +1,3 @@ -import pytest from typing import Any from unittest.mock import MagicMock, patch import ai_client diff --git a/tests/test_api_hook_extensions.py b/tests/test_api_hook_extensions.py index 150d30f..d5e5c37 100644 --- a/tests/test_api_hook_extensions.py +++ b/tests/test_api_hook_extensions.py @@ -1,7 +1,7 @@ import sys import os from typing import Any -from unittest.mock import MagicMock, patch +from unittest.mock import patch # Ensure project root is in path for imports sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) diff --git a/tests/test_arch_boundary_phase2.py b/tests/test_arch_boundary_phase2.py index 69f021f..26a8fe3 100644 --- a/tests/test_arch_boundary_phase2.py +++ b/tests/test_arch_boundary_phase2.py @@ -3,7 +3,6 @@ Tests for architecture_boundary_hardening_20260302 — Phase 2. Tasks 2.1-2.4: MCP tool config exposure + MUTATING_TOOLS + HITL enforcement. """ import tomllib -import pytest from project_manager import default_project MUTATING_TOOLS = {"set_file_slice", "py_update_definition", "py_set_signature", "py_set_var_declaration"} @@ -101,9 +100,8 @@ def test_mutating_tools_excludes_read_tools(): def test_mutating_tool_triggers_pre_tool_callback(monkeypatch): """When a mutating tool is called and pre_tool_callback is set, it must be invoked.""" - import ai_client import mcp_client - from unittest.mock import MagicMock, patch + from unittest.mock import patch callback_called = [] def fake_callback(desc, base_dir, qa_cb): callback_called.append(desc) @@ -113,12 +111,11 @@ def test_mutating_tool_triggers_pre_tool_callback(monkeypatch): tool_name = "set_file_slice" args = {"path": "foo.py", "start_line": 1, "end_line": 2, "new_content": "x"} # Simulate the logic from all 4 provider dispatch blocks - out = "" _res = fake_callback(f"# MCP MUTATING TOOL: {tool_name}", ".", None) if _res is None: - out = "USER REJECTED: tool execution cancelled" + pass else: - out = mcp_client.dispatch(tool_name, args) + mcp_client.dispatch(tool_name, args) assert len(callback_called) == 1, "pre_tool_callback must be called for mutating tools" assert mock_dispatch.called diff --git a/tests/test_arch_boundary_phase3.py b/tests/test_arch_boundary_phase3.py index 13a36b4..b878df6 100644 --- a/tests/test_arch_boundary_phase3.py +++ b/tests/test_arch_boundary_phase3.py @@ -1,4 +1,3 @@ -import pytest from models import Ticket from dag_engine import TrackDAG, ExecutionEngine diff --git a/tests/test_execution_engine.py b/tests/test_execution_engine.py index 2081ea8..9ad90f7 100644 --- a/tests/test_execution_engine.py +++ b/tests/test_execution_engine.py @@ -1,4 +1,3 @@ -import pytest from models import Ticket from dag_engine import TrackDAG, ExecutionEngine diff --git a/tests/test_gemini_cli_adapter_parity.py b/tests/test_gemini_cli_adapter_parity.py index 94b6302..dc322d1 100644 --- a/tests/test_gemini_cli_adapter_parity.py +++ b/tests/test_gemini_cli_adapter_parity.py @@ -3,7 +3,6 @@ from unittest.mock import patch, MagicMock import json import sys import os -import subprocess # Ensure the project root is in sys.path to resolve imports correctly project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) diff --git a/tests/test_gemini_cli_edge_cases.py b/tests/test_gemini_cli_edge_cases.py index e936e16..89050cf 100644 --- a/tests/test_gemini_cli_edge_cases.py +++ b/tests/test_gemini_cli_edge_cases.py @@ -68,7 +68,7 @@ def test_gemini_cli_parameter_resilience(live_gui: Any) -> None: bridge_path_str = bridge_path.replace("\\", "/") else: bridge_path_str = bridge_path - with open(alias_tool_content := "tests/mock_alias_tool.py", "w") as f: + with open("tests/mock_alias_tool.py", "w") as f: f.write(f'''import sys, json, os, subprocess prompt = sys.stdin.read() if '"role": "tool"' in prompt: diff --git a/tests/test_gui2_layout.py b/tests/test_gui2_layout.py index 99bc83c..608ad6e 100644 --- a/tests/test_gui2_layout.py +++ b/tests/test_gui2_layout.py @@ -1,6 +1,3 @@ -from typing import Generator -import pytest -from unittest.mock import patch from gui_2 import App def test_gui2_hubs_exist_in_show_windows(app_instance: App) -> None: diff --git a/tests/test_gui2_mcp.py b/tests/test_gui2_mcp.py index aa48953..818e2b9 100644 --- a/tests/test_gui2_mcp.py +++ b/tests/test_gui2_mcp.py @@ -1,9 +1,6 @@ -import pytest from unittest.mock import patch, MagicMock -from typing import Generator from gui_2 import App import ai_client -from events import EventEmitter def test_mcp_tool_call_is_dispatched(app_instance: App) -> None: """ diff --git a/tests/test_gui_diagnostics.py b/tests/test_gui_diagnostics.py index 56a8168..45f8584 100644 --- a/tests/test_gui_diagnostics.py +++ b/tests/test_gui_diagnostics.py @@ -1,5 +1,3 @@ -import pytest -from unittest.mock import patch, MagicMock import sys import os from typing import Any @@ -7,7 +5,6 @@ from typing import Any # Ensure project root is in path for imports sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) -from gui_2 import App def test_diagnostics_panel_initialization(app_instance: Any) -> None: assert "Diagnostics" in app_instance.show_windows diff --git a/tests/test_gui_events.py b/tests/test_gui_events.py index 92bf719..c1e8450 100644 --- a/tests/test_gui_events.py +++ b/tests/test_gui_events.py @@ -1,16 +1,12 @@ -import pytest import sys import os from unittest.mock import patch -from typing import Generator, Any from gui_2 import App -import ai_client # Ensure project root is in path sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) def test_gui_updates_on_event(app_instance: App) -> None: - mock_stats = {"percentage": 50.0, "current": 500, "limit": 1000} app_instance.last_md = "mock_md" with patch.object(app_instance, '_refresh_api_metrics') as mock_refresh: # Simulate event (bypassing events.emit since _init_ai_and_hooks is mocked) diff --git a/tests/test_gui_phase3.py b/tests/test_gui_phase3.py index 06c040a..504bb2f 100644 --- a/tests/test_gui_phase3.py +++ b/tests/test_gui_phase3.py @@ -1,10 +1,8 @@ import os import json from pathlib import Path -from unittest.mock import MagicMock, patch -import pytest +from unittest.mock import patch -from gui_2 import App def test_track_proposal_editing(app_instance): # Setup some proposed tracks diff --git a/tests/test_gui_streaming.py b/tests/test_gui_streaming.py index 3e82703..838d298 100644 --- a/tests/test_gui_streaming.py +++ b/tests/test_gui_streaming.py @@ -1,5 +1,4 @@ import pytest -from unittest.mock import patch from gui_2 import App @pytest.mark.asyncio diff --git a/tests/test_gui_updates.py b/tests/test_gui_updates.py index 8043f5c..b563f96 100644 --- a/tests/test_gui_updates.py +++ b/tests/test_gui_updates.py @@ -1,4 +1,3 @@ -import pytest from unittest.mock import patch import sys import os @@ -41,7 +40,7 @@ def test_performance_history_updates(app_instance: Any) -> None: def test_gui_updates_on_event(app_instance: App) -> None: mock_stats = {"percentage": 50.0, "current": 500, "limit": 1000} app_instance.last_md = "mock_md" - with patch('ai_client.get_token_stats', return_value=mock_stats) as mock_get_stats: + with patch('ai_client.get_token_stats', return_value=mock_stats): app_instance._on_api_event(payload={"text": "test"}) app_instance._process_pending_gui_tasks() assert app_instance._token_stats["percentage"] == 50.0 diff --git a/tests/test_hooks.py b/tests/test_hooks.py index d91b10e..03f4fd6 100644 --- a/tests/test_hooks.py +++ b/tests/test_hooks.py @@ -6,7 +6,6 @@ from unittest.mock import patch sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) from api_hook_client import ApiHookClient -import gui_2 def test_hooks_enabled_via_cli(mock_app) -> None: with patch.object(sys, 'argv', ['gui_2.py', '--enable-test-hooks']): diff --git a/tests/test_mma_agent_focus_phase1.py b/tests/test_mma_agent_focus_phase1.py index 057d904..d953335 100644 --- a/tests/test_mma_agent_focus_phase1.py +++ b/tests/test_mma_agent_focus_phase1.py @@ -2,11 +2,8 @@ Tests for mma_agent_focus_ux_20260302 — Phase 1: Tier Tagging at Emission. These tests are written RED-first: they fail before implementation. """ -from typing import Generator import pytest -from unittest.mock import patch import ai_client -from gui_2 import App @pytest.fixture(autouse=True) diff --git a/tests/test_mma_agent_focus_phase3.py b/tests/test_mma_agent_focus_phase3.py index 739dad2..488ed0c 100644 --- a/tests/test_mma_agent_focus_phase3.py +++ b/tests/test_mma_agent_focus_phase3.py @@ -1,10 +1,6 @@ """ Tests for mma_agent_focus_ux_20260302 — Phase 3: Focus Agent UI + Filter Logic. """ -from typing import Generator -import pytest -from unittest.mock import patch -from gui_2 import App def test_ui_focus_agent_state_var_exists(app_instance): diff --git a/tests/test_mma_orchestration_gui.py b/tests/test_mma_orchestration_gui.py index 5bf7630..66df561 100644 --- a/tests/test_mma_orchestration_gui.py +++ b/tests/test_mma_orchestration_gui.py @@ -1,4 +1,3 @@ -import pytest import json from unittest.mock import patch import time diff --git a/tests/test_mma_ticket_actions.py b/tests/test_mma_ticket_actions.py index b71bf07..4f365ce 100644 --- a/tests/test_mma_ticket_actions.py +++ b/tests/test_mma_ticket_actions.py @@ -1,6 +1,4 @@ -from typing import Generator -import pytest -from unittest.mock import patch, MagicMock +from unittest.mock import patch from gui_2 import App def test_cb_ticket_retry(app_instance: App) -> None: diff --git a/tests/test_token_usage.py b/tests/test_token_usage.py index 7d8fc00..db13d74 100644 --- a/tests/test_token_usage.py +++ b/tests/test_token_usage.py @@ -1,4 +1,3 @@ -import pytest import sys import os import hashlib diff --git a/tests/test_token_viz.py b/tests/test_token_viz.py index 27d48e8..245444d 100644 --- a/tests/test_token_viz.py +++ b/tests/test_token_viz.py @@ -1,7 +1,4 @@ """Tests for context & token visualization (Track: context_token_viz_20260301).""" -from typing import Generator -from unittest.mock import patch -import pytest import ai_client from ai_client import _add_bleed_derived, get_history_bleed_stats diff --git a/tests/test_visual_mma.py b/tests/test_visual_mma.py index 5432951..c1aa4bf 100644 --- a/tests/test_visual_mma.py +++ b/tests/test_visual_mma.py @@ -1,4 +1,3 @@ -import pytest import time from api_hook_client import ApiHookClient diff --git a/tests/test_vlogger_availability.py b/tests/test_vlogger_availability.py index 8f72af0..67db287 100644 --- a/tests/test_vlogger_availability.py +++ b/tests/test_vlogger_availability.py @@ -1,4 +1,3 @@ -import pytest def test_vlogger_available(vlogger): vlogger.log_state("Test", "Before", "After") diff --git a/theme.py b/theme.py index 9a16dbe..bd4a674 100644 --- a/theme.py +++ b/theme.py @@ -22,13 +22,14 @@ Usage import dearpygui.dearpygui as dpg from pathlib import Path +from typing import Any # ------------------------------------------------------------------ palettes # Colour key names match the DPG mvThemeCol_* constants (string lookup below). # Only keys that differ from DPG defaults need to be listed. -_PALETTES: dict[str, dict] = { +_PALETTES: dict[str, dict[str, Any]] = { "DPG Default": {}, # empty = reset to DPG built-in defaults "10x Dark": { # Window / frame chrome @@ -285,11 +286,11 @@ def get_current_font_size() -> float: def get_current_scale() -> float: return _current_scale -def get_palette_colours(name: str) -> dict: +def get_palette_colours(name: str) -> dict[str, Any]: """Return a copy of the colour dict for the named palette.""" return dict(_PALETTES.get(name, {})) -def apply(palette_name: str, overrides: dict | None = None) -> None: +def apply(palette_name: str, overrides: dict[str, Any] | None = None) -> None: """ Build a global DPG theme from the named palette plus optional per-colour overrides, and bind it as the default theme. @@ -368,7 +369,7 @@ def set_scale(factor: float) -> None: _current_scale = factor dpg.set_global_font_scale(factor) -def save_to_config(config: dict) -> None: +def save_to_config(config: dict[str, Any]) -> None: """Persist theme settings into the config dict under [theme].""" config.setdefault("theme", {}) config["theme"]["palette"] = _current_palette @@ -376,7 +377,7 @@ def save_to_config(config: dict) -> None: config["theme"]["font_size"] = _current_font_size config["theme"]["scale"] = _current_scale -def load_from_config(config: dict) -> None: +def load_from_config(config: dict[str, Any]) -> None: """Read [theme] from config and apply everything.""" t = config.get("theme", {}) palette = t.get("palette", "DPG Default")