diff --git a/ai_client.py b/ai_client.py index b2dd868..7444bab 100644 --- a/ai_client.py +++ b/ai_client.py @@ -17,7 +17,9 @@ import time import datetime import hashlib import difflib +import threading from pathlib import Path +import os import file_cache import mcp_client import anthropic @@ -53,6 +55,8 @@ _GEMINI_CACHE_TTL = 3600 _anthropic_client = None _anthropic_history: list[dict] = [] +_anthropic_history_lock = threading.Lock() +_send_lock = threading.Lock() # Injected by gui.py - called when AI wants to run a command. # Signature: (script: str, base_dir: str) -> str | None @@ -69,6 +73,10 @@ tool_log_callback = None # Increased to allow thorough code exploration before forcing a summary MAX_TOOL_ROUNDS = 10 +# Maximum cumulative bytes of tool output allowed per send() call. +# Prevents unbounded memory growth during long tool-calling loops. +_MAX_TOOL_OUTPUT_BYTES = 500_000 + # Maximum characters per text chunk sent to Anthropic. # Kept well under the ~200k token API limit. _ANTHROPIC_CHUNK_SIZE = 120_000 @@ -130,8 +138,18 @@ def clear_comms_log(): def _load_credentials() -> dict: - with open("credentials.toml", "rb") as f: - return tomllib.load(f) + cred_path = os.environ.get("SLOP_CREDENTIALS", "credentials.toml") + try: + with open(cred_path, "rb") as f: + return tomllib.load(f) + except FileNotFoundError: + raise FileNotFoundError( + f"Credentials file not found: {cred_path}\n" + f"Create a credentials.toml with:\n" + f" [gemini]\n api_key = \"your-key\"\n" + f" [anthropic]\n api_key = \"your-key\"\n" + f"Or set SLOP_CREDENTIALS env var to a custom path." + ) # ------------------------------------------------------------------ provider errors @@ -246,7 +264,8 @@ def reset_session(): _gemini_cache_md_hash = None _gemini_cache_created_at = None _anthropic_client = None - _anthropic_history = [] + with _anthropic_history_lock: + _anthropic_history = [] _CACHED_ANTHROPIC_TOOLS = None file_cache.reset_client() @@ -652,6 +671,7 @@ def _send_gemini(md_content: str, user_message: str, base_dir: str, _append_comms("OUT", "request", {"message": f"[ctx {len(md_content)} + msg {len(user_message)}]"}) payload, all_text = user_message, [] + _cumulative_tool_bytes = 0 # Strip stale file refreshes and truncate old tool outputs ONCE before # entering the tool loop (not per-round — history entries don't change). @@ -701,11 +721,11 @@ def _send_gemini(md_content: str, user_message: str, base_dir: str, if not hist: break for p in hist[0].parts: if hasattr(p, "text") and p.text: - saved += len(p.text) // 4 + saved += int(len(p.text) / _CHARS_PER_TOKEN) elif hasattr(p, "function_response") and p.function_response: r = getattr(p.function_response, "response", {}) if isinstance(r, dict): - saved += len(str(r.get("output", ""))) // 4 + saved += int(len(str(r.get("output", ""))) / _CHARS_PER_TOKEN) hist.pop(0) dropped += 1 total_in -= max(saved, 200) @@ -736,10 +756,17 @@ 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})) 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"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]"}) + _append_comms("OUT", "tool_result_send", {"results": log}) payload = f_resps @@ -1046,6 +1073,7 @@ def _send_anthropic(md_content: str, user_message: str, base_dir: str, file_item }) all_text_parts = [] + _cumulative_tool_bytes = 0 # We allow MAX_TOOL_ROUNDS, plus 1 final loop to get the text synthesis for round_idx in range(MAX_TOOL_ROUNDS + 2): @@ -1132,10 +1160,12 @@ def _send_anthropic(md_content: str, user_message: str, base_dir: str, file_item _append_comms("OUT", "tool_call", {"name": b_name, "id": b_id, "args": b_input}) output = mcp_client.dispatch(b_name, b_input) _append_comms("IN", "tool_result", {"name": b_name, "id": b_id, "output": output}) + truncated = _truncate_tool_output(output) + _cumulative_tool_bytes += len(truncated) tool_results.append({ "type": "tool_result", "tool_use_id": b_id, - "content": _truncate_tool_output(output), + "content": truncated, }) events.emit("tool_execution", payload={"status": "completed", "tool": b_name, "result": output, "round": round_idx}) elif b_name == TOOL_NAME: @@ -1151,13 +1181,22 @@ def _send_anthropic(md_content: str, user_message: str, base_dir: str, file_item "id": b_id, "output": output, }) + truncated = _truncate_tool_output(output) + _cumulative_tool_bytes += len(truncated) tool_results.append({ "type": "tool_result", "tool_use_id": b_id, - "content": _truncate_tool_output(output), + "content": truncated, }) 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({ + "type": "text", + "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) @@ -1220,11 +1259,12 @@ def send( discussion_history : discussion history text (used by Gemini to inject as conversation message instead of caching it) """ - if _provider == "gemini": - return _send_gemini(md_content, user_message, base_dir, file_items, discussion_history) - elif _provider == "anthropic": - return _send_anthropic(md_content, user_message, base_dir, file_items, discussion_history) - raise ValueError(f"unknown provider: {_provider}") + with _send_lock: + if _provider == "gemini": + return _send_gemini(md_content, user_message, base_dir, file_items, discussion_history) + elif _provider == "anthropic": + return _send_anthropic(md_content, user_message, base_dir, file_items, discussion_history) + raise ValueError(f"unknown provider: {_provider}") def get_history_bleed_stats() -> dict: """ @@ -1232,7 +1272,9 @@ def get_history_bleed_stats() -> dict: """ if _provider == "anthropic": # For Anthropic, we have a robust estimator - current_tokens = _estimate_prompt_tokens([], _anthropic_history) + with _anthropic_history_lock: + history_snapshot = list(_anthropic_history) + current_tokens = _estimate_prompt_tokens([], history_snapshot) limit_tokens = _ANTHROPIC_MAX_PROMPT_TOKENS percentage = (current_tokens / limit_tokens) * 100 if limit_tokens > 0 else 0 return { diff --git a/gui_2.py b/gui_2.py index 8d6bfc7..5e7c0f9 100644 --- a/gui_2.py +++ b/gui_2.py @@ -153,6 +153,7 @@ class App: self.last_file_items: list = [] self.send_thread: threading.Thread | None = None + self._send_thread_lock = threading.Lock() self.models_thread: threading.Thread | None = None _default_windows = { @@ -232,6 +233,10 @@ class App: 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 + # Auto-save timer (every 60s) + self._autosave_interval = 60.0 + self._last_autosave = time.time() + session_logger.open_session() ai_client.set_provider(self.current_provider, self.current_model) ai_client.confirm_and_run_callback = self._confirm_and_run @@ -625,6 +630,18 @@ class App: # Process GUI task queue self._process_pending_gui_tasks() + # Auto-save (every 60s) + now = time.time() + if now - self._last_autosave >= self._autosave_interval: + self._last_autosave = now + try: + self._flush_to_project() + self._save_active_project() + self._flush_to_config() + save_config(self.config) + except Exception: + pass # silent — don't disrupt the GUI loop + # Sync pending comms with self._pending_comms_lock: for c in self._pending_comms: @@ -1109,9 +1126,21 @@ class App: imgui.separator() ch, self.ui_ai_input = imgui.input_text_multiline("##ai_in", self.ui_ai_input, imgui.ImVec2(-1, -40)) + + # Keyboard shortcuts + io = imgui.get_io() + ctrl_enter = io.key_ctrl and imgui.is_key_pressed(imgui.Key.enter) + ctrl_l = io.key_ctrl and imgui.is_key_pressed(imgui.Key.l) + if ctrl_l: + self.ui_ai_input = "" + imgui.separator() - if imgui.button("Gen + Send"): - if not (self.send_thread and self.send_thread.is_alive()): + send_busy = False + with self._send_thread_lock: + if self.send_thread and self.send_thread.is_alive(): + send_busy = True + if imgui.button("Gen + Send") or ctrl_enter: + if not send_busy: try: md, path, file_items, stable_md, disc_text = self._do_generate() self.last_md = md @@ -1127,10 +1156,7 @@ class App: ai_client.set_custom_system_prompt("\n\n".join(csp)) ai_client.set_model_params(self.temperature, self.max_tokens, self.history_trunc_limit) ai_client.set_agent_tools(self.ui_agent_tools) - # For Gemini: send stable_md (no history) as cached context, - # and disc_text separately as conversation history. - # For Anthropic: send full md (with history) as before. - send_md = stable_md # No history in cached context for either provider + send_md = stable_md send_disc = disc_text def do_send(): @@ -1159,9 +1185,10 @@ class App: if self.ui_auto_add_history: with self._pending_history_adds_lock: self._pending_history_adds.append({"role": "System", "content": self.ai_response, "collapsed": False, "ts": project_manager.now_ts()}) - - self.send_thread = threading.Thread(target=do_send, daemon=True) - self.send_thread.start() + + with self._send_thread_lock: + self.send_thread = threading.Thread(target=do_send, daemon=True) + self.send_thread.start() imgui.same_line() if imgui.button("MD Only"): try: diff --git a/mcp_client.py b/mcp_client.py index 7fccf53..5fdb5f1 100644 --- a/mcp_client.py +++ b/mcp_client.py @@ -65,7 +65,10 @@ def configure(file_items: list[dict], extra_base_dirs: list[str] | None = None): for item in file_items: p = item.get("path") if p is not None: - rp = Path(p).resolve() + try: + rp = Path(p).resolve(strict=True) + except (OSError, ValueError): + rp = Path(p).resolve() _allowed_paths.add(rp) _base_dirs.add(rp.parent) @@ -82,8 +85,13 @@ def _is_allowed(path: Path) -> bool: A path is allowed if: - it is explicitly in _allowed_paths, OR - it is contained within (or equal to) one of the _base_dirs + All paths are resolved (follows symlinks) before comparison to prevent + symlink-based path traversal. """ - rp = path.resolve() + try: + rp = path.resolve(strict=True) + except (OSError, ValueError): + rp = path.resolve() if rp in _allowed_paths: return True for bd in _base_dirs: @@ -104,7 +112,10 @@ def _resolve_and_check(raw_path: str) -> tuple[Path | None, str]: p = Path(raw_path) if not p.is_absolute() and _primary_base_dir: p = _primary_base_dir / p - p = p.resolve() + try: + p = p.resolve(strict=True) + except (OSError, ValueError): + p = p.resolve() except Exception as e: return None, f"ERROR: invalid path '{raw_path}': {e}" if not _is_allowed(p): @@ -269,7 +280,8 @@ def web_search(query: str) -> str: url = "https://html.duckduckgo.com/html/?q=" + urllib.parse.quote(query) req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'}) try: - html = urllib.request.urlopen(req, timeout=10).read().decode('utf-8', errors='ignore') + with urllib.request.urlopen(req, timeout=10) as resp: + html = resp.read().decode('utf-8', errors='ignore') parser = _DDGParser() parser.feed(html) if not parser.results: @@ -292,7 +304,8 @@ def fetch_url(url: str) -> str: req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'}) try: - html = urllib.request.urlopen(req, timeout=10).read().decode('utf-8', errors='ignore') + with urllib.request.urlopen(req, timeout=10) as resp: + html = resp.read().decode('utf-8', errors='ignore') parser = _TextExtractor() parser.feed(html) full_text = " ".join(parser.text) diff --git a/session_logger.py b/session_logger.py index dd2a32e..bf3c859 100644 --- a/session_logger.py +++ b/session_logger.py @@ -26,6 +26,7 @@ scripts/generated/ Where = YYYYMMDD_HHMMSS of when this session was started. """ +import atexit import datetime import json import threading @@ -71,6 +72,8 @@ def open_session(): _tool_fh.write(f"# Tool-call log — session {_ts}\n\n") _tool_fh.flush() + atexit.register(close_session) + def close_session(): """Flush and close both log files. Called on clean exit (optional)."""