Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bd8551d282 | |||
| 69401365be |
15
aggregate.py
15
aggregate.py
@@ -164,6 +164,18 @@ def build_markdown_from_items(file_items: list[dict], screenshot_base_dir: Path,
|
|||||||
return "\n\n---\n\n".join(parts)
|
return "\n\n---\n\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def build_markdown_no_history(file_items: list[dict], screenshot_base_dir: Path, screenshots: list[str], summary_only: bool = False) -> str:
|
||||||
|
"""Build markdown with only files + screenshots (no history). Used for stable caching."""
|
||||||
|
return build_markdown_from_items(file_items, screenshot_base_dir, screenshots, history=[], summary_only=summary_only)
|
||||||
|
|
||||||
|
|
||||||
|
def build_discussion_text(history: list[str]) -> str:
|
||||||
|
"""Build just the discussion history section text. Returns empty string if no history."""
|
||||||
|
if not history:
|
||||||
|
return ""
|
||||||
|
return "## Discussion History\n\n" + build_discussion_section(history)
|
||||||
|
|
||||||
|
|
||||||
def build_markdown(base_dir: Path, files: list[str], screenshot_base_dir: Path, screenshots: list[str], history: list[str], summary_only: bool = False) -> str:
|
def build_markdown(base_dir: Path, files: list[str], screenshot_base_dir: Path, screenshots: list[str], history: list[str], summary_only: bool = False) -> str:
|
||||||
parts = []
|
parts = []
|
||||||
# STATIC PREFIX: Files and Screenshots must go first to maximize Cache Hits
|
# STATIC PREFIX: Files and Screenshots must go first to maximize Cache Hits
|
||||||
@@ -195,8 +207,9 @@ def run(config: dict) -> tuple[str, Path, list[dict]]:
|
|||||||
output_file = output_dir / f"{namespace}_{increment:03d}.md"
|
output_file = output_dir / f"{namespace}_{increment:03d}.md"
|
||||||
# Build file items once, then construct markdown from them (avoids double I/O)
|
# Build file items once, then construct markdown from them (avoids double I/O)
|
||||||
file_items = build_file_items(base_dir, files)
|
file_items = build_file_items(base_dir, files)
|
||||||
|
summary_only = config.get("project", {}).get("summary_only", False)
|
||||||
markdown = build_markdown_from_items(file_items, screenshot_base_dir, screenshots, history,
|
markdown = build_markdown_from_items(file_items, screenshot_base_dir, screenshots, history,
|
||||||
summary_only=False)
|
summary_only=summary_only)
|
||||||
output_file.write_text(markdown, encoding="utf-8")
|
output_file.write_text(markdown, encoding="utf-8")
|
||||||
return markdown, output_file, file_items
|
return markdown, output_file, file_items
|
||||||
|
|
||||||
|
|||||||
150
ai_client.py
150
ai_client.py
@@ -15,7 +15,11 @@ import tomllib
|
|||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
import datetime
|
import datetime
|
||||||
|
import hashlib
|
||||||
|
import difflib
|
||||||
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import os
|
||||||
import file_cache
|
import file_cache
|
||||||
import mcp_client
|
import mcp_client
|
||||||
import anthropic
|
import anthropic
|
||||||
@@ -51,6 +55,8 @@ _GEMINI_CACHE_TTL = 3600
|
|||||||
|
|
||||||
_anthropic_client = None
|
_anthropic_client = None
|
||||||
_anthropic_history: list[dict] = []
|
_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.
|
# Injected by gui.py - called when AI wants to run a command.
|
||||||
# Signature: (script: str, base_dir: str) -> str | None
|
# Signature: (script: str, base_dir: str) -> str | None
|
||||||
@@ -67,6 +73,10 @@ tool_log_callback = None
|
|||||||
# Increased to allow thorough code exploration before forcing a summary
|
# Increased to allow thorough code exploration before forcing a summary
|
||||||
MAX_TOOL_ROUNDS = 10
|
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.
|
# Maximum characters per text chunk sent to Anthropic.
|
||||||
# Kept well under the ~200k token API limit.
|
# Kept well under the ~200k token API limit.
|
||||||
_ANTHROPIC_CHUNK_SIZE = 120_000
|
_ANTHROPIC_CHUNK_SIZE = 120_000
|
||||||
@@ -128,8 +138,18 @@ def clear_comms_log():
|
|||||||
|
|
||||||
|
|
||||||
def _load_credentials() -> dict:
|
def _load_credentials() -> dict:
|
||||||
with open("credentials.toml", "rb") as f:
|
cred_path = os.environ.get("SLOP_CREDENTIALS", "credentials.toml")
|
||||||
|
try:
|
||||||
|
with open(cred_path, "rb") as f:
|
||||||
return tomllib.load(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
|
# ------------------------------------------------------------------ provider errors
|
||||||
@@ -244,6 +264,7 @@ def reset_session():
|
|||||||
_gemini_cache_md_hash = None
|
_gemini_cache_md_hash = None
|
||||||
_gemini_cache_created_at = None
|
_gemini_cache_created_at = None
|
||||||
_anthropic_client = None
|
_anthropic_client = None
|
||||||
|
with _anthropic_history_lock:
|
||||||
_anthropic_history = []
|
_anthropic_history = []
|
||||||
_CACHED_ANTHROPIC_TOOLS = None
|
_CACHED_ANTHROPIC_TOOLS = None
|
||||||
file_cache.reset_client()
|
file_cache.reset_client()
|
||||||
@@ -435,6 +456,13 @@ def _run_script(script: str, base_dir: str) -> str:
|
|||||||
return output
|
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
|
# ------------------------------------------------------------------ dynamic file context refresh
|
||||||
|
|
||||||
def _reread_file_items(file_items: list[dict]) -> tuple[list[dict], list[dict]]:
|
def _reread_file_items(file_items: list[dict]) -> tuple[list[dict], list[dict]]:
|
||||||
@@ -460,7 +488,7 @@ def _reread_file_items(file_items: list[dict]) -> tuple[list[dict], list[dict]]:
|
|||||||
refreshed.append(item) # unchanged — skip re-read
|
refreshed.append(item) # unchanged — skip re-read
|
||||||
continue
|
continue
|
||||||
content = p.read_text(encoding="utf-8")
|
content = p.read_text(encoding="utf-8")
|
||||||
new_item = {**item, "content": content, "error": False, "mtime": current_mtime}
|
new_item = {**item, "old_content": item.get("content", ""), "content": content, "error": False, "mtime": current_mtime}
|
||||||
refreshed.append(new_item)
|
refreshed.append(new_item)
|
||||||
changed.append(new_item)
|
changed.append(new_item)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -486,6 +514,35 @@ def _build_file_context_text(file_items: list[dict]) -> str:
|
|||||||
return "\n\n---\n\n".join(parts)
|
return "\n\n---\n\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
_DIFF_LINE_THRESHOLD = 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.
|
||||||
|
"""
|
||||||
|
if not changed_items:
|
||||||
|
return ""
|
||||||
|
parts = []
|
||||||
|
for item in changed_items:
|
||||||
|
path = item.get("path") or item.get("entry", "unknown")
|
||||||
|
content = item.get("content", "")
|
||||||
|
old_content = 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"
|
||||||
|
parts.append(f"### `{path}` (full)\n\n```{suffix}\n{content}\n```")
|
||||||
|
else:
|
||||||
|
old_lines = old_content.splitlines(keepends=True)
|
||||||
|
diff = difflib.unified_diff(old_lines, new_lines, fromfile=str(path), tofile=str(path), lineterm="")
|
||||||
|
diff_text = "\n".join(diff)
|
||||||
|
if diff_text:
|
||||||
|
parts.append(f"### `{path}` (diff)\n\n```diff\n{diff_text}\n```")
|
||||||
|
else:
|
||||||
|
parts.append(f"### `{path}` (no changes detected)")
|
||||||
|
return "\n\n---\n\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ content block serialisation
|
# ------------------------------------------------------------------ content block serialisation
|
||||||
|
|
||||||
def _content_block_to_dict(block) -> dict:
|
def _content_block_to_dict(block) -> dict:
|
||||||
@@ -530,22 +587,26 @@ def _get_gemini_history_list(chat):
|
|||||||
return chat.get_history()
|
return chat.get_history()
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def _send_gemini(md_content: str, user_message: str, base_dir: str, file_items: list[dict] | None = None) -> str:
|
def _send_gemini(md_content: str, user_message: str, base_dir: str,
|
||||||
|
file_items: list[dict] | None = None,
|
||||||
|
discussion_history: str = "") -> str:
|
||||||
global _gemini_chat, _gemini_cache, _gemini_cache_md_hash, _gemini_cache_created_at
|
global _gemini_chat, _gemini_cache, _gemini_cache_md_hash, _gemini_cache_created_at
|
||||||
|
|
||||||
try:
|
try:
|
||||||
_ensure_gemini_client(); mcp_client.configure(file_items or [], [base_dir])
|
_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<context>\n{md_content}\n</context>"
|
sys_instr = f"{_get_combined_system_prompt()}\n\n<context>\n{md_content}\n</context>"
|
||||||
tools_decl = [_gemini_tool_declaration()]
|
tools_decl = [_gemini_tool_declaration()]
|
||||||
|
|
||||||
# DYNAMIC CONTEXT: Check if files/context changed mid-session
|
# DYNAMIC CONTEXT: Check if files/context changed mid-session
|
||||||
current_md_hash = hash(md_content)
|
current_md_hash = hashlib.md5(md_content.encode()).hexdigest()
|
||||||
old_history = None
|
old_history = None
|
||||||
if _gemini_chat and _gemini_cache_md_hash != current_md_hash:
|
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 []
|
old_history = list(_get_gemini_history_list(_gemini_chat)) if _get_gemini_history_list(_gemini_chat) else []
|
||||||
if _gemini_cache:
|
if _gemini_cache:
|
||||||
try: _gemini_client.caches.delete(name=_gemini_cache.name)
|
try: _gemini_client.caches.delete(name=_gemini_cache.name)
|
||||||
except: pass
|
except Exception as e: _append_comms("OUT", "request", {"message": f"[CACHE DELETE WARN] {e}"})
|
||||||
_gemini_chat = None
|
_gemini_chat = None
|
||||||
_gemini_cache = None
|
_gemini_cache = None
|
||||||
_gemini_cache_created_at = None
|
_gemini_cache_created_at = None
|
||||||
@@ -558,7 +619,7 @@ def _send_gemini(md_content: str, user_message: str, base_dir: str, file_items:
|
|||||||
if elapsed > _GEMINI_CACHE_TTL * 0.9:
|
if elapsed > _GEMINI_CACHE_TTL * 0.9:
|
||||||
old_history = list(_get_gemini_history_list(_gemini_chat)) if _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)
|
try: _gemini_client.caches.delete(name=_gemini_cache.name)
|
||||||
except: pass
|
except Exception as e: _append_comms("OUT", "request", {"message": f"[CACHE DELETE WARN] {e}"})
|
||||||
_gemini_chat = None
|
_gemini_chat = None
|
||||||
_gemini_cache = None
|
_gemini_cache = None
|
||||||
_gemini_cache_created_at = None
|
_gemini_cache_created_at = None
|
||||||
@@ -602,8 +663,15 @@ def _send_gemini(md_content: str, user_message: str, base_dir: str, file_items:
|
|||||||
_gemini_chat = _gemini_client.chats.create(**kwargs)
|
_gemini_chat = _gemini_client.chats.create(**kwargs)
|
||||||
_gemini_cache_md_hash = current_md_hash
|
_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"})
|
||||||
|
|
||||||
_append_comms("OUT", "request", {"message": f"[ctx {len(md_content)} + msg {len(user_message)}]"})
|
_append_comms("OUT", "request", {"message": f"[ctx {len(md_content)} + msg {len(user_message)}]"})
|
||||||
payload, all_text = user_message, []
|
payload, all_text = user_message, []
|
||||||
|
_cumulative_tool_bytes = 0
|
||||||
|
|
||||||
# Strip stale file refreshes and truncate old tool outputs ONCE before
|
# Strip stale file refreshes and truncate old tool outputs ONCE before
|
||||||
# entering the tool loop (not per-round — history entries don't change).
|
# entering the tool loop (not per-round — history entries don't change).
|
||||||
@@ -634,37 +702,30 @@ def _send_gemini(md_content: str, user_message: str, base_dir: str, file_items:
|
|||||||
if cached_tokens:
|
if cached_tokens:
|
||||||
usage["cache_read_input_tokens"] = cached_tokens
|
usage["cache_read_input_tokens"] = cached_tokens
|
||||||
|
|
||||||
# Fetch cache stats in the background thread to avoid blocking GUI
|
events.emit("response_received", payload={"provider": "gemini", "model": _model, "usage": usage, "round": r_idx})
|
||||||
cache_stats = None
|
|
||||||
try:
|
|
||||||
cache_stats = get_gemini_cache_stats()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
events.emit("response_received", payload={"provider": "gemini", "model": _model, "usage": usage, "round": r_idx, "cache_stats": cache_stats})
|
|
||||||
|
|
||||||
reason = resp.candidates[0].finish_reason.name if resp.candidates and hasattr(resp.candidates[0], "finish_reason") else "STOP"
|
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})
|
_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: if Gemini reports input tokens approaching the limit, drop oldest history pairs
|
# Guard: proactively trim history when input tokens exceed 40% of limit
|
||||||
total_in = usage.get("input_tokens", 0)
|
total_in = usage.get("input_tokens", 0)
|
||||||
if total_in > _GEMINI_MAX_INPUT_TOKENS and _gemini_chat and _get_gemini_history_list(_gemini_chat):
|
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)
|
hist = _get_gemini_history_list(_gemini_chat)
|
||||||
dropped = 0
|
dropped = 0
|
||||||
# Drop oldest pairs (user+model) but keep at least the last 2 entries
|
# 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.7:
|
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
|
# Drop in pairs (user + model) to maintain alternating roles required by Gemini
|
||||||
saved = 0
|
saved = 0
|
||||||
for _ in range(2):
|
for _ in range(2):
|
||||||
if not hist: break
|
if not hist: break
|
||||||
for p in hist[0].parts:
|
for p in hist[0].parts:
|
||||||
if hasattr(p, "text") and p.text:
|
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:
|
elif hasattr(p, "function_response") and p.function_response:
|
||||||
r = getattr(p.function_response, "response", {})
|
r = getattr(p.function_response, "response", {})
|
||||||
if isinstance(r, dict):
|
if isinstance(r, dict):
|
||||||
saved += len(str(r.get("output", ""))) // 4
|
saved += int(len(str(r.get("output", ""))) / _CHARS_PER_TOKEN)
|
||||||
hist.pop(0)
|
hist.pop(0)
|
||||||
dropped += 1
|
dropped += 1
|
||||||
total_in -= max(saved, 200)
|
total_in -= max(saved, 200)
|
||||||
@@ -689,15 +750,23 @@ def _send_gemini(md_content: str, user_message: str, base_dir: str, file_items:
|
|||||||
if i == len(calls) - 1:
|
if i == len(calls) - 1:
|
||||||
if file_items:
|
if file_items:
|
||||||
file_items, changed = _reread_file_items(file_items)
|
file_items, changed = _reread_file_items(file_items)
|
||||||
ctx = _build_file_context_text(changed)
|
ctx = _build_file_diff_text(changed)
|
||||||
if ctx:
|
if ctx:
|
||||||
out += f"\n\n[SYSTEM: FILES UPDATED]\n\n{ctx}"
|
out += f"\n\n[SYSTEM: FILES UPDATED]\n\n{ctx}"
|
||||||
if r_idx == MAX_TOOL_ROUNDS: out += "\n\n[SYSTEM: MAX ROUNDS. PROVIDE FINAL ANSWER.]"
|
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.from_function_response(name=name, response={"output": out}))
|
||||||
log.append({"tool_use_id": name, "content": out})
|
log.append({"tool_use_id": name, "content": out})
|
||||||
events.emit("tool_execution", payload={"status": "completed", "tool": name, "result": out, "round": r_idx})
|
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})
|
_append_comms("OUT", "tool_result_send", {"results": log})
|
||||||
payload = f_resps
|
payload = f_resps
|
||||||
|
|
||||||
@@ -955,7 +1024,7 @@ def _repair_anthropic_history(history: list[dict]):
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
def _send_anthropic(md_content: str, user_message: str, base_dir: str, file_items: list[dict] | None = None) -> str:
|
def _send_anthropic(md_content: str, user_message: str, base_dir: str, file_items: list[dict] | None = None, discussion_history: str = "") -> str:
|
||||||
try:
|
try:
|
||||||
_ensure_anthropic_client()
|
_ensure_anthropic_client()
|
||||||
mcp_client.configure(file_items or [], [base_dir])
|
mcp_client.configure(file_items or [], [base_dir])
|
||||||
@@ -969,6 +1038,10 @@ def _send_anthropic(md_content: str, user_message: str, base_dir: str, file_item
|
|||||||
context_blocks = _build_chunked_context_blocks(context_text)
|
context_blocks = _build_chunked_context_blocks(context_text)
|
||||||
system_blocks = stable_blocks + context_blocks
|
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 = [{"type": "text", "text": f"[DISCUSSION HISTORY]\n\n{discussion_history}\n\n---\n\n{user_message}"}]
|
||||||
|
else:
|
||||||
user_content = [{"type": "text", "text": user_message}]
|
user_content = [{"type": "text", "text": user_message}]
|
||||||
|
|
||||||
# COMPRESS HISTORY: Truncate massive tool outputs from previous turns
|
# COMPRESS HISTORY: Truncate massive tool outputs from previous turns
|
||||||
@@ -1000,6 +1073,7 @@ def _send_anthropic(md_content: str, user_message: str, base_dir: str, file_item
|
|||||||
})
|
})
|
||||||
|
|
||||||
all_text_parts = []
|
all_text_parts = []
|
||||||
|
_cumulative_tool_bytes = 0
|
||||||
|
|
||||||
# We allow MAX_TOOL_ROUNDS, plus 1 final loop to get the text synthesis
|
# We allow MAX_TOOL_ROUNDS, plus 1 final loop to get the text synthesis
|
||||||
for round_idx in range(MAX_TOOL_ROUNDS + 2):
|
for round_idx in range(MAX_TOOL_ROUNDS + 2):
|
||||||
@@ -1086,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})
|
_append_comms("OUT", "tool_call", {"name": b_name, "id": b_id, "args": b_input})
|
||||||
output = mcp_client.dispatch(b_name, b_input)
|
output = mcp_client.dispatch(b_name, b_input)
|
||||||
_append_comms("IN", "tool_result", {"name": b_name, "id": b_id, "output": output})
|
_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({
|
tool_results.append({
|
||||||
"type": "tool_result",
|
"type": "tool_result",
|
||||||
"tool_use_id": b_id,
|
"tool_use_id": b_id,
|
||||||
"content": output,
|
"content": truncated,
|
||||||
})
|
})
|
||||||
events.emit("tool_execution", payload={"status": "completed", "tool": b_name, "result": output, "round": round_idx})
|
events.emit("tool_execution", payload={"status": "completed", "tool": b_name, "result": output, "round": round_idx})
|
||||||
elif b_name == TOOL_NAME:
|
elif b_name == TOOL_NAME:
|
||||||
@@ -1105,17 +1181,26 @@ def _send_anthropic(md_content: str, user_message: str, base_dir: str, file_item
|
|||||||
"id": b_id,
|
"id": b_id,
|
||||||
"output": output,
|
"output": output,
|
||||||
})
|
})
|
||||||
|
truncated = _truncate_tool_output(output)
|
||||||
|
_cumulative_tool_bytes += len(truncated)
|
||||||
tool_results.append({
|
tool_results.append({
|
||||||
"type": "tool_result",
|
"type": "tool_result",
|
||||||
"tool_use_id": b_id,
|
"tool_use_id": b_id,
|
||||||
"content": output,
|
"content": truncated,
|
||||||
})
|
})
|
||||||
events.emit("tool_execution", payload={"status": "completed", "tool": b_name, "result": output, "round": round_idx})
|
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
|
# Refresh file context after tool calls — only inject CHANGED files
|
||||||
if file_items:
|
if file_items:
|
||||||
file_items, changed = _reread_file_items(file_items)
|
file_items, changed = _reread_file_items(file_items)
|
||||||
refreshed_ctx = _build_file_context_text(changed)
|
refreshed_ctx = _build_file_diff_text(changed)
|
||||||
if refreshed_ctx:
|
if refreshed_ctx:
|
||||||
tool_results.append({
|
tool_results.append({
|
||||||
"type": "text",
|
"type": "text",
|
||||||
@@ -1160,20 +1245,25 @@ def send(
|
|||||||
user_message: str,
|
user_message: str,
|
||||||
base_dir: str = ".",
|
base_dir: str = ".",
|
||||||
file_items: list[dict] | None = None,
|
file_items: list[dict] | None = None,
|
||||||
|
discussion_history: str = "",
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Send a message to the active provider.
|
Send a message to the active provider.
|
||||||
|
|
||||||
md_content : aggregated markdown string from aggregate.run()
|
md_content : aggregated markdown string (for Gemini: stable content only,
|
||||||
user_message: the user question / instruction
|
for Anthropic: full content including history)
|
||||||
|
user_message : the user question / instruction
|
||||||
base_dir : project base directory (for PowerShell tool calls)
|
base_dir : project base directory (for PowerShell tool calls)
|
||||||
file_items : list of file dicts from aggregate.build_file_items() for
|
file_items : list of file dicts from aggregate.build_file_items() for
|
||||||
dynamic context refresh after tool calls
|
dynamic context refresh after tool calls
|
||||||
|
discussion_history : discussion history text (used by Gemini to inject as
|
||||||
|
conversation message instead of caching it)
|
||||||
"""
|
"""
|
||||||
|
with _send_lock:
|
||||||
if _provider == "gemini":
|
if _provider == "gemini":
|
||||||
return _send_gemini(md_content, user_message, base_dir, file_items)
|
return _send_gemini(md_content, user_message, base_dir, file_items, discussion_history)
|
||||||
elif _provider == "anthropic":
|
elif _provider == "anthropic":
|
||||||
return _send_anthropic(md_content, user_message, base_dir, file_items)
|
return _send_anthropic(md_content, user_message, base_dir, file_items, discussion_history)
|
||||||
raise ValueError(f"unknown provider: {_provider}")
|
raise ValueError(f"unknown provider: {_provider}")
|
||||||
|
|
||||||
def get_history_bleed_stats() -> dict:
|
def get_history_bleed_stats() -> dict:
|
||||||
@@ -1182,7 +1272,9 @@ def get_history_bleed_stats() -> dict:
|
|||||||
"""
|
"""
|
||||||
if _provider == "anthropic":
|
if _provider == "anthropic":
|
||||||
# For Anthropic, we have a robust estimator
|
# 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
|
limit_tokens = _ANTHROPIC_MAX_PROMPT_TOKENS
|
||||||
percentage = (current_tokens / limit_tokens) * 100 if limit_tokens > 0 else 0
|
percentage = (current_tokens / limit_tokens) * 100 if limit_tokens > 0 else 0
|
||||||
return {
|
return {
|
||||||
|
|||||||
14
config.toml
14
config.toml
@@ -18,3 +18,17 @@ paths = [
|
|||||||
"C:/projects/forth/bootslop/bootslop.toml",
|
"C:/projects/forth/bootslop/bootslop.toml",
|
||||||
]
|
]
|
||||||
active = "manual_slop.toml"
|
active = "manual_slop.toml"
|
||||||
|
|
||||||
|
[gui.show_windows]
|
||||||
|
Projects = true
|
||||||
|
Files = true
|
||||||
|
Screenshots = true
|
||||||
|
"Discussion History" = true
|
||||||
|
Provider = true
|
||||||
|
Message = true
|
||||||
|
Response = true
|
||||||
|
"Tool Calls" = true
|
||||||
|
"Comms History" = true
|
||||||
|
"System Prompts" = true
|
||||||
|
Theme = true
|
||||||
|
Diagnostics = true
|
||||||
|
|||||||
354
gui_2.py
354
gui_2.py
@@ -4,6 +4,8 @@ import threading
|
|||||||
import time
|
import time
|
||||||
import math
|
import math
|
||||||
import json
|
import json
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from tkinter import filedialog, Tk
|
from tkinter import filedialog, Tk
|
||||||
import aggregate
|
import aggregate
|
||||||
@@ -14,6 +16,9 @@ import session_logger
|
|||||||
import project_manager
|
import project_manager
|
||||||
import theme_2 as theme
|
import theme_2 as theme
|
||||||
import tomllib
|
import tomllib
|
||||||
|
import numpy as np
|
||||||
|
import api_hooks
|
||||||
|
from performance_monitor import PerformanceMonitor
|
||||||
|
|
||||||
from imgui_bundle import imgui, hello_imgui, immapp
|
from imgui_bundle import imgui, hello_imgui, immapp
|
||||||
|
|
||||||
@@ -56,6 +61,15 @@ KIND_COLORS = {"request": C_REQ, "response": C_RES, "tool_call": C_TC, "tool_res
|
|||||||
HEAVY_KEYS = {"message", "text", "script", "output", "content"}
|
HEAVY_KEYS = {"message", "text", "script", "output", "content"}
|
||||||
|
|
||||||
DISC_ROLES = ["User", "AI", "Vendor API", "System"]
|
DISC_ROLES = ["User", "AI", "Vendor API", "System"]
|
||||||
|
AGENT_TOOL_NAMES = ["run_powershell", "read_file", "list_directory", "search_files", "get_file_summary", "web_search", "fetch_url"]
|
||||||
|
|
||||||
|
def truncate_entries(entries: list[dict], max_pairs: int) -> list[dict]:
|
||||||
|
if max_pairs <= 0:
|
||||||
|
return []
|
||||||
|
target_count = max_pairs * 2
|
||||||
|
if len(entries) <= target_count:
|
||||||
|
return entries
|
||||||
|
return entries[-target_count:]
|
||||||
|
|
||||||
def _parse_history_entries(history: list[str], roles: list[str] | None = None) -> list[dict]:
|
def _parse_history_entries(history: list[str], roles: list[str] | None = None) -> list[dict]:
|
||||||
known = roles if roles is not None else DISC_ROLES
|
known = roles if roles is not None else DISC_ROLES
|
||||||
@@ -119,6 +133,7 @@ class App:
|
|||||||
self.ui_project_main_context = proj_meta.get("main_context", "")
|
self.ui_project_main_context = proj_meta.get("main_context", "")
|
||||||
self.ui_project_system_prompt = proj_meta.get("system_prompt", "")
|
self.ui_project_system_prompt = proj_meta.get("system_prompt", "")
|
||||||
self.ui_word_wrap = proj_meta.get("word_wrap", True)
|
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_auto_add_history = disc_sec.get("auto_add", False)
|
||||||
|
|
||||||
self.ui_global_system_prompt = self.config.get("ai", {}).get("system_prompt", "")
|
self.ui_global_system_prompt = self.config.get("ai", {}).get("system_prompt", "")
|
||||||
@@ -137,9 +152,10 @@ class App:
|
|||||||
self.last_file_items: list = []
|
self.last_file_items: list = []
|
||||||
|
|
||||||
self.send_thread: threading.Thread | None = None
|
self.send_thread: threading.Thread | None = None
|
||||||
|
self._send_thread_lock = threading.Lock()
|
||||||
self.models_thread: threading.Thread | None = None
|
self.models_thread: threading.Thread | None = None
|
||||||
|
|
||||||
self.show_windows = {
|
_default_windows = {
|
||||||
"Projects": True,
|
"Projects": True,
|
||||||
"Files": True,
|
"Files": True,
|
||||||
"Screenshots": True,
|
"Screenshots": True,
|
||||||
@@ -151,7 +167,10 @@ class App:
|
|||||||
"Comms History": True,
|
"Comms History": True,
|
||||||
"System Prompts": True,
|
"System Prompts": True,
|
||||||
"Theme": True,
|
"Theme": True,
|
||||||
|
"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_script_output = False
|
||||||
self.show_text_viewer = False
|
self.show_text_viewer = False
|
||||||
self.text_viewer_title = ""
|
self.text_viewer_title = ""
|
||||||
@@ -181,12 +200,53 @@ class App:
|
|||||||
|
|
||||||
self._scroll_disc_to_bottom = False
|
self._scroll_disc_to_bottom = False
|
||||||
|
|
||||||
|
# GUI Task Queue (thread-safe, for event handlers and hook server)
|
||||||
|
self._pending_gui_tasks: list[dict] = []
|
||||||
|
self._pending_gui_tasks_lock = threading.Lock()
|
||||||
|
|
||||||
|
# Session usage tracking
|
||||||
|
self.session_usage = {"input_tokens": 0, "output_tokens": 0, "cache_read_input_tokens": 0, "cache_creation_input_tokens": 0}
|
||||||
|
|
||||||
|
# Token budget / cache telemetry
|
||||||
|
self._token_budget_pct = 0.0
|
||||||
|
self._token_budget_current = 0
|
||||||
|
self._token_budget_limit = 0
|
||||||
|
self._gemini_cache_text = ""
|
||||||
|
|
||||||
|
# Discussion truncation
|
||||||
|
self.ui_disc_truncate_pairs: int = 2
|
||||||
|
|
||||||
|
# Agent tools config
|
||||||
|
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}
|
||||||
|
|
||||||
|
# Prior session log viewing
|
||||||
|
self.is_viewing_prior_session = False
|
||||||
|
self.prior_session_entries: list[dict] = []
|
||||||
|
|
||||||
|
# API Hooks
|
||||||
|
self.test_hooks_enabled = ("--enable-test-hooks" in sys.argv) or (os.environ.get("SLOP_TEST_HOOKS") == "1")
|
||||||
|
|
||||||
|
# Performance monitoring
|
||||||
|
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
|
||||||
|
|
||||||
|
# Auto-save timer (every 60s)
|
||||||
|
self._autosave_interval = 60.0
|
||||||
|
self._last_autosave = time.time()
|
||||||
|
|
||||||
session_logger.open_session()
|
session_logger.open_session()
|
||||||
ai_client.set_provider(self.current_provider, self.current_model)
|
ai_client.set_provider(self.current_provider, self.current_model)
|
||||||
ai_client.confirm_and_run_callback = self._confirm_and_run
|
ai_client.confirm_and_run_callback = self._confirm_and_run
|
||||||
ai_client.comms_log_callback = self._on_comms_entry
|
ai_client.comms_log_callback = self._on_comms_entry
|
||||||
ai_client.tool_log_callback = self._on_tool_log
|
ai_client.tool_log_callback = self._on_tool_log
|
||||||
|
|
||||||
|
# AI client event subscriptions
|
||||||
|
ai_client.events.on("request_start", self._on_api_event)
|
||||||
|
ai_client.events.on("response_received", self._on_api_event)
|
||||||
|
ai_client.events.on("tool_execution", self._on_api_event)
|
||||||
|
|
||||||
# ---------------------------------------------------------------- project loading
|
# ---------------------------------------------------------------- project loading
|
||||||
|
|
||||||
def _load_active_project(self):
|
def _load_active_project(self):
|
||||||
@@ -253,6 +313,10 @@ class App:
|
|||||||
self.ui_project_main_context = proj.get("project", {}).get("main_context", "")
|
self.ui_project_main_context = proj.get("project", {}).get("main_context", "")
|
||||||
self.ui_auto_add_history = proj.get("discussion", {}).get("auto_add", False)
|
self.ui_auto_add_history = proj.get("discussion", {}).get("auto_add", False)
|
||||||
self.ui_word_wrap = proj.get("project", {}).get("word_wrap", True)
|
self.ui_word_wrap = proj.get("project", {}).get("word_wrap", True)
|
||||||
|
self.ui_summary_only = proj.get("project", {}).get("summary_only", False)
|
||||||
|
|
||||||
|
agent_tools_cfg = proj.get("agent", {}).get("tools", {})
|
||||||
|
self.ui_agent_tools = {t: agent_tools_cfg.get(t, True) for t in AGENT_TOOL_NAMES}
|
||||||
|
|
||||||
def _save_active_project(self):
|
def _save_active_project(self):
|
||||||
if self.active_project_path:
|
if self.active_project_path:
|
||||||
@@ -337,6 +401,76 @@ class App:
|
|||||||
def _on_tool_log(self, script: str, result: str):
|
def _on_tool_log(self, script: str, result: str):
|
||||||
session_logger.log_tool_call(script, result, None)
|
session_logger.log_tool_call(script, result, None)
|
||||||
|
|
||||||
|
def _on_api_event(self, *args, **kwargs):
|
||||||
|
payload = kwargs.get("payload", {})
|
||||||
|
with self._pending_gui_tasks_lock:
|
||||||
|
self._pending_gui_tasks.append({"action": "refresh_api_metrics", "payload": payload})
|
||||||
|
|
||||||
|
def _process_pending_gui_tasks(self):
|
||||||
|
if not self._pending_gui_tasks:
|
||||||
|
return
|
||||||
|
with self._pending_gui_tasks_lock:
|
||||||
|
tasks = self._pending_gui_tasks[:]
|
||||||
|
self._pending_gui_tasks.clear()
|
||||||
|
for task in tasks:
|
||||||
|
try:
|
||||||
|
action = task.get("action")
|
||||||
|
if action == "refresh_api_metrics":
|
||||||
|
self._refresh_api_metrics(task.get("payload", {}))
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error executing GUI task: {e}")
|
||||||
|
|
||||||
|
def _recalculate_session_usage(self):
|
||||||
|
usage = {"input_tokens": 0, "output_tokens": 0, "cache_read_input_tokens": 0, "cache_creation_input_tokens": 0}
|
||||||
|
for entry in ai_client.get_comms_log():
|
||||||
|
if entry.get("kind") == "response" and "usage" in entry.get("payload", {}):
|
||||||
|
u = entry["payload"]["usage"]
|
||||||
|
for k in usage.keys():
|
||||||
|
usage[k] += u.get(k, 0) or 0
|
||||||
|
self.session_usage = usage
|
||||||
|
|
||||||
|
def _refresh_api_metrics(self, payload: dict):
|
||||||
|
self._recalculate_session_usage()
|
||||||
|
try:
|
||||||
|
stats = ai_client.get_history_bleed_stats()
|
||||||
|
self._token_budget_pct = stats.get("percentage", 0.0) / 100.0
|
||||||
|
self._token_budget_current = stats.get("current", 0)
|
||||||
|
self._token_budget_limit = stats.get("limit", 0)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
cache_stats = payload.get("cache_stats")
|
||||||
|
if cache_stats:
|
||||||
|
count = cache_stats.get("cache_count", 0)
|
||||||
|
size_bytes = cache_stats.get("total_size_bytes", 0)
|
||||||
|
self._gemini_cache_text = f"Gemini Caches: {count} ({size_bytes / 1024:.1f} KB)"
|
||||||
|
|
||||||
|
def cb_load_prior_log(self):
|
||||||
|
root = hide_tk_root()
|
||||||
|
path = filedialog.askopenfilename(
|
||||||
|
title="Load Session Log",
|
||||||
|
initialdir="logs",
|
||||||
|
filetypes=[("Log/JSONL", "*.log *.jsonl"), ("All Files", "*.*")]
|
||||||
|
)
|
||||||
|
root.destroy()
|
||||||
|
if not path:
|
||||||
|
return
|
||||||
|
entries = []
|
||||||
|
try:
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.strip()
|
||||||
|
if line:
|
||||||
|
try:
|
||||||
|
entries.append(json.loads(line))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
except Exception as e:
|
||||||
|
self.ai_status = f"log load error: {e}"
|
||||||
|
return
|
||||||
|
self.prior_session_entries = entries
|
||||||
|
self.is_viewing_prior_session = True
|
||||||
|
self.ai_status = f"viewing prior session: {Path(path).name} ({len(entries)} entries)"
|
||||||
|
|
||||||
def _confirm_and_run(self, script: str, base_dir: str) -> str | None:
|
def _confirm_and_run(self, script: str, base_dir: str) -> str | None:
|
||||||
dialog = ConfirmDialog(script, base_dir)
|
dialog = ConfirmDialog(script, base_dir)
|
||||||
with self._pending_dialog_lock:
|
with self._pending_dialog_lock:
|
||||||
@@ -373,6 +507,11 @@ class App:
|
|||||||
proj["project"]["system_prompt"] = self.ui_project_system_prompt
|
proj["project"]["system_prompt"] = self.ui_project_system_prompt
|
||||||
proj["project"]["main_context"] = self.ui_project_main_context
|
proj["project"]["main_context"] = self.ui_project_main_context
|
||||||
proj["project"]["word_wrap"] = self.ui_word_wrap
|
proj["project"]["word_wrap"] = self.ui_word_wrap
|
||||||
|
proj["project"]["summary_only"] = self.ui_summary_only
|
||||||
|
|
||||||
|
proj.setdefault("agent", {}).setdefault("tools", {})
|
||||||
|
for t_name in AGENT_TOOL_NAMES:
|
||||||
|
proj["agent"]["tools"][t_name] = self.ui_agent_tools.get(t_name, True)
|
||||||
|
|
||||||
self._flush_disc_entries_to_project()
|
self._flush_disc_entries_to_project()
|
||||||
disc_sec = proj.setdefault("discussion", {})
|
disc_sec = proj.setdefault("discussion", {})
|
||||||
@@ -390,15 +529,26 @@ class App:
|
|||||||
}
|
}
|
||||||
self.config["ai"]["system_prompt"] = self.ui_global_system_prompt
|
self.config["ai"]["system_prompt"] = self.ui_global_system_prompt
|
||||||
self.config["projects"] = {"paths": self.project_paths, "active": self.active_project_path}
|
self.config["projects"] = {"paths": self.project_paths, "active": self.active_project_path}
|
||||||
|
self.config["gui"] = {"show_windows": self.show_windows}
|
||||||
theme.save_to_config(self.config)
|
theme.save_to_config(self.config)
|
||||||
|
|
||||||
def _do_generate(self) -> tuple[str, Path, list]:
|
def _do_generate(self) -> tuple[str, Path, list, str, str]:
|
||||||
|
"""Returns (full_md, output_path, file_items, stable_md, discussion_text)."""
|
||||||
self._flush_to_project()
|
self._flush_to_project()
|
||||||
self._save_active_project()
|
self._save_active_project()
|
||||||
self._flush_to_config()
|
self._flush_to_config()
|
||||||
save_config(self.config)
|
save_config(self.config)
|
||||||
flat = project_manager.flat_config(self.project, self.active_discussion)
|
flat = project_manager.flat_config(self.project, self.active_discussion)
|
||||||
return aggregate.run(flat)
|
full_md, path, file_items = aggregate.run(flat)
|
||||||
|
# Build stable markdown (no history) for Gemini caching
|
||||||
|
screenshot_base_dir = Path(flat.get("screenshots", {}).get("base_dir", "."))
|
||||||
|
screenshots = flat.get("screenshots", {}).get("paths", [])
|
||||||
|
summary_only = flat.get("project", {}).get("summary_only", False)
|
||||||
|
stable_md = aggregate.build_markdown_no_history(file_items, screenshot_base_dir, screenshots, summary_only=summary_only)
|
||||||
|
# Build discussion history text separately
|
||||||
|
history = flat.get("discussion", {}).get("history", [])
|
||||||
|
discussion_text = aggregate.build_discussion_text(history)
|
||||||
|
return full_md, path, file_items, stable_md, discussion_text
|
||||||
|
|
||||||
def _fetch_models(self, provider: str):
|
def _fetch_models(self, provider: str):
|
||||||
self.ai_status = "fetching models..."
|
self.ai_status = "fetching models..."
|
||||||
@@ -445,6 +595,23 @@ class App:
|
|||||||
# ---------------------------------------------------------------- gui
|
# ---------------------------------------------------------------- gui
|
||||||
|
|
||||||
def _gui_func(self):
|
def _gui_func(self):
|
||||||
|
self.perf_monitor.start_frame()
|
||||||
|
|
||||||
|
# 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
|
# Sync pending comms
|
||||||
with self._pending_comms_lock:
|
with self._pending_comms_lock:
|
||||||
for c in self._pending_comms:
|
for c in self._pending_comms:
|
||||||
@@ -576,6 +743,14 @@ class App:
|
|||||||
self.ai_status = "config saved"
|
self.ai_status = "config saved"
|
||||||
|
|
||||||
ch, self.ui_word_wrap = imgui.checkbox("Word-Wrap (Read-only panels)", self.ui_word_wrap)
|
ch, self.ui_word_wrap = imgui.checkbox("Word-Wrap (Read-only panels)", self.ui_word_wrap)
|
||||||
|
ch, self.ui_summary_only = imgui.checkbox("Summary Only (send file structure, not full content)", self.ui_summary_only)
|
||||||
|
|
||||||
|
if imgui.collapsing_header("Agent Tools"):
|
||||||
|
for t_name in AGENT_TOOL_NAMES:
|
||||||
|
val = self.ui_agent_tools.get(t_name, True)
|
||||||
|
ch, val = imgui.checkbox(f"Enable {t_name}", val)
|
||||||
|
if ch:
|
||||||
|
self.ui_agent_tools[t_name] = val
|
||||||
imgui.end()
|
imgui.end()
|
||||||
|
|
||||||
# ---- Files
|
# ---- Files
|
||||||
@@ -655,7 +830,50 @@ class App:
|
|||||||
if self.show_windows["Discussion History"]:
|
if self.show_windows["Discussion History"]:
|
||||||
exp, self.show_windows["Discussion History"] = imgui.begin("Discussion History", self.show_windows["Discussion History"])
|
exp, self.show_windows["Discussion History"] = imgui.begin("Discussion History", self.show_windows["Discussion History"])
|
||||||
if exp:
|
if exp:
|
||||||
if imgui.collapsing_header("Discussions", imgui.TreeNodeFlags_.default_open):
|
# THINKING indicator
|
||||||
|
is_thinking = self.ai_status in ["sending..."]
|
||||||
|
if is_thinking:
|
||||||
|
val = math.sin(time.time() * 10 * math.pi)
|
||||||
|
alpha = 1.0 if val > 0 else 0.0
|
||||||
|
imgui.text_colored(imgui.ImVec4(1.0, 0.39, 0.39, alpha), "THINKING...")
|
||||||
|
imgui.separator()
|
||||||
|
|
||||||
|
# Prior session viewing mode
|
||||||
|
if self.is_viewing_prior_session:
|
||||||
|
imgui.push_style_color(imgui.Col_.child_bg, vec4(50, 40, 20))
|
||||||
|
imgui.text_colored(vec4(255, 200, 100), "VIEWING PRIOR SESSION")
|
||||||
|
imgui.same_line()
|
||||||
|
if imgui.button("Exit Prior Session"):
|
||||||
|
self.is_viewing_prior_session = False
|
||||||
|
self.prior_session_entries.clear()
|
||||||
|
imgui.separator()
|
||||||
|
imgui.begin_child("prior_scroll", imgui.ImVec2(0, 0), False)
|
||||||
|
for idx, entry in enumerate(self.prior_session_entries):
|
||||||
|
imgui.push_id(f"prior_{idx}")
|
||||||
|
kind = entry.get("kind", entry.get("type", ""))
|
||||||
|
imgui.text_colored(C_LBL, f"#{idx+1}")
|
||||||
|
imgui.same_line()
|
||||||
|
ts = entry.get("ts", entry.get("timestamp", ""))
|
||||||
|
if ts:
|
||||||
|
imgui.text_colored(vec4(160, 160, 160), str(ts))
|
||||||
|
imgui.same_line()
|
||||||
|
imgui.text_colored(C_KEY, str(kind))
|
||||||
|
payload = entry.get("payload", entry)
|
||||||
|
text = payload.get("text", payload.get("message", payload.get("content", "")))
|
||||||
|
if text:
|
||||||
|
preview = str(text).replace("\n", " ")[:200]
|
||||||
|
if self.ui_word_wrap:
|
||||||
|
imgui.push_text_wrap_pos(imgui.get_content_region_avail().x)
|
||||||
|
imgui.text(preview)
|
||||||
|
imgui.pop_text_wrap_pos()
|
||||||
|
else:
|
||||||
|
imgui.text(preview)
|
||||||
|
imgui.separator()
|
||||||
|
imgui.pop_id()
|
||||||
|
imgui.end_child()
|
||||||
|
imgui.pop_style_color()
|
||||||
|
|
||||||
|
if not self.is_viewing_prior_session and imgui.collapsing_header("Discussions", imgui.TreeNodeFlags_.default_open):
|
||||||
names = self._get_discussion_names()
|
names = self._get_discussion_names()
|
||||||
|
|
||||||
if imgui.begin_combo("##disc_sel", self.active_discussion):
|
if imgui.begin_combo("##disc_sel", self.active_discussion):
|
||||||
@@ -702,6 +920,7 @@ class App:
|
|||||||
if imgui.button("Delete"):
|
if imgui.button("Delete"):
|
||||||
self._delete_discussion(self.active_discussion)
|
self._delete_discussion(self.active_discussion)
|
||||||
|
|
||||||
|
if not self.is_viewing_prior_session:
|
||||||
imgui.separator()
|
imgui.separator()
|
||||||
if imgui.button("+ Entry"):
|
if imgui.button("+ Entry"):
|
||||||
self.disc_entries.append({"role": self.disc_roles[0] if self.disc_roles else "User", "content": "", "collapsed": False, "ts": project_manager.now_ts()})
|
self.disc_entries.append({"role": self.disc_roles[0] if self.disc_roles else "User", "content": "", "collapsed": False, "ts": project_manager.now_ts()})
|
||||||
@@ -721,8 +940,22 @@ class App:
|
|||||||
self._flush_to_config()
|
self._flush_to_config()
|
||||||
save_config(self.config)
|
save_config(self.config)
|
||||||
self.ai_status = "discussion saved"
|
self.ai_status = "discussion saved"
|
||||||
|
imgui.same_line()
|
||||||
|
if imgui.button("Load Log"):
|
||||||
|
self.cb_load_prior_log()
|
||||||
|
|
||||||
ch, self.ui_auto_add_history = imgui.checkbox("Auto-add message & response to history", self.ui_auto_add_history)
|
ch, self.ui_auto_add_history = imgui.checkbox("Auto-add message & response to history", self.ui_auto_add_history)
|
||||||
|
|
||||||
|
# Truncation controls
|
||||||
|
imgui.text("Keep Pairs:")
|
||||||
|
imgui.same_line()
|
||||||
|
imgui.set_next_item_width(80)
|
||||||
|
ch, self.ui_disc_truncate_pairs = imgui.input_int("##trunc_pairs", self.ui_disc_truncate_pairs, 1)
|
||||||
|
if self.ui_disc_truncate_pairs < 1: self.ui_disc_truncate_pairs = 1
|
||||||
|
imgui.same_line()
|
||||||
|
if imgui.button("Truncate"):
|
||||||
|
self.disc_entries = truncate_entries(self.disc_entries, self.ui_disc_truncate_pairs)
|
||||||
|
self.ai_status = f"history truncated to {self.ui_disc_truncate_pairs} pairs"
|
||||||
imgui.separator()
|
imgui.separator()
|
||||||
|
|
||||||
if imgui.collapsing_header("Roles"):
|
if imgui.collapsing_header("Roles"):
|
||||||
@@ -836,16 +1069,48 @@ class App:
|
|||||||
ch, self.temperature = imgui.slider_float("Temperature", self.temperature, 0.0, 2.0, "%.2f")
|
ch, self.temperature = imgui.slider_float("Temperature", self.temperature, 0.0, 2.0, "%.2f")
|
||||||
ch, self.max_tokens = imgui.input_int("Max Tokens (Output)", self.max_tokens, 1024)
|
ch, self.max_tokens = imgui.input_int("Max Tokens (Output)", self.max_tokens, 1024)
|
||||||
ch, self.history_trunc_limit = imgui.input_int("History Truncation Limit", self.history_trunc_limit, 1024)
|
ch, self.history_trunc_limit = imgui.input_int("History Truncation Limit", self.history_trunc_limit, 1024)
|
||||||
|
|
||||||
|
imgui.separator()
|
||||||
|
imgui.text("Telemetry")
|
||||||
|
usage = self.session_usage
|
||||||
|
total = usage["input_tokens"] + usage["output_tokens"]
|
||||||
|
imgui.text_colored(C_RES, f"Tokens: {total:,} (In: {usage['input_tokens']:,} Out: {usage['output_tokens']:,})")
|
||||||
|
if usage["cache_read_input_tokens"]:
|
||||||
|
imgui.text_colored(C_LBL, f" Cache Read: {usage['cache_read_input_tokens']:,} Creation: {usage['cache_creation_input_tokens']:,}")
|
||||||
|
imgui.text("Token Budget:")
|
||||||
|
imgui.progress_bar(self._token_budget_pct, imgui.ImVec2(-1, 0), f"{self._token_budget_current:,} / {self._token_budget_limit:,}")
|
||||||
|
if self._gemini_cache_text:
|
||||||
|
imgui.text_colored(C_SUB, self._gemini_cache_text)
|
||||||
imgui.end()
|
imgui.end()
|
||||||
|
|
||||||
# ---- Message
|
# ---- Message
|
||||||
if self.show_windows["Message"]:
|
if self.show_windows["Message"]:
|
||||||
exp, self.show_windows["Message"] = imgui.begin("Message", self.show_windows["Message"])
|
exp, self.show_windows["Message"] = imgui.begin("Message", self.show_windows["Message"])
|
||||||
if exp:
|
if exp:
|
||||||
ch, self.ui_ai_input = imgui.input_text_multiline("##ai_in", self.ui_ai_input, imgui.ImVec2(-1, -40))
|
# LIVE indicator
|
||||||
|
is_live = self.ai_status in ["running powershell...", "fetching url...", "searching web...", "powershell done, awaiting AI..."]
|
||||||
|
if is_live:
|
||||||
|
val = math.sin(time.time() * 10 * math.pi)
|
||||||
|
alpha = 1.0 if val > 0 else 0.0
|
||||||
|
imgui.text_colored(imgui.ImVec4(0.39, 1.0, 0.39, alpha), "LIVE")
|
||||||
imgui.separator()
|
imgui.separator()
|
||||||
if imgui.button("Gen + Send"):
|
|
||||||
if not (self.send_thread and self.send_thread.is_alive()):
|
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()
|
||||||
|
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:
|
try:
|
||||||
md, path, file_items, stable_md, disc_text = self._do_generate()
|
md, path, file_items, stable_md, disc_text = self._do_generate()
|
||||||
self.last_md = md
|
self.last_md = md
|
||||||
@@ -859,14 +1124,17 @@ class App:
|
|||||||
base_dir = self.ui_files_base_dir
|
base_dir = self.ui_files_base_dir
|
||||||
csp = filter(bool, [self.ui_global_system_prompt.strip(), self.ui_project_system_prompt.strip()])
|
csp = filter(bool, [self.ui_global_system_prompt.strip(), self.ui_project_system_prompt.strip()])
|
||||||
ai_client.set_custom_system_prompt("\n\n".join(csp))
|
ai_client.set_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)
|
||||||
|
send_md = stable_md
|
||||||
|
send_disc = disc_text
|
||||||
|
|
||||||
def do_send():
|
def do_send():
|
||||||
if self.ui_auto_add_history:
|
if self.ui_auto_add_history:
|
||||||
with self._pending_history_adds_lock:
|
with self._pending_history_adds_lock:
|
||||||
self._pending_history_adds.append({"role": "User", "content": user_msg, "collapsed": False, "ts": project_manager.now_ts()})
|
self._pending_history_adds.append({"role": "User", "content": user_msg, "collapsed": False, "ts": project_manager.now_ts()})
|
||||||
try:
|
try:
|
||||||
resp = ai_client.send(self.last_md, user_msg, base_dir, self.last_file_items)
|
resp = ai_client.send(send_md, user_msg, base_dir, self.last_file_items, send_disc)
|
||||||
self.ai_response = resp
|
self.ai_response = resp
|
||||||
self.ai_status = "done"
|
self.ai_status = "done"
|
||||||
self._trigger_blink = True
|
self._trigger_blink = True
|
||||||
@@ -888,6 +1156,7 @@ class App:
|
|||||||
with self._pending_history_adds_lock:
|
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._pending_history_adds.append({"role": "System", "content": self.ai_response, "collapsed": False, "ts": project_manager.now_ts()})
|
||||||
|
|
||||||
|
with self._send_thread_lock:
|
||||||
self.send_thread = threading.Thread(target=do_send, daemon=True)
|
self.send_thread = threading.Thread(target=do_send, daemon=True)
|
||||||
self.send_thread.start()
|
self.send_thread.start()
|
||||||
imgui.same_line()
|
imgui.same_line()
|
||||||
@@ -1168,6 +1437,67 @@ class App:
|
|||||||
if ch: theme.set_scale(scale)
|
if ch: theme.set_scale(scale)
|
||||||
imgui.end()
|
imgui.end()
|
||||||
|
|
||||||
|
# ---- Diagnostics
|
||||||
|
if self.show_windows["Diagnostics"]:
|
||||||
|
exp, self.show_windows["Diagnostics"] = imgui.begin("Diagnostics", self.show_windows["Diagnostics"])
|
||||||
|
if exp:
|
||||||
|
now = time.time()
|
||||||
|
if now - self._perf_last_update >= 0.5:
|
||||||
|
self._perf_last_update = now
|
||||||
|
metrics = self.perf_monitor.get_metrics()
|
||||||
|
self.perf_history["frame_time"].pop(0)
|
||||||
|
self.perf_history["frame_time"].append(metrics.get("last_frame_time_ms", 0.0))
|
||||||
|
self.perf_history["fps"].pop(0)
|
||||||
|
self.perf_history["fps"].append(metrics.get("fps", 0.0))
|
||||||
|
self.perf_history["cpu"].pop(0)
|
||||||
|
self.perf_history["cpu"].append(metrics.get("cpu_percent", 0.0))
|
||||||
|
self.perf_history["input_lag"].pop(0)
|
||||||
|
self.perf_history["input_lag"].append(metrics.get("input_lag_ms", 0.0))
|
||||||
|
|
||||||
|
metrics = self.perf_monitor.get_metrics()
|
||||||
|
imgui.text("Performance Telemetry")
|
||||||
|
imgui.separator()
|
||||||
|
|
||||||
|
if imgui.begin_table("perf_table", 2, imgui.TableFlags_.borders_inner_h):
|
||||||
|
imgui.table_setup_column("Metric")
|
||||||
|
imgui.table_setup_column("Value")
|
||||||
|
imgui.table_headers_row()
|
||||||
|
|
||||||
|
imgui.table_next_row()
|
||||||
|
imgui.table_next_column()
|
||||||
|
imgui.text("FPS")
|
||||||
|
imgui.table_next_column()
|
||||||
|
imgui.text(f"{metrics.get('fps', 0.0):.1f}")
|
||||||
|
|
||||||
|
imgui.table_next_row()
|
||||||
|
imgui.table_next_column()
|
||||||
|
imgui.text("Frame Time (ms)")
|
||||||
|
imgui.table_next_column()
|
||||||
|
imgui.text(f"{metrics.get('last_frame_time_ms', 0.0):.2f}")
|
||||||
|
|
||||||
|
imgui.table_next_row()
|
||||||
|
imgui.table_next_column()
|
||||||
|
imgui.text("CPU %")
|
||||||
|
imgui.table_next_column()
|
||||||
|
imgui.text(f"{metrics.get('cpu_percent', 0.0):.1f}")
|
||||||
|
|
||||||
|
imgui.table_next_row()
|
||||||
|
imgui.table_next_column()
|
||||||
|
imgui.text("Input Lag (ms)")
|
||||||
|
imgui.table_next_column()
|
||||||
|
imgui.text(f"{metrics.get('input_lag_ms', 0.0):.1f}")
|
||||||
|
|
||||||
|
imgui.end_table()
|
||||||
|
|
||||||
|
imgui.separator()
|
||||||
|
imgui.text("Frame Time (ms)")
|
||||||
|
imgui.plot_lines("##ft_plot", np.array(self.perf_history["frame_time"], dtype=np.float32), overlay_text="frame_time", graph_size=imgui.ImVec2(-1, 60))
|
||||||
|
imgui.text("CPU %")
|
||||||
|
imgui.plot_lines("##cpu_plot", np.array(self.perf_history["cpu"], dtype=np.float32), overlay_text="cpu", graph_size=imgui.ImVec2(-1, 60))
|
||||||
|
imgui.end()
|
||||||
|
|
||||||
|
self.perf_monitor.end_frame()
|
||||||
|
|
||||||
# ---- Modals / Popups
|
# ---- Modals / Popups
|
||||||
with self._pending_dialog_lock:
|
with self._pending_dialog_lock:
|
||||||
dlg = self._pending_dialog
|
dlg = self._pending_dialog
|
||||||
@@ -1294,9 +1624,15 @@ class App:
|
|||||||
|
|
||||||
self._fetch_models(self.current_provider)
|
self._fetch_models(self.current_provider)
|
||||||
|
|
||||||
|
# Start API hooks server (if enabled)
|
||||||
|
self.hook_server = api_hooks.HookServer(self)
|
||||||
|
self.hook_server.start()
|
||||||
|
|
||||||
immapp.run(self.runner_params)
|
immapp.run(self.runner_params)
|
||||||
|
|
||||||
# On exit
|
# On exit
|
||||||
|
self.hook_server.stop()
|
||||||
|
self.perf_monitor.stop()
|
||||||
ai_client.cleanup() # Destroy active API caches to stop billing
|
ai_client.cleanup() # Destroy active API caches to stop billing
|
||||||
self._flush_to_project()
|
self._flush_to_project()
|
||||||
self._save_active_project()
|
self._save_active_project()
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -27,7 +27,7 @@ DockId=0x00000015,0
|
|||||||
|
|
||||||
[Window][Discussion History]
|
[Window][Discussion History]
|
||||||
Pos=598,128
|
Pos=598,128
|
||||||
Size=712,619
|
Size=554,619
|
||||||
Collapsed=0
|
Collapsed=0
|
||||||
DockId=0x0000000E,0
|
DockId=0x0000000E,0
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ DockId=0x0000000A,0
|
|||||||
|
|
||||||
[Window][Message]
|
[Window][Message]
|
||||||
Pos=598,749
|
Pos=598,749
|
||||||
Size=712,451
|
Size=554,451
|
||||||
Collapsed=0
|
Collapsed=0
|
||||||
DockId=0x0000000C,0
|
DockId=0x0000000C,0
|
||||||
|
|
||||||
@@ -50,22 +50,22 @@ Collapsed=0
|
|||||||
DockId=0x00000010,0
|
DockId=0x00000010,0
|
||||||
|
|
||||||
[Window][Tool Calls]
|
[Window][Tool Calls]
|
||||||
Pos=1312,733
|
Pos=1154,733
|
||||||
Size=368,144
|
Size=526,144
|
||||||
Collapsed=0
|
Collapsed=0
|
||||||
DockId=0x00000008,0
|
DockId=0x00000008,0
|
||||||
|
|
||||||
[Window][Comms History]
|
[Window][Comms History]
|
||||||
Pos=1312,879
|
Pos=1154,879
|
||||||
Size=368,321
|
Size=526,321
|
||||||
Collapsed=0
|
Collapsed=0
|
||||||
DockId=0x00000006,0
|
DockId=0x00000006,0
|
||||||
|
|
||||||
[Window][System Prompts]
|
[Window][System Prompts]
|
||||||
Pos=1312,0
|
Pos=1154,0
|
||||||
Size=368,731
|
Size=286,731
|
||||||
Collapsed=0
|
Collapsed=0
|
||||||
DockId=0x00000007,0
|
DockId=0x00000017,0
|
||||||
|
|
||||||
[Window][Theme]
|
[Window][Theme]
|
||||||
Pos=209,173
|
Pos=209,173
|
||||||
@@ -78,11 +78,17 @@ Pos=379,324
|
|||||||
Size=900,700
|
Size=900,700
|
||||||
Collapsed=0
|
Collapsed=0
|
||||||
|
|
||||||
|
[Window][Diagnostics]
|
||||||
|
Pos=1442,0
|
||||||
|
Size=238,731
|
||||||
|
Collapsed=0
|
||||||
|
DockId=0x00000018,0
|
||||||
|
|
||||||
[Docking][Data]
|
[Docking][Data]
|
||||||
DockSpace ID=0xAFC85805 Window=0x079D3A04 Pos=138,161 Size=1680,1200 Split=X
|
DockSpace ID=0xAFC85805 Window=0x079D3A04 Pos=346,232 Size=1680,1200 Split=X
|
||||||
DockNode ID=0x00000011 Parent=0xAFC85805 SizeRef=207,1200 Selected=0x0469CA7A
|
DockNode ID=0x00000011 Parent=0xAFC85805 SizeRef=207,1200 Selected=0x0469CA7A
|
||||||
DockNode ID=0x00000012 Parent=0xAFC85805 SizeRef=1559,1200 Split=X
|
DockNode ID=0x00000012 Parent=0xAFC85805 SizeRef=1559,1200 Split=X
|
||||||
DockNode ID=0x00000003 Parent=0x00000012 SizeRef=1189,1200 Split=X
|
DockNode ID=0x00000003 Parent=0x00000012 SizeRef=943,1200 Split=X
|
||||||
DockNode ID=0x00000001 Parent=0x00000003 SizeRef=387,1200 Split=Y Selected=0x8CA2375C
|
DockNode ID=0x00000001 Parent=0x00000003 SizeRef=387,1200 Split=Y Selected=0x8CA2375C
|
||||||
DockNode ID=0x00000009 Parent=0x00000001 SizeRef=405,911 Split=Y Selected=0x8CA2375C
|
DockNode ID=0x00000009 Parent=0x00000001 SizeRef=405,911 Split=Y Selected=0x8CA2375C
|
||||||
DockNode ID=0x0000000F Parent=0x00000009 SizeRef=405,733 Split=Y Selected=0x8CA2375C
|
DockNode ID=0x0000000F Parent=0x00000009 SizeRef=405,733 Split=Y Selected=0x8CA2375C
|
||||||
@@ -92,14 +98,16 @@ DockSpace ID=0xAFC85805 Window=0x079D3A04 Pos=138,161 Size=1680,12
|
|||||||
DockNode ID=0x00000014 Parent=0x0000000F SizeRef=405,337 Selected=0xDA22FEDA
|
DockNode ID=0x00000014 Parent=0x0000000F SizeRef=405,337 Selected=0xDA22FEDA
|
||||||
DockNode ID=0x00000010 Parent=0x00000009 SizeRef=405,176 Selected=0x0D5A5273
|
DockNode ID=0x00000010 Parent=0x00000009 SizeRef=405,176 Selected=0x0D5A5273
|
||||||
DockNode ID=0x0000000A Parent=0x00000001 SizeRef=405,287 Selected=0xA07B5F14
|
DockNode ID=0x0000000A Parent=0x00000001 SizeRef=405,287 Selected=0xA07B5F14
|
||||||
DockNode ID=0x00000002 Parent=0x00000003 SizeRef=800,1200 Split=Y
|
DockNode ID=0x00000002 Parent=0x00000003 SizeRef=554,1200 Split=Y
|
||||||
DockNode ID=0x0000000B Parent=0x00000002 SizeRef=1010,747 Split=Y
|
DockNode ID=0x0000000B Parent=0x00000002 SizeRef=1010,747 Split=Y
|
||||||
DockNode ID=0x0000000D Parent=0x0000000B SizeRef=1010,126 CentralNode=1
|
DockNode ID=0x0000000D Parent=0x0000000B SizeRef=1010,126 CentralNode=1
|
||||||
DockNode ID=0x0000000E Parent=0x0000000B SizeRef=1010,619 Selected=0x5D11106F
|
DockNode ID=0x0000000E Parent=0x0000000B SizeRef=1010,619 Selected=0x5D11106F
|
||||||
DockNode ID=0x0000000C Parent=0x00000002 SizeRef=1010,451 Selected=0x66CFB56E
|
DockNode ID=0x0000000C Parent=0x00000002 SizeRef=1010,451 Selected=0x66CFB56E
|
||||||
DockNode ID=0x00000004 Parent=0x00000012 SizeRef=368,1200 Split=Y Selected=0xDD6419BC
|
DockNode ID=0x00000004 Parent=0x00000012 SizeRef=526,1200 Split=Y Selected=0xDD6419BC
|
||||||
DockNode ID=0x00000005 Parent=0x00000004 SizeRef=261,877 Split=Y Selected=0xDD6419BC
|
DockNode ID=0x00000005 Parent=0x00000004 SizeRef=261,877 Split=Y Selected=0xDD6419BC
|
||||||
DockNode ID=0x00000007 Parent=0x00000005 SizeRef=261,731 Selected=0xDD6419BC
|
DockNode ID=0x00000007 Parent=0x00000005 SizeRef=261,731 Split=X Selected=0xDD6419BC
|
||||||
|
DockNode ID=0x00000017 Parent=0x00000007 SizeRef=286,731 Selected=0xDD6419BC
|
||||||
|
DockNode ID=0x00000018 Parent=0x00000007 SizeRef=238,731 Selected=0xB4CBF21A
|
||||||
DockNode ID=0x00000008 Parent=0x00000005 SizeRef=261,144 Selected=0x1D56B311
|
DockNode ID=0x00000008 Parent=0x00000005 SizeRef=261,144 Selected=0x1D56B311
|
||||||
DockNode ID=0x00000006 Parent=0x00000004 SizeRef=261,321 Selected=0x8B4EBFA6
|
DockNode ID=0x00000006 Parent=0x00000004 SizeRef=261,321 Selected=0x8B4EBFA6
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,9 @@ def configure(file_items: list[dict], extra_base_dirs: list[str] | None = None):
|
|||||||
for item in file_items:
|
for item in file_items:
|
||||||
p = item.get("path")
|
p = item.get("path")
|
||||||
if p is not None:
|
if p is not None:
|
||||||
|
try:
|
||||||
|
rp = Path(p).resolve(strict=True)
|
||||||
|
except (OSError, ValueError):
|
||||||
rp = Path(p).resolve()
|
rp = Path(p).resolve()
|
||||||
_allowed_paths.add(rp)
|
_allowed_paths.add(rp)
|
||||||
_base_dirs.add(rp.parent)
|
_base_dirs.add(rp.parent)
|
||||||
@@ -82,7 +85,12 @@ def _is_allowed(path: Path) -> bool:
|
|||||||
A path is allowed if:
|
A path is allowed if:
|
||||||
- it is explicitly in _allowed_paths, OR
|
- it is explicitly in _allowed_paths, OR
|
||||||
- it is contained within (or equal to) one of the _base_dirs
|
- 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.
|
||||||
"""
|
"""
|
||||||
|
try:
|
||||||
|
rp = path.resolve(strict=True)
|
||||||
|
except (OSError, ValueError):
|
||||||
rp = path.resolve()
|
rp = path.resolve()
|
||||||
if rp in _allowed_paths:
|
if rp in _allowed_paths:
|
||||||
return True
|
return True
|
||||||
@@ -104,6 +112,9 @@ def _resolve_and_check(raw_path: str) -> tuple[Path | None, str]:
|
|||||||
p = Path(raw_path)
|
p = Path(raw_path)
|
||||||
if not p.is_absolute() and _primary_base_dir:
|
if not p.is_absolute() and _primary_base_dir:
|
||||||
p = _primary_base_dir / p
|
p = _primary_base_dir / p
|
||||||
|
try:
|
||||||
|
p = p.resolve(strict=True)
|
||||||
|
except (OSError, ValueError):
|
||||||
p = p.resolve()
|
p = p.resolve()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return None, f"ERROR: invalid path '{raw_path}': {e}"
|
return None, f"ERROR: invalid path '{raw_path}': {e}"
|
||||||
@@ -269,7 +280,8 @@ def web_search(query: str) -> str:
|
|||||||
url = "https://html.duckduckgo.com/html/?q=" + urllib.parse.quote(query)
|
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)'})
|
req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'})
|
||||||
try:
|
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 = _DDGParser()
|
||||||
parser.feed(html)
|
parser.feed(html)
|
||||||
if not parser.results:
|
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)'})
|
req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'})
|
||||||
try:
|
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 = _TextExtractor()
|
||||||
parser.feed(html)
|
parser.feed(html)
|
||||||
full_text = " ".join(parser.text)
|
full_text = " ".join(parser.text)
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ scripts/generated/
|
|||||||
Where <ts> = YYYYMMDD_HHMMSS of when this session was started.
|
Where <ts> = YYYYMMDD_HHMMSS of when this session was started.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import atexit
|
||||||
import datetime
|
import datetime
|
||||||
import json
|
import json
|
||||||
import threading
|
import threading
|
||||||
@@ -71,6 +72,8 @@ def open_session():
|
|||||||
_tool_fh.write(f"# Tool-call log — session {_ts}\n\n")
|
_tool_fh.write(f"# Tool-call log — session {_ts}\n\n")
|
||||||
_tool_fh.flush()
|
_tool_fh.flush()
|
||||||
|
|
||||||
|
atexit.register(close_session)
|
||||||
|
|
||||||
|
|
||||||
def close_session():
|
def close_session():
|
||||||
"""Flush and close both log files. Called on clean exit (optional)."""
|
"""Flush and close both log files. Called on clean exit (optional)."""
|
||||||
|
|||||||
Reference in New Issue
Block a user