Merge origin/cache
This commit is contained in:
70
ai_client.py
70
ai_client.py
@@ -17,7 +17,9 @@ import time
|
|||||||
import datetime
|
import datetime
|
||||||
import hashlib
|
import hashlib
|
||||||
import difflib
|
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
|
||||||
@@ -53,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
|
||||||
@@ -69,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
|
||||||
@@ -130,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")
|
||||||
return tomllib.load(f)
|
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
|
# ------------------------------------------------------------------ provider errors
|
||||||
@@ -246,7 +264,8 @@ 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
|
||||||
_anthropic_history = []
|
with _anthropic_history_lock:
|
||||||
|
_anthropic_history = []
|
||||||
_CACHED_ANTHROPIC_TOOLS = None
|
_CACHED_ANTHROPIC_TOOLS = None
|
||||||
file_cache.reset_client()
|
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)}]"})
|
_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).
|
||||||
@@ -701,11 +721,11 @@ def _send_gemini(md_content: str, user_message: str, base_dir: str,
|
|||||||
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)
|
||||||
@@ -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.]"
|
if r_idx == MAX_TOOL_ROUNDS: out += "\n\n[SYSTEM: MAX ROUNDS. PROVIDE FINAL ANSWER.]"
|
||||||
|
|
||||||
out = _truncate_tool_output(out)
|
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
|
||||||
|
|
||||||
@@ -1046,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):
|
||||||
@@ -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})
|
_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": _truncate_tool_output(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:
|
||||||
@@ -1151,13 +1181,22 @@ 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": _truncate_tool_output(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)
|
||||||
@@ -1220,11 +1259,12 @@ def send(
|
|||||||
discussion_history : discussion history text (used by Gemini to inject as
|
discussion_history : discussion history text (used by Gemini to inject as
|
||||||
conversation message instead of caching it)
|
conversation message instead of caching it)
|
||||||
"""
|
"""
|
||||||
if _provider == "gemini":
|
with _send_lock:
|
||||||
return _send_gemini(md_content, user_message, base_dir, file_items, discussion_history)
|
if _provider == "gemini":
|
||||||
elif _provider == "anthropic":
|
return _send_gemini(md_content, user_message, base_dir, file_items, discussion_history)
|
||||||
return _send_anthropic(md_content, user_message, base_dir, file_items, discussion_history)
|
elif _provider == "anthropic":
|
||||||
raise ValueError(f"unknown provider: {_provider}")
|
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:
|
def get_history_bleed_stats() -> dict:
|
||||||
"""
|
"""
|
||||||
@@ -1232,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 {
|
||||||
|
|||||||
45
gui_2.py
45
gui_2.py
@@ -153,6 +153,7 @@ 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
|
||||||
|
|
||||||
_default_windows = {
|
_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_history = {"frame_time": [0.0]*100, "fps": [0.0]*100, "cpu": [0.0]*100, "input_lag": [0.0]*100}
|
||||||
self._perf_last_update = 0.0
|
self._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
|
||||||
@@ -625,6 +630,18 @@ class App:
|
|||||||
# Process GUI task queue
|
# Process GUI task queue
|
||||||
self._process_pending_gui_tasks()
|
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:
|
||||||
@@ -1109,9 +1126,21 @@ class App:
|
|||||||
imgui.separator()
|
imgui.separator()
|
||||||
|
|
||||||
ch, self.ui_ai_input = imgui.input_text_multiline("##ai_in", self.ui_ai_input, imgui.ImVec2(-1, -40))
|
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()
|
imgui.separator()
|
||||||
if imgui.button("Gen + Send"):
|
send_busy = False
|
||||||
if not (self.send_thread and self.send_thread.is_alive()):
|
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
|
||||||
@@ -1127,10 +1156,7 @@ class App:
|
|||||||
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_model_params(self.temperature, self.max_tokens, self.history_trunc_limit)
|
||||||
ai_client.set_agent_tools(self.ui_agent_tools)
|
ai_client.set_agent_tools(self.ui_agent_tools)
|
||||||
# For Gemini: send stable_md (no history) as cached context,
|
send_md = stable_md
|
||||||
# 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_disc = disc_text
|
send_disc = disc_text
|
||||||
|
|
||||||
def do_send():
|
def do_send():
|
||||||
@@ -1159,9 +1185,10 @@ class App:
|
|||||||
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": "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()})
|
||||||
|
|
||||||
self.send_thread = threading.Thread(target=do_send, daemon=True)
|
with self._send_thread_lock:
|
||||||
self.send_thread.start()
|
self.send_thread = threading.Thread(target=do_send, daemon=True)
|
||||||
|
self.send_thread.start()
|
||||||
imgui.same_line()
|
imgui.same_line()
|
||||||
if imgui.button("MD Only"):
|
if imgui.button("MD Only"):
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -65,7 +65,10 @@ 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:
|
||||||
rp = Path(p).resolve()
|
try:
|
||||||
|
rp = Path(p).resolve(strict=True)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
rp = Path(p).resolve()
|
||||||
_allowed_paths.add(rp)
|
_allowed_paths.add(rp)
|
||||||
_base_dirs.add(rp.parent)
|
_base_dirs.add(rp.parent)
|
||||||
|
|
||||||
@@ -82,8 +85,13 @@ 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.
|
||||||
"""
|
"""
|
||||||
rp = path.resolve()
|
try:
|
||||||
|
rp = path.resolve(strict=True)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
rp = path.resolve()
|
||||||
if rp in _allowed_paths:
|
if rp in _allowed_paths:
|
||||||
return True
|
return True
|
||||||
for bd in _base_dirs:
|
for bd in _base_dirs:
|
||||||
@@ -104,7 +112,10 @@ 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
|
||||||
p = p.resolve()
|
try:
|
||||||
|
p = p.resolve(strict=True)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
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}"
|
||||||
if not _is_allowed(p):
|
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)
|
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