Merge origin/cache
This commit is contained in:
68
ai_client.py
68
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 {
|
||||
|
||||
43
gui_2.py
43
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():
|
||||
@@ -1160,8 +1186,9 @@ class App:
|
||||
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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -26,6 +26,7 @@ scripts/generated/
|
||||
Where <ts> = 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)."""
|
||||
|
||||
Reference in New Issue
Block a user