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")