Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8a3c2d8e21 | |||
| 73fad80257 | |||
| 17eebff5f8 | |||
| 1581380a43 | |||
| 8bf95866dc |
+29
-6
@@ -13,15 +13,15 @@ Is a local GUI tool for manually curating and sending context to AI APIs. It agg
|
||||
|
||||
**Files:**
|
||||
- `gui.py` - main GUI, `App` class, all panels, all callbacks, confirmation dialog, layout persistence, rich comms rendering
|
||||
- `ai_client.py` - unified provider wrapper, model listing, session management, send, tool/function-call loop, comms log, provider error classification
|
||||
- `aggregate.py` - reads config, collects files/screenshots/discussion, writes numbered `.md` files to `output_dir`
|
||||
- `ai_client.py` - unified provider wrapper, model listing, session management, send, tool/function-call loop, comms log, provider error classification, token estimation, and aggressive history truncation
|
||||
- `aggregate.py` - reads config, collects files/screenshots/discussion, builds `file_items` with `mtime` for cache optimization, writes numbered `.md` files to `output_dir` using `build_markdown_from_items` to avoid double I/O; `run()` returns `(markdown_str, path, file_items)` tuple; `summary_only=False` by default (full file contents sent, not heuristic summaries)
|
||||
- `shell_runner.py` - subprocess wrapper that runs PowerShell scripts sandboxed to `base_dir`, returns stdout/stderr/exit code as a string
|
||||
- `session_logger.py` - opens timestamped log files at session start; writes comms entries as JSON-L and tool calls as markdown; saves each AI-generated script as a `.ps1` file
|
||||
- `project_manager.py` - per-project .toml load/save, entry serialisation (entry_to_str/str_to_entry with @timestamp support), default_project/default_discussion factories, migrate_from_legacy_config, flat_config for aggregate.run(), git helpers (get_git_commit, get_git_log)
|
||||
- `theme.py` - palette definitions, font loading, scale, load_from_config/save_to_config
|
||||
- `gemini.py` - legacy standalone Gemini wrapper (not used by the main GUI; superseded by `ai_client.py`)
|
||||
- `file_cache.py` - stub; Anthropic Files API path removed; kept so stale imports don't break
|
||||
- `mcp_client.py` - MCP-style read-only file tools (read_file, list_directory, search_files, get_file_summary); allowlist enforced against project file_items + base_dirs; dispatched by ai_client tool-use loop for both Anthropic and Gemini
|
||||
- `mcp_client.py` - MCP-style tools (read_file, list_directory, search_files, get_file_summary, web_search, fetch_url); allowlist enforced against project file_items + base_dirs for file tools; web tools are unrestricted; dispatched by ai_client tool-use loop for both Anthropic and Gemini
|
||||
- `summarize.py` - local heuristic summariser (no AI); .py via AST, .toml via regex, .md headings, generic preview; used by mcp_client.get_file_summary and aggregate.build_summary_section
|
||||
- `config.toml` - global-only settings: [ai] provider+model+system_prompt, [theme] palette+font+scale, [projects] paths array + active path
|
||||
- `manual_slop.toml` - per-project file: [project] name+git_dir+system_prompt+main_context, [output] namespace+output_dir, [files] base_dir+paths, [screenshots] base_dir+paths, [discussion] roles+active+[discussion.discussions.<name>] git_commit+last_updated+history
|
||||
@@ -87,7 +87,7 @@ Is a local GUI tool for manually curating and sending context to AI APIs. It agg
|
||||
- All tool calls (script + result/rejection) are appended to `_tool_log` and displayed in the Tool Calls panel
|
||||
|
||||
**Dynamic file context refresh (ai_client.py):**
|
||||
- After the last tool call in each round, all project files from `file_items` are re-read from disk via `_reread_file_items()`. The `file_items` variable is reassigned so subsequent rounds see fresh content.
|
||||
- After the last tool call in each round, project files from `file_items` are checked via `_reread_file_items()`. It uses `mtime` to only re-read modified files, returning only the `changed` files to build a minimal `[FILES UPDATED]` block.
|
||||
- For Anthropic: the refreshed file contents are injected as a `text` block appended to the `tool_results` user message, prefixed with `[FILES UPDATED]` and an instruction not to re-read them.
|
||||
- For Gemini: refreshed file contents are appended to the last function response's `output` string as a `[SYSTEM: FILES UPDATED]` block. On the next tool round, stale `[FILES UPDATED]` blocks are stripped from history and old tool outputs are truncated to `_history_trunc_limit` characters to control token growth.
|
||||
- `_build_file_context_text(file_items)` formats the refreshed files as markdown code blocks (same format as the original context)
|
||||
@@ -141,10 +141,12 @@ Entry layout: index + timestamp + direction + kind + provider/model header row,
|
||||
- `log_tool_call(script, result, script_path)` writes the script to `scripts/generated/<ts>_<seq:04d>.ps1` and appends a markdown record to the toolcalls log without the script body (just the file path + result); uses a `threading.Lock` for the sequence counter
|
||||
- `close_session()` flushes and closes both file handles; called just before `dpg.destroy_context()`
|
||||
|
||||
**Anthropic prompt caching:**
|
||||
**Anthropic prompt caching & history management:**
|
||||
- System prompt + context are combined into one string, chunked into <=120k char blocks, and sent as the `system=` parameter array. Only the LAST chunk gets `cache_control: ephemeral`, so the entire system prefix is cached as one unit.
|
||||
- Last tool in `_ANTHROPIC_TOOLS` (`run_powershell`) has `cache_control: ephemeral`; this means the tools prefix is cached together with the system prefix after the first request.
|
||||
- The user message is sent as a plain `[{"type": "text", "text": user_message}]` block with NO cache_control. The context lives in `system=`, not in the first user message.
|
||||
- `_add_history_cache_breakpoint` places `cache_control:ephemeral` on the last content block of the second-to-last user message, using the 4th cache breakpoint to cache the conversation history prefix.
|
||||
- `_trim_anthropic_history` uses token estimation (`_CHARS_PER_TOKEN = 3.5`) to keep the prompt under `_ANTHROPIC_MAX_PROMPT_TOKENS = 180_000`. It strips stale file refreshes from old turns, and drops oldest turn pairs if still over budget.
|
||||
- The tools list is built once per session via `_get_anthropic_tools()` and reused across all API calls within the tool loop, avoiding redundant Python-side reconstruction.
|
||||
- `_strip_cache_controls()` removes stale `cache_control` markers from all history entries before each API call, ensuring only the stable system/tools prefix consumes cache breakpoint slots.
|
||||
- Cache stats (creation tokens, read tokens) are surfaced in the comms log usage dict and displayed in the Comms History panel
|
||||
@@ -180,13 +182,15 @@ Entry layout: index + timestamp + direction + kind + provider/model header row,
|
||||
**MCP file tools (mcp_client.py + ai_client.py):**
|
||||
- Four read-only tools exposed to the AI as native function/tool declarations: `read_file`, `list_directory`, `search_files`, `get_file_summary`
|
||||
- Access control: `mcp_client.configure(file_items, extra_base_dirs)` is called before each send; builds an allowlist of resolved absolute paths from the project's `file_items` plus the `base_dir`; any path that is not explicitly in the list or not under one of the allowed directories returns `ACCESS DENIED`
|
||||
- `mcp_client.dispatch(tool_name, tool_input)` is the single dispatch entry point used by both Anthropic and Gemini tool-use loops
|
||||
- `mcp_client.dispatch(tool_name, tool_input)` is the single dispatch entry point used by both Anthropic and Gemini tool-use loops; `TOOL_NAMES` set now includes all six tool names
|
||||
- Anthropic: MCP tools appear before `run_powershell` in the tools list (no `cache_control` on them; only `run_powershell` carries `cache_control: ephemeral`)
|
||||
- Gemini: MCP tools are included in the `FunctionDeclaration` list alongside `run_powershell`
|
||||
- `get_file_summary` uses `summarize.summarise_file()` — same heuristic used for the initial `<context>` block, so the AI gets the same compact structural view it already knows
|
||||
- `list_directory` sorts dirs before files; shows name, type, and size
|
||||
- `search_files` uses `Path.glob()` with the caller-supplied pattern (supports `**/*.py` style)
|
||||
- `read_file` returns raw UTF-8 text; errors (not found, access denied, decode error) are returned as error strings rather than exceptions, so the AI sees them as tool results
|
||||
- `web_search(query)` queries DuckDuckGo HTML endpoint and returns the top 5 results (title, URL, snippet) as a formatted string; uses a custom `_DDGParser` (HTMLParser subclass)
|
||||
- `fetch_url(url)` fetches a URL, strips HTML tags/scripts via `_TextExtractor` (HTMLParser subclass), collapses whitespace, and truncates to 40k chars to prevent context blowup; handles DuckDuckGo redirect links automatically
|
||||
- `summarize.py` heuristics: `.py` → AST imports + ALL_CAPS constants + classes+methods + top-level functions; `.toml` → table headers + top-level keys; `.md` → h1–h3 headings with indentation; all others → line count + first 8 lines preview
|
||||
- Comms log: MCP tool calls log `OUT/tool_call` with `{"name": ..., "args": {...}}` and `IN/tool_result` with `{"name": ..., "output": ...}`; rendered in the Comms History panel via `_render_payload_tool_call` (shows each arg key/value) and `_render_payload_tool_result` (shows output)
|
||||
|
||||
@@ -199,7 +203,9 @@ Entry layout: index + timestamp + direction + kind + provider/model header row,
|
||||
|
||||
### Gemini Context Management
|
||||
- Gemini uses explicit caching via `client.caches.create()` to store the `system_instruction` + tools as an immutable cached prefix with a 1-hour TTL. The cache is created once per chat session.
|
||||
- Proactively rebuilds cache at 90% of `_GEMINI_CACHE_TTL = 3600` to avoid stale-reference errors.
|
||||
- When context changes (detected via `md_content` hash), the old cache is deleted, a new cache is created, and chat history is migrated to a fresh chat session pointing at the new cache.
|
||||
- Trims history by dropping oldest pairs if input tokens exceed `_GEMINI_MAX_INPUT_TOKENS = 900_000`.
|
||||
- If cache creation fails (e.g., content is under the minimum token threshold — 1024 for Flash, 4096 for Pro), the system falls back to inline `system_instruction` in the chat config. Implicit caching may still provide cost savings in this case.
|
||||
- The `<context>` block lives inside `system_instruction`, NOT in user messages, preventing history bloat across turns.
|
||||
- On cleanup/exit, active caches are deleted via `ai_client.cleanup()` to prevent orphaned billing.
|
||||
@@ -244,3 +250,20 @@ Documentation has been completely rewritten matching the strict, structural form
|
||||
- `docs/guide_architecture.md`: Details the Python implementation algorithms, queue management for UI rendering, the specific AST heuristics used for context aggregation, and the distinct algorithms for trimming Anthropic history vs Gemini state caching.
|
||||
- `docs/Readme.md`: The core interface manual.
|
||||
- `docs/guide_tools.md`: Security architecture for `_is_allowed` paths and definitions of the read-only vs destructive tool pipeline.
|
||||
|
||||
|
||||
|
||||
|
||||
## Updates (2026-02-22 — ai_client.py & aggregate.py)
|
||||
|
||||
### mcp_client.py — Web Tools Added
|
||||
- `web_search(query)` and `fetch_url(url)` added as two new MCP tools alongside the existing four file tools.
|
||||
- `TOOL_NAMES` set updated to include all six tool names for dispatch routing.
|
||||
- `MCP_TOOL_SPECS` list extended with full JSON schema definitions for both web tools.
|
||||
- Both tools are declared in `_build_anthropic_tools()` and `_gemini_tool_declaration()` so they are available to both providers.
|
||||
- Web tools bypass the `_is_allowed` path check (no filesystem access); file tools retain the allowlist enforcement.
|
||||
|
||||
### aggregate.py — run() double-I/O elimination
|
||||
- `run()` now calls `build_file_items()` once, then passes the result to `build_markdown_from_items()` instead of calling `build_files_section()` separately. This avoids reading every file twice per send.
|
||||
- `build_markdown_from_items()` accepts a `summary_only` flag (default `False`); when `False` it inlines full file content; when `True` it delegates to `summarize.build_summary_markdown()` for compact structural summaries.
|
||||
- `run()` returns a 3-tuple `(markdown_str, output_path, file_items)` — the `file_items` list is passed through to `gui.py` as `self.last_file_items` for dynamic context refresh after tool calls.
|
||||
|
||||
+53
-17
@@ -98,24 +98,28 @@ def build_file_items(base_dir: Path, files: list[str]) -> list[dict]:
|
||||
entry : str (original config entry string)
|
||||
content : str (file text, or error string)
|
||||
error : bool
|
||||
mtime : float (last modification time, for skip-if-unchanged optimization)
|
||||
"""
|
||||
items = []
|
||||
for entry in files:
|
||||
paths = resolve_paths(base_dir, entry)
|
||||
if not paths:
|
||||
items.append({"path": None, "entry": entry, "content": f"ERROR: no files matched: {entry}", "error": True})
|
||||
items.append({"path": None, "entry": entry, "content": f"ERROR: no files matched: {entry}", "error": True, "mtime": 0.0})
|
||||
continue
|
||||
for path in paths:
|
||||
try:
|
||||
content = path.read_text(encoding="utf-8")
|
||||
mtime = path.stat().st_mtime
|
||||
error = False
|
||||
except FileNotFoundError:
|
||||
content = f"ERROR: file not found: {path}"
|
||||
mtime = 0.0
|
||||
error = True
|
||||
except Exception as e:
|
||||
content = f"ERROR: {e}"
|
||||
mtime = 0.0
|
||||
error = True
|
||||
items.append({"path": path, "entry": entry, "content": content, "error": error})
|
||||
items.append({"path": path, "entry": entry, "content": content, "error": error, "mtime": mtime})
|
||||
return items
|
||||
|
||||
def build_summary_section(base_dir: Path, files: list[str]) -> str:
|
||||
@@ -126,8 +130,43 @@ def build_summary_section(base_dir: Path, files: list[str]) -> str:
|
||||
items = build_file_items(base_dir, files)
|
||||
return summarize.build_summary_markdown(items)
|
||||
|
||||
def build_static_markdown(base_dir: Path, files: list[str], screenshot_base_dir: Path, screenshots: list[str], summary_only: bool = False) -> str:
|
||||
def _build_files_section_from_items(file_items: list[dict]) -> str:
|
||||
"""Build the files markdown section from pre-read file items (avoids double I/O)."""
|
||||
sections = []
|
||||
for item in file_items:
|
||||
path = item.get("path")
|
||||
entry = item.get("entry", "unknown")
|
||||
content = item.get("content", "")
|
||||
if path is None:
|
||||
sections.append(f"### `{entry}`\n\n```text\n{content}\n```")
|
||||
continue
|
||||
suffix = path.suffix.lstrip(".") if hasattr(path, "suffix") else "text"
|
||||
lang = suffix if suffix else "text"
|
||||
original = entry if "*" not in entry else str(path)
|
||||
sections.append(f"### `{original}`\n\n```{lang}\n{content}\n```")
|
||||
return "\n\n---\n\n".join(sections)
|
||||
|
||||
|
||||
def build_markdown_from_items(file_items: list[dict], screenshot_base_dir: Path, screenshots: list[str], history: list[str], summary_only: bool = False) -> str:
|
||||
"""Build markdown from pre-read file items instead of re-reading from disk."""
|
||||
parts = []
|
||||
# STATIC PREFIX: Files and Screenshots must go first to maximize Cache Hits
|
||||
if file_items:
|
||||
if summary_only:
|
||||
parts.append("## Files (Summary)\n\n" + summarize.build_summary_markdown(file_items))
|
||||
else:
|
||||
parts.append("## Files\n\n" + _build_files_section_from_items(file_items))
|
||||
if screenshots:
|
||||
parts.append("## Screenshots\n\n" + build_screenshots_section(screenshot_base_dir, screenshots))
|
||||
# DYNAMIC SUFFIX: History changes every turn, must go last
|
||||
if history:
|
||||
parts.append("## Discussion History\n\n" + build_discussion_section(history))
|
||||
return "\n\n---\n\n".join(parts)
|
||||
|
||||
|
||||
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 = []
|
||||
# STATIC PREFIX: Files and Screenshots must go first to maximize Cache Hits
|
||||
if files:
|
||||
if summary_only:
|
||||
parts.append("## Files (Summary)\n\n" + build_summary_section(base_dir, files))
|
||||
@@ -135,12 +174,12 @@ def build_static_markdown(base_dir: Path, files: list[str], screenshot_base_dir:
|
||||
parts.append("## Files\n\n" + build_files_section(base_dir, files))
|
||||
if screenshots:
|
||||
parts.append("## Screenshots\n\n" + build_screenshots_section(screenshot_base_dir, screenshots))
|
||||
return "\n\n---\n\n".join(parts) if parts else ""
|
||||
# DYNAMIC SUFFIX: History changes every turn, must go last
|
||||
if history:
|
||||
parts.append("## Discussion History\n\n" + build_discussion_section(history))
|
||||
return "\n\n---\n\n".join(parts)
|
||||
|
||||
def build_dynamic_markdown(history: list[str]) -> str:
|
||||
return "## Discussion History\n\n" + build_discussion_section(history) if history else ""
|
||||
|
||||
def run(config: dict) -> tuple[str, str, Path, list[dict]]:
|
||||
def run(config: dict) -> tuple[str, Path, list[dict]]:
|
||||
namespace = config.get("project", {}).get("name")
|
||||
if not namespace:
|
||||
namespace = config.get("output", {}).get("namespace", "project")
|
||||
@@ -154,21 +193,18 @@ def run(config: dict) -> tuple[str, str, Path, list[dict]]:
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
increment = find_next_increment(output_dir, namespace)
|
||||
output_file = output_dir / f"{namespace}_{increment:03d}.md"
|
||||
|
||||
static_md = build_static_markdown(base_dir, files, screenshot_base_dir, screenshots, summary_only=False)
|
||||
dynamic_md = build_dynamic_markdown(history)
|
||||
|
||||
markdown = f"{static_md}\n\n---\n\n{dynamic_md}" if static_md and dynamic_md else static_md or dynamic_md
|
||||
output_file.write_text(markdown, encoding="utf-8")
|
||||
|
||||
# Build file items once, then construct markdown from them (avoids double I/O)
|
||||
file_items = build_file_items(base_dir, files)
|
||||
return static_md, dynamic_md, output_file, file_items
|
||||
markdown = build_markdown_from_items(file_items, screenshot_base_dir, screenshots, history,
|
||||
summary_only=False)
|
||||
output_file.write_text(markdown, encoding="utf-8")
|
||||
return markdown, output_file, file_items
|
||||
|
||||
def main():
|
||||
with open("config.toml", "rb") as f:
|
||||
import tomllib
|
||||
config = tomllib.load(f)
|
||||
static_md, dynamic_md, output_file, _ = run(config)
|
||||
markdown, output_file, _ = run(config)
|
||||
print(f"Written: {output_file}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+369
-122
@@ -13,6 +13,7 @@ during chat creation to avoid massive history bloat.
|
||||
# ai_client.py
|
||||
import tomllib
|
||||
import json
|
||||
import time
|
||||
import datetime
|
||||
from pathlib import Path
|
||||
import file_cache
|
||||
@@ -34,6 +35,12 @@ def set_model_params(temp: float, max_tok: int, trunc_limit: int = 8000):
|
||||
_gemini_client = None
|
||||
_gemini_chat = None
|
||||
_gemini_cache = None
|
||||
_gemini_cache_md_hash: int | None = None
|
||||
_gemini_cache_created_at: float | None = None
|
||||
|
||||
# Gemini cache TTL in seconds. Caches are created with this TTL and
|
||||
# proactively rebuilt at 90% of this value to avoid stale-reference errors.
|
||||
_GEMINI_CACHE_TTL = 3600
|
||||
|
||||
_anthropic_client = None
|
||||
_anthropic_history: list[dict] = []
|
||||
@@ -216,6 +223,7 @@ def cleanup():
|
||||
|
||||
def reset_session():
|
||||
global _gemini_client, _gemini_chat, _gemini_cache
|
||||
global _gemini_cache_md_hash, _gemini_cache_created_at
|
||||
global _anthropic_client, _anthropic_history
|
||||
global _CACHED_ANTHROPIC_TOOLS
|
||||
if _gemini_client and _gemini_cache:
|
||||
@@ -226,6 +234,8 @@ def reset_session():
|
||||
_gemini_client = None
|
||||
_gemini_chat = None
|
||||
_gemini_cache = None
|
||||
_gemini_cache_md_hash = None
|
||||
_gemini_cache_created_at = None
|
||||
_anthropic_client = None
|
||||
_anthropic_history = []
|
||||
_CACHED_ANTHROPIC_TOOLS = None
|
||||
@@ -383,12 +393,15 @@ def _run_script(script: str, base_dir: str) -> str:
|
||||
|
||||
# ------------------------------------------------------------------ dynamic file context refresh
|
||||
|
||||
def _reread_file_items(file_items: list[dict]) -> list[dict]:
|
||||
def _reread_file_items(file_items: list[dict]) -> tuple[list[dict], list[dict]]:
|
||||
"""
|
||||
Re-read every file in file_items from disk, returning a fresh list.
|
||||
This is called after tool calls so the AI sees updated file contents.
|
||||
Re-read file_items from disk, but only files whose mtime has changed.
|
||||
Returns (all_items, changed_items) — all_items is the full refreshed list,
|
||||
changed_items contains only the files that were actually modified since
|
||||
the last read (used to build a minimal [FILES UPDATED] block).
|
||||
"""
|
||||
refreshed = []
|
||||
changed = []
|
||||
for item in file_items:
|
||||
path = item.get("path")
|
||||
if path is None:
|
||||
@@ -397,11 +410,20 @@ def _reread_file_items(file_items: list[dict]) -> list[dict]:
|
||||
from pathlib import Path as _P
|
||||
p = _P(path) if not isinstance(path, _P) else path
|
||||
try:
|
||||
current_mtime = p.stat().st_mtime
|
||||
prev_mtime = item.get("mtime", 0.0)
|
||||
if current_mtime == prev_mtime:
|
||||
refreshed.append(item) # unchanged — skip re-read
|
||||
continue
|
||||
content = p.read_text(encoding="utf-8")
|
||||
refreshed.append({**item, "content": content, "error": False})
|
||||
new_item = {**item, "content": content, "error": False, "mtime": current_mtime}
|
||||
refreshed.append(new_item)
|
||||
changed.append(new_item)
|
||||
except Exception as e:
|
||||
refreshed.append({**item, "content": f"ERROR re-reading {p}: {e}", "error": True})
|
||||
return refreshed
|
||||
err_item = {**item, "content": f"ERROR re-reading {p}: {e}", "error": True, "mtime": 0.0}
|
||||
refreshed.append(err_item)
|
||||
changed.append(err_item)
|
||||
return refreshed, changed
|
||||
|
||||
|
||||
def _build_file_context_text(file_items: list[dict]) -> str:
|
||||
@@ -453,66 +475,110 @@ def _ensure_gemini_client():
|
||||
_gemini_client = genai.Client(api_key=creds["gemini"]["api_key"])
|
||||
|
||||
|
||||
def _send_gemini(static_md: str, dynamic_md: str, user_message: str, base_dir: str, file_items: list[dict] | None = None) -> str:
|
||||
global _gemini_chat, _gemini_cache
|
||||
|
||||
def _get_gemini_history_list(chat):
|
||||
if not chat: return []
|
||||
# google-genai SDK stores the mutable list in _history
|
||||
if hasattr(chat, "_history"):
|
||||
return chat._history
|
||||
if hasattr(chat, "history"):
|
||||
return chat.history
|
||||
if hasattr(chat, "get_history"):
|
||||
return chat.get_history()
|
||||
return []
|
||||
|
||||
def _send_gemini(md_content: str, user_message: str, base_dir: str, file_items: list[dict] | None = None) -> str:
|
||||
global _gemini_chat, _gemini_cache, _gemini_cache_md_hash, _gemini_cache_created_at
|
||||
from google.genai import types
|
||||
try:
|
||||
_ensure_gemini_client(); mcp_client.configure(file_items or [], [base_dir])
|
||||
sys_instr = f"{_get_combined_system_prompt()}\n\n<context>\n{static_md}\n</context>"
|
||||
sys_instr = f"{_get_combined_system_prompt()}\n\n<context>\n{md_content}\n</context>"
|
||||
tools_decl = [_gemini_tool_declaration()]
|
||||
|
||||
current_md_hash = hash(static_md)
|
||||
|
||||
# DYNAMIC CONTEXT: Check if files/context changed mid-session
|
||||
current_md_hash = hash(md_content)
|
||||
old_history = None
|
||||
if _gemini_chat and getattr(_gemini_chat, "_last_md_hash", None) != current_md_hash:
|
||||
old_history = list(_gemini_chat.history) if _gemini_chat.history else []
|
||||
if _gemini_chat and _gemini_cache_md_hash != current_md_hash:
|
||||
old_history = list(_get_gemini_history_list(_gemini_chat)) if _get_gemini_history_list(_gemini_chat) else []
|
||||
if _gemini_cache:
|
||||
try: _gemini_client.caches.delete(name=_gemini_cache.name)
|
||||
except: pass
|
||||
_gemini_chat, _gemini_cache = None, None
|
||||
_append_comms("OUT", "request", {"message": "[STATIC CONTEXT CHANGED] Rebuilding cache and chat session..."})
|
||||
_gemini_chat = None
|
||||
_gemini_cache = None
|
||||
_gemini_cache_created_at = None
|
||||
_append_comms("OUT", "request", {"message": "[CONTEXT CHANGED] Rebuilding cache and chat session..."})
|
||||
|
||||
# CACHE TTL: Proactively rebuild before the cache expires server-side.
|
||||
# If we don't, send_message() will reference a deleted cache and fail.
|
||||
if _gemini_chat and _gemini_cache and _gemini_cache_created_at:
|
||||
elapsed = time.time() - _gemini_cache_created_at
|
||||
if elapsed > _GEMINI_CACHE_TTL * 0.9:
|
||||
old_history = list(_get_gemini_history_list(_gemini_chat)) if _get_gemini_history_list(_gemini_chat) else []
|
||||
try: _gemini_client.caches.delete(name=_gemini_cache.name)
|
||||
except: pass
|
||||
_gemini_chat = None
|
||||
_gemini_cache = None
|
||||
_gemini_cache_created_at = None
|
||||
_append_comms("OUT", "request", {"message": f"[CACHE TTL] Rebuilding cache (expired after {int(elapsed)}s)..."})
|
||||
|
||||
if not _gemini_chat:
|
||||
chat_config = types.GenerateContentConfig(
|
||||
system_instruction=sys_instr, tools=tools_decl, temperature=_temperature, max_output_tokens=_max_tokens,
|
||||
system_instruction=sys_instr,
|
||||
tools=tools_decl,
|
||||
temperature=_temperature,
|
||||
max_output_tokens=_max_tokens,
|
||||
safety_settings=[types.SafetySetting(category="HARM_CATEGORY_DANGEROUS_CONTENT", threshold="BLOCK_ONLY_HIGH")]
|
||||
)
|
||||
try:
|
||||
_gemini_cache = _gemini_client.caches.create(model=_model, config=types.CreateCachedContentConfig(system_instruction=sys_instr, tools=tools_decl, ttl="3600s"))
|
||||
# Gemini requires 1024 (Flash) or 4096 (Pro) tokens to cache.
|
||||
_gemini_cache = _gemini_client.caches.create(
|
||||
model=_model,
|
||||
config=types.CreateCachedContentConfig(
|
||||
system_instruction=sys_instr,
|
||||
tools=tools_decl,
|
||||
ttl=f"{_GEMINI_CACHE_TTL}s",
|
||||
)
|
||||
)
|
||||
_gemini_cache_created_at = time.time()
|
||||
chat_config = types.GenerateContentConfig(
|
||||
cached_content=_gemini_cache.name, temperature=_temperature, max_output_tokens=_max_tokens,
|
||||
cached_content=_gemini_cache.name,
|
||||
temperature=_temperature,
|
||||
max_output_tokens=_max_tokens,
|
||||
safety_settings=[types.SafetySetting(category="HARM_CATEGORY_DANGEROUS_CONTENT", threshold="BLOCK_ONLY_HIGH")]
|
||||
)
|
||||
_append_comms("OUT", "request", {"message": f"[CACHE CREATED] {_gemini_cache.name}"})
|
||||
except Exception: _gemini_cache = None
|
||||
|
||||
kwargs = {"model": _model, "config": chat_config}
|
||||
if old_history: kwargs["history"] = old_history
|
||||
_gemini_chat = _gemini_client.chats.create(**kwargs)
|
||||
_gemini_chat._last_md_hash = current_md_hash
|
||||
except Exception as e:
|
||||
_gemini_cache = None
|
||||
_gemini_cache_created_at = None
|
||||
_append_comms("OUT", "request", {"message": f"[CACHE FAILED] {type(e).__name__}: {e} — falling back to inline system_instruction"})
|
||||
|
||||
import re
|
||||
if _gemini_chat and _gemini_chat.history:
|
||||
for msg in _gemini_chat.history:
|
||||
kwargs = {"model": _model, "config": chat_config}
|
||||
if old_history:
|
||||
kwargs["history"] = old_history
|
||||
|
||||
_gemini_chat = _gemini_client.chats.create(**kwargs)
|
||||
_gemini_cache_md_hash = current_md_hash
|
||||
|
||||
_append_comms("OUT", "request", {"message": f"[ctx {len(md_content)} + msg {len(user_message)}]"})
|
||||
payload, all_text = user_message, []
|
||||
|
||||
# Strip stale file refreshes and truncate old tool outputs ONCE before
|
||||
# entering the tool loop (not per-round — history entries don't change).
|
||||
if _gemini_chat and _get_gemini_history_list(_gemini_chat):
|
||||
for msg in _get_gemini_history_list(_gemini_chat):
|
||||
if msg.role == "user" and hasattr(msg, "parts"):
|
||||
for p in msg.parts:
|
||||
if hasattr(p, "text") and p.text and "<discussion>" in p.text:
|
||||
p.text = re.sub(r"<discussion>.*?</discussion>\n\n", "", p.text, flags=re.DOTALL)
|
||||
if hasattr(p, "function_response") and p.function_response and hasattr(p.function_response, "response"):
|
||||
r = p.function_response.response
|
||||
r_dict = r if isinstance(r, dict) else getattr(r, "__dict__", {})
|
||||
val = r_dict.get("output") if isinstance(r_dict, dict) else getattr(r, "output", None)
|
||||
if isinstance(val, str):
|
||||
if "[SYSTEM: FILES UPDATED]" in val: val = val.split("[SYSTEM: FILES UPDATED]")[0].strip()
|
||||
if _history_trunc_limit > 0 and len(val) > _history_trunc_limit:
|
||||
val = val[:_history_trunc_limit] + "\n\n... [TRUNCATED BY SYSTEM TO SAVE TOKENS.]"
|
||||
if isinstance(r, dict): r["output"] = val
|
||||
else: setattr(r, "output", val)
|
||||
if isinstance(r, dict) and "output" in r:
|
||||
val = r["output"]
|
||||
if isinstance(val, str):
|
||||
if "[SYSTEM: FILES UPDATED]" in val:
|
||||
val = val.split("[SYSTEM: FILES UPDATED]")[0].strip()
|
||||
if _history_trunc_limit > 0 and len(val) > _history_trunc_limit:
|
||||
val = val[:_history_trunc_limit] + "\n\n... [TRUNCATED BY SYSTEM TO SAVE TOKENS.]"
|
||||
r["output"] = val
|
||||
|
||||
full_user_msg = f"<discussion>\n{dynamic_md}\n</discussion>\n\n{user_message}" if dynamic_md else user_message
|
||||
_append_comms("OUT", "request", {"message": f"[ctx {len(static_md)} static + {len(dynamic_md)} dynamic + msg {len(user_message)}]"})
|
||||
|
||||
payload, all_text = full_user_msg, []
|
||||
|
||||
for r_idx in range(MAX_TOOL_ROUNDS + 2):
|
||||
resp = _gemini_chat.send_message(payload)
|
||||
txt = "\n".join(p.text for c in resp.candidates if getattr(c, "content", None) for p in c.content.parts if hasattr(p, "text") and p.text)
|
||||
@@ -521,27 +587,34 @@ def _send_gemini(static_md: str, dynamic_md: str, user_message: str, base_dir: s
|
||||
calls = [p.function_call for c in resp.candidates if getattr(c, "content", None) for p in c.content.parts if hasattr(p, "function_call") and p.function_call]
|
||||
usage = {"input_tokens": getattr(resp.usage_metadata, "prompt_token_count", 0), "output_tokens": getattr(resp.usage_metadata, "candidates_token_count", 0)}
|
||||
cached_tokens = getattr(resp.usage_metadata, "cached_content_token_count", None)
|
||||
if cached_tokens: usage["cache_read_input_tokens"] = cached_tokens
|
||||
if cached_tokens:
|
||||
usage["cache_read_input_tokens"] = cached_tokens
|
||||
reason = resp.candidates[0].finish_reason.name if resp.candidates and hasattr(resp.candidates[0], "finish_reason") else "STOP"
|
||||
|
||||
_append_comms("IN", "response", {"round": r_idx, "stop_reason": reason, "text": txt, "tool_calls": [{"name": c.name, "args": dict(c.args)} for c in calls], "usage": usage})
|
||||
|
||||
# Guard: if Gemini reports input tokens approaching the limit, drop oldest history pairs
|
||||
total_in = usage.get("input_tokens", 0)
|
||||
if total_in > _GEMINI_MAX_INPUT_TOKENS and _gemini_chat and _gemini_chat.history:
|
||||
hist = list(_gemini_chat.history)
|
||||
if total_in > _GEMINI_MAX_INPUT_TOKENS and _gemini_chat and _get_gemini_history_list(_gemini_chat):
|
||||
hist = _get_gemini_history_list(_gemini_chat)
|
||||
dropped = 0
|
||||
# Drop oldest pairs (user+model) but keep at least the last 2 entries
|
||||
while len(hist) > 4 and total_in > _GEMINI_MAX_INPUT_TOKENS * 0.7:
|
||||
saved = sum(len(p.text)//4 for p in hist[0].parts if hasattr(p, "text") and p.text)
|
||||
for p in hist[0].parts:
|
||||
if hasattr(p, "function_response") and p.function_response:
|
||||
r = getattr(p.function_response, "response", {})
|
||||
val = r.get("output", "") if isinstance(r, dict) else getattr(r, "output", "")
|
||||
saved += len(str(val)) // 4
|
||||
hist.pop(0)
|
||||
total_in -= max(saved, 100)
|
||||
dropped += 1
|
||||
# Drop in pairs (user + model) to maintain alternating roles required by Gemini
|
||||
saved = 0
|
||||
for _ in range(2):
|
||||
if not hist: break
|
||||
for p in hist[0].parts:
|
||||
if hasattr(p, "text") and p.text:
|
||||
saved += len(p.text) // 4
|
||||
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
|
||||
hist.pop(0)
|
||||
dropped += 1
|
||||
total_in -= max(saved, 200)
|
||||
if dropped > 0:
|
||||
_gemini_chat.history = hist
|
||||
_append_comms("OUT", "request", {"message": f"[GEMINI HISTORY TRIMMED: dropped {dropped} old entries to stay within token budget]"})
|
||||
|
||||
if not calls or r_idx > MAX_TOOL_ROUNDS: break
|
||||
@@ -560,11 +633,12 @@ def _send_gemini(static_md: str, dynamic_md: str, user_message: str, base_dir: s
|
||||
|
||||
if i == len(calls) - 1:
|
||||
if file_items:
|
||||
file_items = _reread_file_items(file_items)
|
||||
ctx = _build_file_context_text(file_items)
|
||||
if ctx: out += f"\n\n[SYSTEM: FILES UPDATED]\n\n{ctx}"
|
||||
file_items, changed = _reread_file_items(file_items)
|
||||
ctx = _build_file_context_text(changed)
|
||||
if 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.]"
|
||||
|
||||
|
||||
f_resps.append(types.Part.from_function_response(name=name, response={"output": out}))
|
||||
log.append({"tool_use_id": name, "content": out})
|
||||
|
||||
@@ -596,7 +670,15 @@ _FILE_REFRESH_MARKER = "[FILES UPDATED"
|
||||
|
||||
|
||||
def _estimate_message_tokens(msg: dict) -> int:
|
||||
"""Rough token estimate for a single Anthropic message dict."""
|
||||
"""
|
||||
Rough token estimate for a single Anthropic message dict.
|
||||
Caches the result on the dict as '_est_tokens' so repeated calls
|
||||
(e.g., from _trim_anthropic_history) don't re-scan unchanged messages.
|
||||
Call _invalidate_token_estimate() when a message's content is modified.
|
||||
"""
|
||||
cached = msg.get("_est_tokens")
|
||||
if cached is not None:
|
||||
return cached
|
||||
total_chars = 0
|
||||
content = msg.get("content", "")
|
||||
if isinstance(content, str):
|
||||
@@ -614,7 +696,14 @@ def _estimate_message_tokens(msg: dict) -> int:
|
||||
total_chars += len(_json.dumps(inp, ensure_ascii=False))
|
||||
elif isinstance(block, str):
|
||||
total_chars += len(block)
|
||||
return max(1, int(total_chars / _CHARS_PER_TOKEN))
|
||||
est = max(1, int(total_chars / _CHARS_PER_TOKEN))
|
||||
msg["_est_tokens"] = est
|
||||
return est
|
||||
|
||||
|
||||
def _invalidate_token_estimate(msg: dict):
|
||||
"""Remove the cached token estimate so the next call recalculates."""
|
||||
msg.pop("_est_tokens", None)
|
||||
|
||||
|
||||
def _estimate_prompt_tokens(system_blocks: list[dict], history: list[dict]) -> int:
|
||||
@@ -626,48 +715,86 @@ def _estimate_prompt_tokens(system_blocks: list[dict], history: list[dict]) -> i
|
||||
total += max(1, int(len(text) / _CHARS_PER_TOKEN))
|
||||
# Tool definitions (rough fixed estimate — they're ~2k tokens for our set)
|
||||
total += 2500
|
||||
# History messages
|
||||
# History messages (uses cached estimates for unchanged messages)
|
||||
for msg in history:
|
||||
total += _estimate_message_tokens(msg)
|
||||
return total
|
||||
|
||||
|
||||
def _strip_stale_file_refreshes(history: list[dict]):
|
||||
"""
|
||||
Remove [FILES UPDATED ...] text blocks from all history turns EXCEPT
|
||||
the very last user message. These are stale snapshots from previous
|
||||
tool rounds that bloat the context without providing value.
|
||||
"""
|
||||
if len(history) < 2:
|
||||
return
|
||||
last_user_idx = next((i for i in range(len(history)-1, -1, -1) if history[i].get("role") == "user"), -1)
|
||||
# Find the index of the last user message — we keep its file refresh intact
|
||||
last_user_idx = -1
|
||||
for i in range(len(history) - 1, -1, -1):
|
||||
if history[i].get("role") == "user":
|
||||
last_user_idx = i
|
||||
break
|
||||
for i, msg in enumerate(history):
|
||||
if msg.get("role") != "user" or i == last_user_idx:
|
||||
continue
|
||||
content = msg.get("content")
|
||||
if not isinstance(content, list):
|
||||
continue
|
||||
cleaned = [b for b in content if not (isinstance(b, dict) and b.get("type") == "text" and b.get("text", "").startswith(_FILE_REFRESH_MARKER))]
|
||||
cleaned = []
|
||||
for block in content:
|
||||
if isinstance(block, dict) and block.get("type") == "text":
|
||||
text = block.get("text", "")
|
||||
if text.startswith(_FILE_REFRESH_MARKER):
|
||||
continue # drop this stale file refresh block
|
||||
cleaned.append(block)
|
||||
if len(cleaned) < len(content):
|
||||
msg["content"] = cleaned
|
||||
_invalidate_token_estimate(msg)
|
||||
|
||||
|
||||
def _trim_anthropic_history(system_blocks: list[dict], history: list[dict]) -> int:
|
||||
def _trim_anthropic_history(system_blocks: list[dict], history: list[dict]):
|
||||
"""
|
||||
Trim the Anthropic history to fit within the token budget.
|
||||
Strategy:
|
||||
1. Strip stale file-refresh injections from old turns.
|
||||
2. If still over budget, drop oldest turn pairs (user + assistant).
|
||||
Returns the number of messages dropped.
|
||||
"""
|
||||
# Phase 1: strip stale file refreshes
|
||||
_strip_stale_file_refreshes(history)
|
||||
|
||||
est = _estimate_prompt_tokens(system_blocks, history)
|
||||
if est <= _ANTHROPIC_MAX_PROMPT_TOKENS:
|
||||
return 0
|
||||
|
||||
# Phase 2: drop oldest turn pairs until within budget
|
||||
dropped = 0
|
||||
while len(history) > 3 and est > _ANTHROPIC_MAX_PROMPT_TOKENS:
|
||||
# Protect history[0] (original user prompt). Drop from history[1] (assistant) and history[2] (user)
|
||||
if history[1].get("role") == "assistant" and len(history) > 2 and history[2].get("role") == "user":
|
||||
est -= _estimate_message_tokens(history.pop(1))
|
||||
est -= _estimate_message_tokens(history.pop(1))
|
||||
removed_asst = history.pop(1)
|
||||
removed_user = history.pop(1)
|
||||
dropped += 2
|
||||
est -= _estimate_message_tokens(removed_asst)
|
||||
est -= _estimate_message_tokens(removed_user)
|
||||
# Also drop dangling tool_results if the next message is an assistant and the removed user was just tool results
|
||||
while len(history) > 2 and history[1].get("role") == "assistant" and history[2].get("role") == "user":
|
||||
c = history[2].get("content", [])
|
||||
if isinstance(c, list) and c and isinstance(c[0], dict) and c[0].get("type") == "tool_result":
|
||||
est -= _estimate_message_tokens(history.pop(1))
|
||||
est -= _estimate_message_tokens(history.pop(1))
|
||||
content = history[2].get("content", [])
|
||||
if isinstance(content, list) and content and isinstance(content[0], dict) and content[0].get("type") == "tool_result":
|
||||
r_a = history.pop(1)
|
||||
r_u = history.pop(1)
|
||||
dropped += 2
|
||||
else: break
|
||||
est -= _estimate_message_tokens(r_a)
|
||||
est -= _estimate_message_tokens(r_u)
|
||||
else:
|
||||
break
|
||||
else:
|
||||
est -= _estimate_message_tokens(history.pop(1))
|
||||
# Edge case fallback: drop index 1 (protecting index 0)
|
||||
removed = history.pop(1)
|
||||
dropped += 1
|
||||
est -= _estimate_message_tokens(removed)
|
||||
|
||||
return dropped
|
||||
|
||||
|
||||
@@ -715,6 +842,28 @@ def _strip_cache_controls(history: list[dict]):
|
||||
if isinstance(block, dict):
|
||||
block.pop("cache_control", None)
|
||||
|
||||
def _add_history_cache_breakpoint(history: list[dict]):
|
||||
"""
|
||||
Place cache_control:ephemeral on the last content block of the
|
||||
second-to-last user message. This uses one of the 4 allowed Anthropic
|
||||
cache breakpoints to cache the conversation prefix so the full history
|
||||
isn't reprocessed on every request.
|
||||
"""
|
||||
user_indices = [i for i, m in enumerate(history) if m.get("role") == "user"]
|
||||
if len(user_indices) < 2:
|
||||
return # Only one user message (the current turn) — nothing stable to cache
|
||||
target_idx = user_indices[-2]
|
||||
content = history[target_idx].get("content")
|
||||
if isinstance(content, list) and content:
|
||||
last_block = content[-1]
|
||||
if isinstance(last_block, dict):
|
||||
last_block["cache_control"] = {"type": "ephemeral"}
|
||||
elif isinstance(content, str):
|
||||
history[target_idx]["content"] = [
|
||||
{"type": "text", "text": content, "cache_control": {"type": "ephemeral"}}
|
||||
]
|
||||
|
||||
|
||||
def _repair_anthropic_history(history: list[dict]):
|
||||
"""
|
||||
If history ends with an assistant message that contains tool_use blocks
|
||||
@@ -747,119 +896,217 @@ def _repair_anthropic_history(history: list[dict]):
|
||||
})
|
||||
|
||||
|
||||
def _send_anthropic(static_md: str, dynamic_md: 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) -> str:
|
||||
try:
|
||||
_ensure_anthropic_client()
|
||||
mcp_client.configure(file_items or [], [base_dir])
|
||||
|
||||
system_text = _get_combined_system_prompt() + f"\n\n<context>\n{static_md}\n</context>"
|
||||
system_blocks = _build_chunked_context_blocks(system_text)
|
||||
|
||||
if dynamic_md:
|
||||
system_blocks.append({"type": "text", "text": f"<discussion>\n{dynamic_md}\n</discussion>"})
|
||||
# Split system into two cache breakpoints:
|
||||
# 1. Stable system prompt (never changes — always a cache hit)
|
||||
# 2. Dynamic file context (invalidated only when files change)
|
||||
stable_prompt = _get_combined_system_prompt()
|
||||
stable_blocks = [{"type": "text", "text": stable_prompt, "cache_control": {"type": "ephemeral"}}]
|
||||
context_text = f"\n\n<context>\n{md_content}\n</context>"
|
||||
context_blocks = _build_chunked_context_blocks(context_text)
|
||||
system_blocks = stable_blocks + context_blocks
|
||||
|
||||
user_content = [{"type": "text", "text": user_message}]
|
||||
|
||||
# COMPRESS HISTORY: Truncate massive tool outputs from previous turns
|
||||
for msg in _anthropic_history:
|
||||
if msg.get("role") == "user" and isinstance(msg.get("content"), list):
|
||||
modified = False
|
||||
for block in msg["content"]:
|
||||
if isinstance(block, dict) and block.get("type") == "tool_result":
|
||||
t_content = block.get("content", "")
|
||||
if _history_trunc_limit > 0 and isinstance(t_content, str) and len(t_content) > _history_trunc_limit:
|
||||
block["content"] = t_content[:_history_trunc_limit] + "\n\n... [TRUNCATED BY SYSTEM TO SAVE TOKENS. Original output was too large.]"
|
||||
modified = True
|
||||
if modified:
|
||||
_invalidate_token_estimate(msg)
|
||||
|
||||
_strip_cache_controls(_anthropic_history)
|
||||
_repair_anthropic_history(_anthropic_history)
|
||||
|
||||
user_content[-1]["cache_control"] = {"type": "ephemeral"}
|
||||
_anthropic_history.append({"role": "user", "content": user_content})
|
||||
# Use the 4th cache breakpoint to cache the conversation history prefix.
|
||||
# This is placed on the second-to-last user message (the last stable one).
|
||||
_add_history_cache_breakpoint(_anthropic_history)
|
||||
|
||||
n_chunks = len(system_blocks)
|
||||
_append_comms("OUT", "request", {
|
||||
"message": (f"[system {n_chunks} chunk(s), {len(static_md)} static + {len(dynamic_md)} dynamic chars context] "
|
||||
f"{user_message[:200]}{'...' if len(user_message) > 200 else ''}"),
|
||||
"message": (
|
||||
f"[system {n_chunks} chunk(s), {len(md_content)} chars context] "
|
||||
f"{user_message[:200]}{'...' if len(user_message) > 200 else ''}"
|
||||
),
|
||||
})
|
||||
|
||||
all_text_parts = []
|
||||
|
||||
# We allow MAX_TOOL_ROUNDS, plus 1 final loop to get the text synthesis
|
||||
for round_idx in range(MAX_TOOL_ROUNDS + 2):
|
||||
# Trim history to fit within token budget before each API call
|
||||
dropped = _trim_anthropic_history(system_blocks, _anthropic_history)
|
||||
if dropped > 0:
|
||||
est_tokens = _estimate_prompt_tokens(system_blocks, _anthropic_history)
|
||||
_append_comms("OUT", "request", {"message": f"[HISTORY TRIMMED: dropped {dropped} old messages to fit token budget. Estimated {est_tokens} tokens remaining.]"})
|
||||
_append_comms("OUT", "request", {
|
||||
"message": (
|
||||
f"[HISTORY TRIMMED: dropped {dropped} old messages to fit token budget. "
|
||||
f"Estimated {est_tokens} tokens remaining. {len(_anthropic_history)} messages in history.]"
|
||||
),
|
||||
})
|
||||
|
||||
def _strip_private_keys(history):
|
||||
return [{k: v for k, v in m.items() if not k.startswith("_")} for m in history]
|
||||
|
||||
response = _anthropic_client.messages.create(
|
||||
model=_model, max_tokens=_max_tokens, temperature=_temperature,
|
||||
system=system_blocks, tools=_get_anthropic_tools(), messages=_anthropic_history,
|
||||
model=_model,
|
||||
max_tokens=_max_tokens,
|
||||
temperature=_temperature,
|
||||
system=system_blocks,
|
||||
tools=_get_anthropic_tools(),
|
||||
messages=_strip_private_keys(_anthropic_history),
|
||||
)
|
||||
|
||||
# Convert SDK content block objects to plain dicts before storing in history
|
||||
serialised_content = [_content_block_to_dict(b) for b in response.content]
|
||||
_anthropic_history.append({"role": "assistant", "content": serialised_content})
|
||||
|
||||
_anthropic_history.append({
|
||||
"role": "assistant",
|
||||
"content": serialised_content,
|
||||
})
|
||||
|
||||
text_blocks = [b.text for b in response.content if hasattr(b, "text") and b.text]
|
||||
if text_blocks: all_text_parts.append("\n".join(text_blocks))
|
||||
if text_blocks:
|
||||
all_text_parts.append("\n".join(text_blocks))
|
||||
|
||||
tool_use_blocks = [{"id": b.id, "name": b.name, "input": b.input} for b in response.content if getattr(b, "type", None) == "tool_use"]
|
||||
tool_use_blocks = [
|
||||
{"id": b.id, "name": b.name, "input": b.input}
|
||||
for b in response.content
|
||||
if getattr(b, "type", None) == "tool_use"
|
||||
]
|
||||
|
||||
usage_dict = {}
|
||||
usage_dict: dict = {}
|
||||
if response.usage:
|
||||
usage_dict.update({"input_tokens": response.usage.input_tokens, "output_tokens": response.usage.output_tokens})
|
||||
if getattr(response.usage, "cache_creation_input_tokens", None) is not None:
|
||||
usage_dict["cache_creation_input_tokens"] = response.usage.cache_creation_input_tokens
|
||||
if getattr(response.usage, "cache_read_input_tokens", None) is not None:
|
||||
usage_dict["cache_read_input_tokens"] = response.usage.cache_read_input_tokens
|
||||
usage_dict["input_tokens"] = response.usage.input_tokens
|
||||
usage_dict["output_tokens"] = response.usage.output_tokens
|
||||
cache_creation = getattr(response.usage, "cache_creation_input_tokens", None)
|
||||
cache_read = getattr(response.usage, "cache_read_input_tokens", None)
|
||||
if cache_creation is not None:
|
||||
usage_dict["cache_creation_input_tokens"] = cache_creation
|
||||
if cache_read is not None:
|
||||
usage_dict["cache_read_input_tokens"] = cache_read
|
||||
|
||||
_append_comms("IN", "response", {"round": round_idx, "stop_reason": response.stop_reason, "text": "\n".join(text_blocks), "tool_calls": tool_use_blocks, "usage": usage_dict})
|
||||
_append_comms("IN", "response", {
|
||||
"round": round_idx,
|
||||
"stop_reason": response.stop_reason,
|
||||
"text": "\n".join(text_blocks),
|
||||
"tool_calls": tool_use_blocks,
|
||||
"usage": usage_dict,
|
||||
})
|
||||
|
||||
if response.stop_reason != "tool_use" or not tool_use_blocks: break
|
||||
if round_idx > MAX_TOOL_ROUNDS: break
|
||||
if response.stop_reason != "tool_use" or not tool_use_blocks:
|
||||
break
|
||||
|
||||
if round_idx > MAX_TOOL_ROUNDS:
|
||||
# The model ignored the MAX ROUNDS warning and kept calling tools.
|
||||
# Force abort to prevent infinite loop.
|
||||
break
|
||||
|
||||
tool_results = []
|
||||
for block in response.content:
|
||||
if getattr(block, "type", None) != "tool_use": continue
|
||||
b_name, b_id, b_input = getattr(block, "name", None), getattr(block, "id", ""), getattr(block, "input", {})
|
||||
if getattr(block, "type", None) != "tool_use":
|
||||
continue
|
||||
b_name = getattr(block, "name", None)
|
||||
b_id = getattr(block, "id", "")
|
||||
b_input = getattr(block, "input", {})
|
||||
if b_name in mcp_client.TOOL_NAMES:
|
||||
_append_comms("OUT", "tool_call", {"name": b_name, "id": b_id, "args": b_input})
|
||||
out = 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})
|
||||
tool_results.append({
|
||||
"type": "tool_result",
|
||||
"tool_use_id": b_id,
|
||||
"content": output,
|
||||
})
|
||||
elif b_name == TOOL_NAME:
|
||||
scr = b_input.get("script", "")
|
||||
_append_comms("OUT", "tool_call", {"name": TOOL_NAME, "id": b_id, "script": scr})
|
||||
out = _run_script(scr, base_dir)
|
||||
else: out = f"ERROR: unknown tool '{b_name}'"
|
||||
|
||||
_append_comms("IN", "tool_result", {"name": b_name, "id": b_id, "output": out})
|
||||
tool_results.append({"type": "tool_result", "tool_use_id": b_id, "content": out})
|
||||
script = b_input.get("script", "")
|
||||
_append_comms("OUT", "tool_call", {
|
||||
"name": TOOL_NAME,
|
||||
"id": b_id,
|
||||
"script": script,
|
||||
})
|
||||
output = _run_script(script, base_dir)
|
||||
_append_comms("IN", "tool_result", {
|
||||
"name": TOOL_NAME,
|
||||
"id": b_id,
|
||||
"output": output,
|
||||
})
|
||||
tool_results.append({
|
||||
"type": "tool_result",
|
||||
"tool_use_id": b_id,
|
||||
"content": output,
|
||||
})
|
||||
|
||||
# Refresh file context after tool calls — only inject CHANGED files
|
||||
if file_items:
|
||||
file_items = _reread_file_items(file_items)
|
||||
refreshed_ctx = _build_file_context_text(file_items)
|
||||
file_items, changed = _reread_file_items(file_items)
|
||||
refreshed_ctx = _build_file_context_text(changed)
|
||||
if refreshed_ctx:
|
||||
tool_results.append({"type": "text", "text": f"[{_FILE_REFRESH_MARKER} — current contents below. Do NOT re-read these files with PowerShell.]\n\n{refreshed_ctx}"})
|
||||
tool_results.append({
|
||||
"type": "text",
|
||||
"text": (
|
||||
"[FILES UPDATED — current contents below. "
|
||||
"Do NOT re-read these files with PowerShell.]\n\n"
|
||||
+ refreshed_ctx
|
||||
),
|
||||
})
|
||||
|
||||
if round_idx == MAX_TOOL_ROUNDS:
|
||||
tool_results.append({"type": "text", "text": "SYSTEM WARNING: MAX TOOL ROUNDS REACHED. YOU MUST PROVIDE YOUR FINAL ANSWER NOW WITHOUT CALLING ANY MORE TOOLS."})
|
||||
tool_results.append({
|
||||
"type": "text",
|
||||
"text": "SYSTEM WARNING: MAX TOOL ROUNDS REACHED. YOU MUST PROVIDE YOUR FINAL ANSWER NOW WITHOUT CALLING ANY MORE TOOLS."
|
||||
})
|
||||
|
||||
_anthropic_history.append({"role": "user", "content": tool_results})
|
||||
_append_comms("OUT", "tool_result_send", {"results": [{"tool_use_id": r["tool_use_id"], "content": r["content"]} for r in tool_results if r.get("type") == "tool_result"]})
|
||||
_anthropic_history.append({
|
||||
"role": "user",
|
||||
"content": tool_results,
|
||||
})
|
||||
|
||||
_append_comms("OUT", "tool_result_send", {
|
||||
"results": [
|
||||
{"tool_use_id": r["tool_use_id"], "content": r["content"]}
|
||||
for r in tool_results if r.get("type") == "tool_result"
|
||||
],
|
||||
})
|
||||
|
||||
final_text = "\n\n".join(all_text_parts)
|
||||
return final_text if final_text.strip() else "(No text returned by the model)"
|
||||
except ProviderError: raise
|
||||
except Exception as exc: raise _classify_anthropic_error(exc) from exc
|
||||
|
||||
except ProviderError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise _classify_anthropic_error(exc) from exc
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ unified send
|
||||
|
||||
def send(
|
||||
static_md: str,
|
||||
dynamic_md: str,
|
||||
md_content: str,
|
||||
user_message: str,
|
||||
base_dir: str = ".",
|
||||
file_items: list[dict] | None = None,
|
||||
) -> str:
|
||||
"""Send a message to the active provider."""
|
||||
"""
|
||||
Send a message to the active provider.
|
||||
|
||||
md_content : aggregated markdown string from aggregate.run()
|
||||
user_message: the user question / instruction
|
||||
base_dir : project base directory (for PowerShell tool calls)
|
||||
file_items : list of file dicts from aggregate.build_file_items() for
|
||||
dynamic context refresh after tool calls
|
||||
"""
|
||||
if _provider == "gemini":
|
||||
return _send_gemini(static_md, dynamic_md, user_message, base_dir, file_items)
|
||||
return _send_gemini(md_content, user_message, base_dir, file_items)
|
||||
elif _provider == "anthropic":
|
||||
return _send_anthropic(static_md, dynamic_md, user_message, base_dir, file_items)
|
||||
raise ValueError(f"unknown provider: {_provider}")
|
||||
return _send_anthropic(md_content, user_message, base_dir, file_items)
|
||||
raise ValueError(f"unknown provider: {_provider}")
|
||||
+1
-1
@@ -17,4 +17,4 @@ paths = [
|
||||
"manual_slop.toml",
|
||||
"C:/projects/forth/bootslop/bootslop.toml",
|
||||
]
|
||||
active = "C:/projects/forth/bootslop/bootslop.toml"
|
||||
active = "manual_slop.toml"
|
||||
|
||||
@@ -121,19 +121,10 @@ def _add_kv_row(parent: str, key: str, val, val_color=None):
|
||||
|
||||
|
||||
def _render_usage(parent: str, usage: dict):
|
||||
"""Render Anthropic usage dict as a compact token table, with true totals."""
|
||||
"""Render Anthropic usage dict as a compact token table."""
|
||||
if not usage:
|
||||
return
|
||||
dpg.add_text("usage:", color=_SUBHDR_COLOR, parent=parent)
|
||||
|
||||
cache_read = usage.get("cache_read_input_tokens", 0)
|
||||
cache_create = usage.get("cache_creation_input_tokens", 0)
|
||||
raw_input = usage.get("input_tokens", 0)
|
||||
total_in = cache_read + cache_create + raw_input
|
||||
|
||||
if total_in > raw_input:
|
||||
_add_kv_row(parent, " total_input_tokens", total_in, _NUM_COLOR)
|
||||
|
||||
order = [
|
||||
"input_tokens",
|
||||
"cache_read_input_tokens",
|
||||
@@ -864,7 +855,7 @@ class App:
|
||||
}
|
||||
theme.save_to_config(self.config)
|
||||
|
||||
def _do_generate(self) -> tuple[str, str, Path, list]:
|
||||
def _do_generate(self) -> tuple[str, Path, list]:
|
||||
self._flush_to_project()
|
||||
self._save_active_project()
|
||||
self._flush_to_config()
|
||||
@@ -1119,9 +1110,8 @@ class App:
|
||||
|
||||
def cb_md_only(self):
|
||||
try:
|
||||
s_md, d_md, path, _file_items = self._do_generate()
|
||||
self.last_static_md = s_md
|
||||
self.last_dynamic_md = d_md
|
||||
md, path, _file_items = self._do_generate()
|
||||
self.last_md = md
|
||||
self.last_md_path = path
|
||||
self._update_status(f"md written: {path.name}")
|
||||
except Exception as e:
|
||||
@@ -1144,9 +1134,8 @@ class App:
|
||||
if self.send_thread and self.send_thread.is_alive():
|
||||
return
|
||||
try:
|
||||
s_md, d_md, path, file_items = self._do_generate()
|
||||
self.last_static_md = s_md
|
||||
self.last_dynamic_md = d_md
|
||||
md, path, file_items = self._do_generate()
|
||||
self.last_md = md
|
||||
self.last_md_path = path
|
||||
self.last_file_items = file_items
|
||||
except Exception as e:
|
||||
@@ -1163,7 +1152,6 @@ class App:
|
||||
if global_sp: combined_sp.append(global_sp.strip())
|
||||
if project_sp: combined_sp.append(project_sp.strip())
|
||||
ai_client.set_custom_system_prompt("\n\n".join(combined_sp))
|
||||
|
||||
temp = dpg.get_value("ai_temperature") if dpg.does_item_exist("ai_temperature") else 0.0
|
||||
max_tok = dpg.get_value("ai_max_tokens") if dpg.does_item_exist("ai_max_tokens") else 8192
|
||||
trunc = dpg.get_value("ai_history_trunc") if dpg.does_item_exist("ai_history_trunc") else 8000
|
||||
@@ -1174,7 +1162,7 @@ class App:
|
||||
if auto_add:
|
||||
self._queue_history_add("User", user_msg)
|
||||
try:
|
||||
response = ai_client.send(getattr(self, "last_static_md", ""), getattr(self, "last_dynamic_md", ""), user_msg, base_dir, self.last_file_items)
|
||||
response = ai_client.send(self.last_md, user_msg, base_dir, self.last_file_items)
|
||||
self._update_response(response)
|
||||
self._update_status("done")
|
||||
self._trigger_blink = True
|
||||
@@ -2131,4 +2119,4 @@ def main():
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
main()
|
||||
+37
-29
@@ -38,33 +38,12 @@ roles = [
|
||||
"Vendor API",
|
||||
"System",
|
||||
]
|
||||
active = "docs writeup"
|
||||
active = "docs"
|
||||
auto_add = true
|
||||
|
||||
[discussion.discussions.main]
|
||||
git_commit = "3b2da7873f839bf062c06c7539fadd9df7a3e2b9"
|
||||
last_updated = "2026-02-21T22:23:30"
|
||||
history = [
|
||||
"User:\nLets add multiple project support. currently this project would be the \"manual_slop\" project. \nI need to make a separate \"manual_slop.toml\" for this and offload things that would be within a per project to it.\nI also want to have several discussion histories that I can label. \nThis would allow me to stash and continue separate effort's histories..\nIt would be important if these discussions saved the last git commit they were on. (I'll need a buton to manually update it).\nAnd timestamps on each discussion entry to give an idea of the duration involved and \nhow the project has evolved since the discussion stream's effort was last done.\nBasically preserve context so ai can use that as valuable information in its working memory.\n\nTo load a project I'll need to hoist it into the config by providing the path.\nMaybe allow multiple project tomls to be specified path wise in the array.",
|
||||
"User:\nOk your started to work on multiple-project support, you added project_manager.py and manaual_slop.toml, its not being used yet. the rest of the codebase hasn't intergrated with the changes.\nThere might be something wrong with powershell tool use. If you suspect so try to fix that but otherwise lets finish the support for multiple projects.",
|
||||
"AI:\nGood. Now the big one — rewriting `gui.py`. The integration points are:\n\n- Load/save via `project_manager` instead of raw `config.toml` for project data\n- A **Projects** panel: list of loaded project paths, add/remove, switch active project, \"New Project\" button\n- A **Discussions** sub-section inside Discussion History panel: list named discussions, create/rename/delete/switch active, show git commit + timestamp, \"Update Commit\" button\n- `disc_entries` timestamps wired up via `entry_to_str`/`str_to_entry`\n- `_flush_to_project()` saves to the active `.toml`; `_flush_to_config()` saves global config only",
|
||||
"User:\nSure do it, I can't restart the program till you do those changes cause otherwise the config will not work.",
|
||||
"AI:\nI have a full picture of the current state. The plan is clear. I'll write the new `gui.py` as a Python script to avoid all PowerShell string-escaping issues — this is a full rewrite of that file since the integration touches nearly every part of it.\n\nKey changes:\n- `App.__init__` loads from `config.toml` for global settings (`[ai]`, `[theme]`, `[projects]`), then loads the active project `.toml` via `project_manager`\n- `_flush_to_project()` / `_flush_to_config()` save to the right places\n- **Projects panel** (`win_projects`): list of project paths from `config.toml [projects]`, add/remove, switch active — reloads all project-derived state\n- **Discussion selector** inside the Discussion History panel: list named discussions, create new, rename, delete, switch — each has git commit + last_updated shown, \"Update Commit\" button\n- `disc_entries` now round-trips through `project_manager.entry_to_str` / `str_to_entry` (preserving `@timestamp` lines)\n- New entries get a `ts` stamp when appended via `-> History`",
|
||||
"User:\nfor the love of god. please just finish the todos, I tired to fix a bug with the way your able to use tooling, hopefully its fixed\n\nOpus helped me:\nBug 1 — Anthropic SDK objects stored as-is in history (the main tool use bug): response.content contains Anthropic SDK ContentBlock objects (not dicts). These were stored directly into _anthropic_history. On subsequent API calls, the SDK would try to re-serialize these objects alongside plain dicts, causing failures — especially after tool use rounds where the history gets complex. Fixed by adding _content_block_to_dict() and calling [_content_block_to_dict(b) for b in response.content] before storing.\n\nBug 2 — _repair_anthropic_history tried to handle SDK objects but history now always has dicts: The old code had a dual path (isinstance(block, dict) vs getattr(block, \"type\", None)) to handle both dicts and SDK objects. Since we now always store dicts, I simplified it to only check dicts, removing the dead code path that could mask issues.\n\nBug 3 — Gemini function_call access without hasattr guard: The tool call list comprehension did part.function_call is not None without first checking hasattr(part, \"function_call\"). Added the hasattr guard to match the pattern used for part.text.\n\nBug 4 — Anthropic b.type == \"tool_use\" direct attribute access: Changed to getattr(b, \"type\", None) == \"tool_use\" for safer access on SDK content blocks during the response processing loop (before they get converted to dicts).\n\n---\nSo lets get back to supporting multiple projects thanks.",
|
||||
"AI:\nPowerShell is collapsing the string interpolation. I'll write it as a `.py` file instead.",
|
||||
"@2026-02-21T20:41:36\nUser:\nSo I did it with anythingllm frontend instead. \n\nLets update MainContext.md.\n\nSummary of what changed:\n\n- config.toml — now global-only: [ai], [theme], [projects] (paths list + active path). All project data removed.\n- gui.py — App.__init__ — loads global config, then loads the active project .toml via project_manager.load_project(). Falls back to migrate_from_legacy_config() if no valid project file exists.\n- _flush_to_project() / _flush_to_config() — split into two methods. _flush_to_project() writes widget values into self.project (the per-project dict). _flush_to_config() writes global settings ([ai], [theme], [projects]) into self.config.\n- _do_generate() — uses project_manager.flat_config() to produce the dict that aggregate.run() expects, so aggregate.py needs zero changes.\n- Projects panel — new win_projects window with: active project name display, git directory input, scrollable list of project paths (click to switch, x to remove), Add Project / New Project / Save All buttons.\n- Discussion selector — collapsible section inside Discussion History panel with: listbox of named discussions, git commit + last_updated display, Update Commit button, Create/Rename/Delete buttons with a name input field.\n- Timestamps — new entries get ts via project_manager.now_ts(). Displayed next to the role combo in the disc entry list. Round-tripped through entry_to_str/str_to_entry.\n- On exit — run() now saves both project and global config before destroying context, so nothing is lost on clean close.",
|
||||
"@2026-02-21T20:41:37\nVendor API:\nERROR: 'NoneType' object has no attribute 'messages'",
|
||||
"@2026-02-21T20:43:31\nUser:\nYou got an error when attempting to modify the markdown file wth powershell...\n\nScript used:\n```ps1\n[Omitted becuase issue was already resolved]\n```",
|
||||
"@2026-02-21T20:44:45\nAI:\n`MainContext.md` has been successfully updated. Here's a summary of what changed:\n\n**Sections updated:**\n\n1. **Files** — Added `project_manager.py`, `theme.py`, `config.toml` (now described as global-only), `manual_slop.toml` (per-project). Removed the old description of `config.toml` as holding all project data.\n\n2. **GUI Panels** — Added the **Projects** panel description. Updated **Discussion History** to include the discussion selector (collapsible header with listbox, git commit/timestamp display, Update Commit, Create/Rename/Delete), collapse toggle, timestamps, and the `-> History` button descriptions on Message/Response panels.\n\n3. **Project management** — New section describing the split config model, `_flush_to_project()`/`_flush_to_config()`, project switching, and new project creation.\n\n4. **Discussion management** — New section covering named discussions, create/rename/delete/switch, git commit tracking, and timestamps.\n\n5. **Entry serialisation** — New section documenting `entry_to_str`/`str_to_entry` round-trip format with `@timestamp` prefix.\n\n6. **Data flow** — Updated to reflect `self.project`/`self.disc_entries`, the two flush methods, `project_manager.flat_config()` producing the dict for `aggregate.run()`.\n\n7. **Config persistence** — Replaced with the new split model: `config.toml` (global) + `<project>.toml` (per-project), both saved on send/save/exit.\n\n8. **Anthropic bug fixes** — New section documenting the 4 SDK bugs that were fixed.",
|
||||
"@2026-02-21T20:47:42\nUser:\nOk perfect, now update your tool usage with this frontend prompting editor so that its easier for you to make code changes the way you just did with python.\n\nIn ./scripts/generated, just the last 3-4 scripts created to see what I mean.",
|
||||
"@2026-02-21T20:47:45\nAI:\nLet me verify that came out clean:",
|
||||
"@2026-02-21T20:47:54\nUser:\nGo for it! I'll restart it rn...",
|
||||
"@2026-02-21T20:50:05\nUser:\nnope thats all.",
|
||||
]
|
||||
|
||||
[discussion.discussions."Support files or mcp, or proper dynamic context uptake."]
|
||||
git_commit = "59208b27ec31949cb45cae382dddaee645b53b4f"
|
||||
last_updated = "2026-02-21T22:23:32"
|
||||
last_updated = "2026-02-22T17:04:05"
|
||||
history = [
|
||||
"@2026-02-21T21:30:26\nUser:\nLets add support for mcp. Similar to what claude code has, but restricted only to the paths provided. We have a basic summerizer right now but it doesn't work for all code files (we'd need tree-sitter). We need to add support for getting heurstics on what nuances each file has that is valuable for a basic overview before retrieving their content worst case to get a job done.",
|
||||
"@2026-02-21T21:30:32\nAI:\nThe PowerShell `Set-Content` write corrupted the file — it re-encoded it as Windows-1252, turning the UTF-8 em-dash (0xE2 0x80 0x94) into 0x97. I need to read the file as bytes, find and fix the corruption, apply the three targeted changes, and write back as UTF-8.",
|
||||
@@ -86,7 +65,7 @@ history = [
|
||||
|
||||
[discussion.discussions."Support for system prompt"]
|
||||
git_commit = "f258fc5765d647eb821716bce3d2000fb2ba2857"
|
||||
last_updated = "2026-02-21T22:23:31"
|
||||
last_updated = "2026-02-22T17:04:05"
|
||||
history = [
|
||||
"@2026-02-21T21:52:16\nUser:\ngemini testing 123 you get this message, first time using you proper in this custom frontend.",
|
||||
"@2026-02-21T21:52:17\nAI:\nLoud and clear! I've received your message through your **Manual Slop** frontend. It looks like a very capable environment—I can see you've implemented:\n\n1. **Multi-provider support** (Anthropic and Gemini).\n2. **Tool use/MCP-like capabilities** (file reading, directory listing, searching, etc.).\n3. **Project & Discussion management** via TOML files.\n4. **Rich GUI** using `dearpygui`.\n\nSince this is our first \"proper\" session in this interface, I'm ready to help you develop, debug, or refine the project. I have access to your local files through the tools you've provided, so I can see the codebase and even run PowerShell scripts if needed.\n\nIs there a specific feature you'd like to work on next, or any bugs in the Gemini integration you've noticed during your \"123\" test?",
|
||||
@@ -99,8 +78,8 @@ history = [
|
||||
]
|
||||
|
||||
[discussion.discussions."Make sure it works well with gemini"]
|
||||
git_commit = "32af96b3658b813cf4fd7e270b20d661187394cf"
|
||||
last_updated = "2026-02-21T22:46:10"
|
||||
git_commit = "73fad80257e285a0390e4e7e55886457c22a77a0"
|
||||
last_updated = "2026-02-22T17:04:55"
|
||||
history = [
|
||||
"@2026-02-21T22:26:26\nUser:\nQuestion: For how we're doing things right now, you have to re-read files cause its not saved in discussion history rigt per me sending a gen + send?\nIs there a way you can do a routine to save important files to the discussion or upload them temporarily to a gemini cache so that we don't have to worrya bout re-reading read-only things you already gathered info on for an active discussion?\nI'm not sure the depths of how this custom api works or custom tooling, etc gemini api from google provides...\n\n(I'm also reviewing using this with gemini to make sure there are no hiccups from how the api is utilized).",
|
||||
"@2026-02-21T22:26:32\nAI:\n(No text returned by the model)",
|
||||
@@ -112,7 +91,7 @@ history = [
|
||||
|
||||
[discussion.discussions."Misc UX improvements"]
|
||||
git_commit = "0cccaf05a36269a23224d731bf9a57a209a23f14"
|
||||
last_updated = "2026-02-22T01:08:35"
|
||||
last_updated = "2026-02-22T17:04:06"
|
||||
history = [
|
||||
"@2026-02-21T23:03:51\nUser:\nMake the namespace for the generated markdown file the projet name instead of that specific namespace in the config panel.\nThe ouptut dir can be in the project panel spec (that way we can just remove config panel entirely).",
|
||||
"@2026-02-21T23:04:55\nAI:\nI have applied the changes to streamline the project settings and remove the old Config panel completely:\n\n1. **GUI Layout Changes (`gui.py`)**:\n - The `Config` panel has been completely removed.\n - The **Output Dir** field has been moved into the **Projects** panel (right above the Project Files list).\n - The **Auto-add history** checkbox has been moved to the top of the **Discussion History** panel (directly under the entry toolbar buttons).\n - The **Files** panel was shifted upward to reclaim the space left behind by the old Config panel.\n\n2. **Namespace Management (`aggregate.py` & `project_manager.py`)**:\n - The explicitly defined `namespace` field is gone.\n - Instead, `aggregate.py` now directly infers the output markdown file namespace from the active project's `name` property. (e.g., saving under the `manual_slop` project will just prefix the markdown with `manual_slop_001.md`).\n - `project_manager.py` was updated to no longer scaffold out `namespace` strings into new `.toml` files, and `flat_config` now injects the core `project` dictionary segment so the generators can access the internal name.\n\n3. **`MainContext.md` Updated**:\n - I have updated the markdown file with the latest layout structures and explained that `namespace` is no longer a configurable setting but dynamically determined by the project profile.\n\nYou'll need to restart the application for the GUI layout and DearPyGUI state changes to take effect! Let me know how it looks.",
|
||||
@@ -133,7 +112,7 @@ history = [
|
||||
|
||||
[discussion.discussions."Support multi-viewporting"]
|
||||
git_commit = ""
|
||||
last_updated = "2026-02-22T08:53:04"
|
||||
last_updated = "2026-02-22T17:04:04"
|
||||
history = [
|
||||
"@2026-02-22T01:13:53\nUser:\nLets port this program to use https://github.com/pthom/hello_imgui or another imgui package (WEb search if you have to) that will allow us to do Multi-Viewporting (independent OS windows).\nSo far all my attempts to get dearpygui working with native os window multi-viewporting have failed. Which c++ imgui supports. I think there might be a python package that supports this.\n\nWorst case I might have to use a different language that has a dear imgui package with multi-viewporting support that may make ai codegen performance far worse.",
|
||||
"@2026-02-22T01:15:32\nAI:\nAlright, I've done extensive research on this. Here's my assessment:\n\n## The Multi-Viewport Situation in Python\n\n### The Problem with DearPyGui\nDearPyGui wraps Dear ImGui but **does not expose the multi-viewport feature** (dragging imgui windows out as independent OS windows). It has its own docking implementation but the C++ multi-viewport backend plumbing was never wired through to Python. You've already discovered this the hard way.\n\n### The Best Option: `imgui-bundle` (pip: `imgui-bundle`)\n\n**This is the package you want.** It's by Pascal Thomet (pthom) and wraps:\n- **Dear ImGui** (docking branch — which includes multi-viewport support)\n- **Hello ImGui** (application framework on top of Dear ImGui)\n- 20+ additional libraries (plots, markdown, node editors, etc.)\n\nKey facts:\n- `pip install imgui-bundle` — binary wheels for Windows/macOS/Linux\n- It tracks Dear ImGui's **docking branch** closely, which is the branch that contains `ImGuiConfigFlags_ViewportsEnable`\n- Full Python bindings with type hints\n- The API uses `imgui.config_flags` to set `ImGuiConfigFlags_ViewportsEnable`, then Dear ImGui handles creating/managing OS windows automatically when you drag an imgui window outside the main viewport\n- Has its own layout/docking system via Hello ImGui's dock spaces\n\n### What the Port Involves\n\nThis is a **massive** rewrite. The entire `gui.py` (2094 lines) and `theme.py` (415 lines) are completely DearPyGui-specific. Every single widget call, every tag system, every callback pattern is different. Here's the mapping:\n\n| DearPyGui | imgui-bundle |\n|---|---|\n| `dpg.add_text(...)` | `imgui.text(...)` |\n| `dpg.add_input_text(tag=..., ...)` | State variable + `imgui.input_text(...)` |\n| `dpg.add_button(label=..., callback=...)` | `if imgui.button(\"label\"): do_thing()` |\n| Tag-based widget identity | Immediate mode — no tags, state lives in your code |\n| `dpg.does_item_exist(tag)` | Not needed — immediate mode |\n| `dpg.configure_item(tag, ...)` | Just change the variable and it renders next frame |\n| `dpg.create_viewport()` + render loop | `hello_imgui.run(gui_func, params)` |\n| `dpg.enable_docking()` | `runner_params.imgui_window_params.enable_viewports = True` |\n| Theme via `dpg.add_theme_color()` | `imgui.push_style_color()` / `imgui.get_style()` |\n\nThe immediate-mode paradigm is actually **simpler** conceptually — no tags, no \"does item exist\" checks, no callback registration. But it means a full rewrite of all rendering code.\n\n### My Recommendation\n\n**Do the port to `imgui-bundle`.** Here's why:\n1. It definitively supports multi-viewporting (the whole point)\n2. The immediate-mode API is actually cleaner and more maintainable\n3. Better AI codegen compatibility (pthom specifically calls this out — readable code)\n4. You stay in Python — no language switch needed\n5. Active maintenance tracking upstream Dear ImGui\n\n### How I'd Structure the Port\n\n1. **Phase 1**: Install `imgui-bundle`, create a minimal proof-of-concept with multi-viewporting enabled (verify it works on your machine)\n2. **Phase 2**: Port `theme.py` → new imgui-bundle theming\n3. **Phase 3**: Port `gui.py` — the big one. Rewrite all panels as immediate-mode rendering functions\n4. **Phase 4**: Wire up the threading/callbacks (confirmation dialogs, background sends, etc.)\n\n**Want me to start with Phase 1 — a minimal proof-of-concept** that creates a window with viewports enabled, a couple of dockable panels, and verifies you can drag a panel out as an independent OS window? That way we confirm the foundation works before committing to the full rewrite.\n\nOr if you want me to just go all-in and start the full port, I can do that too. Your call.",
|
||||
@@ -147,7 +126,7 @@ history = [
|
||||
|
||||
[discussion.discussions."docs writeup"]
|
||||
git_commit = "bf2d09f3fd817d64fbf6b4aa667e2b635b6fbc0e"
|
||||
last_updated = "2026-02-22T11:08:58"
|
||||
last_updated = "2026-02-22T17:04:03"
|
||||
history = [
|
||||
"@2026-02-22T08:56:39\nUser:\nLets write extensive documentation in the same style that I used for my VEFontCache-Oodin project.\nI added it's directories to your context.",
|
||||
"@2026-02-22T08:56:58\nAI:\n(No text returned)",
|
||||
@@ -158,3 +137,32 @@ history = [
|
||||
"@2026-02-22T09:11:01\nUser:\nThe docs aren't detailed enough, the code isn't commented like the vefontcache code is. etc. My docs were straight to the point, in-depth, old school provides extensive insight into what was done.",
|
||||
"@2026-02-22T09:17:39\nVendor API:\n[GEMINI QUOTA EXHAUSTED]\n\n429 RESOURCE_EXHAUSTED. {'error': {'code': 429, 'message': 'You exceeded your current quota, please check your plan and billing details. For more information on this error, head to: https://ai.google.dev/gemini-api/docs/rate-limits. To monitor your current usage, head to: https://ai.dev/rate-limit. \\n* Quota exceeded for metric: generativelanguage.googleapis.com/generate_content_paid_tier_input_token_count, limit: 1000000, model: gemini-3.1-pro\\nPlease retry in 20.333361679s.', 'status': 'RESOURCE_EXHAUSTED', 'details': [{'@type': 'type.googleapis.com/google.rpc.Help', 'links': [{'description': 'Learn more about Gemini API quotas', 'url': 'https://ai.google.dev/gemini-api/docs/rate-limits'}]}, {'@type': 'type.googleapis.com/google.rpc.QuotaFailure', 'violations': [{'quotaMetric': 'generativelanguage.googleapis.com/generate_content_paid_tier_input_token_count', 'quotaId': 'GenerateContentPaidTierInputTokensPerModelPerMinute', 'quotaDimensions': {'location': 'global', 'model': 'gemini-3.1-pro'}, 'quotaValue': '1000000'}]}, {'@type': 'type.googleapis.com/google.rpc.RetryInfo', 'retryDelay': '20s'}]}}",
|
||||
]
|
||||
|
||||
[discussion.discussions."mutliple project support"]
|
||||
git_commit = "3b2da7873f839bf062c06c7539fadd9df7a3e2b9"
|
||||
last_updated = "2026-02-22T17:04:45"
|
||||
history = [
|
||||
"User:\nLets add multiple project support. currently this project would be the \"manual_slop\" project. \nI need to make a separate \"manual_slop.toml\" for this and offload things that would be within a per project to it.\nI also want to have several discussion histories that I can label. \nThis would allow me to stash and continue separate effort's histories..\nIt would be important if these discussions saved the last git commit they were on. (I'll need a buton to manually update it).\nAnd timestamps on each discussion entry to give an idea of the duration involved and \nhow the project has evolved since the discussion stream's effort was last done.\nBasically preserve context so ai can use that as valuable information in its working memory.\n\nTo load a project I'll need to hoist it into the config by providing the path.\nMaybe allow multiple project tomls to be specified path wise in the array.",
|
||||
"User:\nOk your started to work on multiple-project support, you added project_manager.py and manaual_slop.toml, its not being used yet. the rest of the codebase hasn't intergrated with the changes.\nThere might be something wrong with powershell tool use. If you suspect so try to fix that but otherwise lets finish the support for multiple projects.",
|
||||
"AI:\nGood. Now the big one — rewriting `gui.py`. The integration points are:\n\n- Load/save via `project_manager` instead of raw `config.toml` for project data\n- A **Projects** panel: list of loaded project paths, add/remove, switch active project, \"New Project\" button\n- A **Discussions** sub-section inside Discussion History panel: list named discussions, create/rename/delete/switch active, show git commit + timestamp, \"Update Commit\" button\n- `disc_entries` timestamps wired up via `entry_to_str`/`str_to_entry`\n- `_flush_to_project()` saves to the active `.toml`; `_flush_to_config()` saves global config only",
|
||||
"User:\nSure do it, I can't restart the program till you do those changes cause otherwise the config will not work.",
|
||||
"AI:\nI have a full picture of the current state. The plan is clear. I'll write the new `gui.py` as a Python script to avoid all PowerShell string-escaping issues — this is a full rewrite of that file since the integration touches nearly every part of it.\n\nKey changes:\n- `App.__init__` loads from `config.toml` for global settings (`[ai]`, `[theme]`, `[projects]`), then loads the active project `.toml` via `project_manager`\n- `_flush_to_project()` / `_flush_to_config()` save to the right places\n- **Projects panel** (`win_projects`): list of project paths from `config.toml [projects]`, add/remove, switch active — reloads all project-derived state\n- **Discussion selector** inside the Discussion History panel: list named discussions, create new, rename, delete, switch — each has git commit + last_updated shown, \"Update Commit\" button\n- `disc_entries` now round-trips through `project_manager.entry_to_str` / `str_to_entry` (preserving `@timestamp` lines)\n- New entries get a `ts` stamp when appended via `-> History`",
|
||||
"User:\nfor the love of god. please just finish the todos, I tired to fix a bug with the way your able to use tooling, hopefully its fixed\n\nOpus helped me:\nBug 1 — Anthropic SDK objects stored as-is in history (the main tool use bug): response.content contains Anthropic SDK ContentBlock objects (not dicts). These were stored directly into _anthropic_history. On subsequent API calls, the SDK would try to re-serialize these objects alongside plain dicts, causing failures — especially after tool use rounds where the history gets complex. Fixed by adding _content_block_to_dict() and calling [_content_block_to_dict(b) for b in response.content] before storing.\n\nBug 2 — _repair_anthropic_history tried to handle SDK objects but history now always has dicts: The old code had a dual path (isinstance(block, dict) vs getattr(block, \"type\", None)) to handle both dicts and SDK objects. Since we now always store dicts, I simplified it to only check dicts, removing the dead code path that could mask issues.\n\nBug 3 — Gemini function_call access without hasattr guard: The tool call list comprehension did part.function_call is not None without first checking hasattr(part, \"function_call\"). Added the hasattr guard to match the pattern used for part.text.\n\nBug 4 — Anthropic b.type == \"tool_use\" direct attribute access: Changed to getattr(b, \"type\", None) == \"tool_use\" for safer access on SDK content blocks during the response processing loop (before they get converted to dicts).\n\n---\nSo lets get back to supporting multiple projects thanks.",
|
||||
"AI:\nPowerShell is collapsing the string interpolation. I'll write it as a `.py` file instead.",
|
||||
"@2026-02-21T20:41:36\nUser:\nSo I did it with anythingllm frontend instead. \n\nLets update MainContext.md.\n\nSummary of what changed:\n\n- config.toml — now global-only: [ai], [theme], [projects] (paths list + active path). All project data removed.\n- gui.py — App.__init__ — loads global config, then loads the active project .toml via project_manager.load_project(). Falls back to migrate_from_legacy_config() if no valid project file exists.\n- _flush_to_project() / _flush_to_config() — split into two methods. _flush_to_project() writes widget values into self.project (the per-project dict). _flush_to_config() writes global settings ([ai], [theme], [projects]) into self.config.\n- _do_generate() — uses project_manager.flat_config() to produce the dict that aggregate.run() expects, so aggregate.py needs zero changes.\n- Projects panel — new win_projects window with: active project name display, git directory input, scrollable list of project paths (click to switch, x to remove), Add Project / New Project / Save All buttons.\n- Discussion selector — collapsible section inside Discussion History panel with: listbox of named discussions, git commit + last_updated display, Update Commit button, Create/Rename/Delete buttons with a name input field.\n- Timestamps — new entries get ts via project_manager.now_ts(). Displayed next to the role combo in the disc entry list. Round-tripped through entry_to_str/str_to_entry.\n- On exit — run() now saves both project and global config before destroying context, so nothing is lost on clean close.",
|
||||
"@2026-02-21T20:41:37\nVendor API:\nERROR: 'NoneType' object has no attribute 'messages'",
|
||||
"@2026-02-21T20:43:31\nUser:\nYou got an error when attempting to modify the markdown file wth powershell...\n\nScript used:\n```ps1\n[Omitted becuase issue was already resolved]\n```",
|
||||
"@2026-02-21T20:44:45\nAI:\n`MainContext.md` has been successfully updated. Here's a summary of what changed:\n\n**Sections updated:**\n\n1. **Files** — Added `project_manager.py`, `theme.py`, `config.toml` (now described as global-only), `manual_slop.toml` (per-project). Removed the old description of `config.toml` as holding all project data.\n\n2. **GUI Panels** — Added the **Projects** panel description. Updated **Discussion History** to include the discussion selector (collapsible header with listbox, git commit/timestamp display, Update Commit, Create/Rename/Delete), collapse toggle, timestamps, and the `-> History` button descriptions on Message/Response panels.\n\n3. **Project management** — New section describing the split config model, `_flush_to_project()`/`_flush_to_config()`, project switching, and new project creation.\n\n4. **Discussion management** — New section covering named discussions, create/rename/delete/switch, git commit tracking, and timestamps.\n\n5. **Entry serialisation** — New section documenting `entry_to_str`/`str_to_entry` round-trip format with `@timestamp` prefix.\n\n6. **Data flow** — Updated to reflect `self.project`/`self.disc_entries`, the two flush methods, `project_manager.flat_config()` producing the dict for `aggregate.run()`.\n\n7. **Config persistence** — Replaced with the new split model: `config.toml` (global) + `<project>.toml` (per-project), both saved on send/save/exit.\n\n8. **Anthropic bug fixes** — New section documenting the 4 SDK bugs that were fixed.",
|
||||
"@2026-02-21T20:47:42\nUser:\nOk perfect, now update your tool usage with this frontend prompting editor so that its easier for you to make code changes the way you just did with python.\n\nIn ./scripts/generated, just the last 3-4 scripts created to see what I mean.",
|
||||
"@2026-02-21T20:47:45\nAI:\nLet me verify that came out clean:",
|
||||
"@2026-02-21T20:47:54\nUser:\nGo for it! I'll restart it rn...",
|
||||
"@2026-02-21T20:50:05\nUser:\nnope thats all.",
|
||||
]
|
||||
|
||||
[discussion.discussions.docs]
|
||||
git_commit = ""
|
||||
last_updated = "2026-02-22T17:16:20"
|
||||
history = [
|
||||
"@2026-02-22T17:05:29\nUser:\nUpdate docs with latest state of the codebase files. ai_client.py and aggregate.py both recieved updates.",
|
||||
"@2026-02-22T17:06:43\nVendor API:\n[GEMINI QUOTA EXHAUSTED]\n\n429 RESOURCE_EXHAUSTED. {'error': {'code': 429, 'message': 'Resource has been exhausted (e.g. check quota).', 'status': 'RESOURCE_EXHAUSTED'}}",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user