From 06a200d676d7744787cc2f86d8c5ef58bff02130 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 25 Apr 2026 21:25:47 -0400 Subject: [PATCH 1/9] Bump Version --- cecli/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cecli/__init__.py b/cecli/__init__.py index 4e43758df83..61710003ccb 100644 --- a/cecli/__init__.py +++ b/cecli/__init__.py @@ -1,6 +1,6 @@ from packaging import version -__version__ = "0.99.6.dev" +__version__ = "0.99.7.dev" safe_version = __version__ try: From 50619feb9e2432ccd0652daf113fa87579c7e1ff Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 25 Apr 2026 21:26:26 -0400 Subject: [PATCH 2/9] Fix /copy-context command --- cecli/commands/copy_context.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/cecli/commands/copy_context.py b/cecli/commands/copy_context.py index c490bb54c1d..900557a7eb4 100644 --- a/cecli/commands/copy_context.py +++ b/cecli/commands/copy_context.py @@ -4,6 +4,7 @@ from cecli.commands.utils.base_command import BaseCommand from cecli.commands.utils.helpers import format_command_result +from cecli.helpers.conversation import ConversationService, MessageTag class CopyContextCommand(BaseCommand): @@ -13,13 +14,13 @@ class CopyContextCommand(BaseCommand): @classmethod async def execute(cls, io, coder, args, **kwargs): """Execute the copy-context command with given parameters.""" - chunks = coder.format_chat_chunks() + manager = ConversationService.get_manager(coder) markdown = "" - # Only include specified chunks in order - for messages in [chunks.repo, chunks.readonly_files, chunks.chat_files]: - for msg in messages: + # Only include specified chunks in order using conversation tags + for tag in [MessageTag.REPO, MessageTag.READONLY_FILES, MessageTag.CHAT_FILES]: + for msg in manager.get_messages_dict(tag=tag): # Only include user messages if msg["role"] != "user": continue From f428d1f965c596e9eb88335099a7066d6e93f33f Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 25 Apr 2026 22:07:44 -0400 Subject: [PATCH 3/9] File Context don't need to be promoted any longer with the randomized CTAs in place. Better for caching --- cecli/helpers/conversation/integration.py | 12 ------------ cecli/tools/show_context.py | 5 +++-- 2 files changed, 3 insertions(+), 14 deletions(-) diff --git a/cecli/helpers/conversation/integration.py b/cecli/helpers/conversation/integration.py index 785d2e215d8..aba4f584670 100644 --- a/cecli/helpers/conversation/integration.py +++ b/cecli/helpers/conversation/integration.py @@ -786,12 +786,6 @@ def add_file_context_messages(self, promote_messages=True) -> None: tag=MessageTag.FILE_CONTEXTS, hash_key=("file_context_user", file_path), force=True, - promotion=( - ConversationService.get_manager(coder).DEFAULT_TAG_PROMOTION_VALUE - if promote_messages - else None - ), - mark_for_demotion=1 if promote_messages else None, ) ConversationService.get_manager(coder).add_message( @@ -799,12 +793,6 @@ def add_file_context_messages(self, promote_messages=True) -> None: tag=MessageTag.FILE_CONTEXTS, hash_key=("file_context_assistant", file_path), force=True, - promotion=( - ConversationService.get_manager(coder).DEFAULT_TAG_PROMOTION_VALUE - if promote_messages - else None - ), - mark_for_demotion=1 if promote_messages else None, ) def reset(self) -> None: diff --git a/cecli/tools/show_context.py b/cecli/tools/show_context.py index 82516936c58..8aac09d485a 100644 --- a/cecli/tools/show_context.py +++ b/cecli/tools/show_context.py @@ -269,6 +269,7 @@ def execute(cls, coder, show, **kwargs): already_up_to_date = True else: ConversationService.get_files(coder).remove_file_messages(abs_path) + ConversationService.get_chunks(coder).add_file_context_messages() # Log success and return the formatted context directly @@ -277,8 +278,8 @@ def execute(cls, coder, show, **kwargs): if already_up_to_date: coder.io.tool_output("File contents already up to date") return ( - "File contents already up to date. Please proceed with your task. " - "Do not call ShowContext again until you edit the file." + "File contents already up to date." + "Do not call ShowContext again with these parameters until you edit the file." ) else: coder.io.tool_output(f"✅ Successfully retrieved context for {len(show)} file(s)") From 7ab96293b7717b87847d9fccd0b5465d25f7b3a7 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 25 Apr 2026 22:22:15 -0400 Subject: [PATCH 4/9] Rename ExploreSymbols to ExploreCode so it makes a bit more sense to models what it's for --- cecli/coders/agent_coder.py | 2 +- cecli/prompts/agent.yml | 2 +- cecli/tools/__init__.py | 4 ++-- cecli/tools/{explore_symbols.py => explore_code.py} | 8 ++++---- 4 files changed, 8 insertions(+), 8 deletions(-) rename cecli/tools/{explore_symbols.py => explore_code.py} (98%) diff --git a/cecli/coders/agent_coder.py b/cecli/coders/agent_coder.py index a24714dff1b..372503b3780 100644 --- a/cecli/coders/agent_coder.py +++ b/cecli/coders/agent_coder.py @@ -53,7 +53,7 @@ def __init__(self, *args, **kwargs): self.read_tools = { "command", "commandinteractive", - "exploresymbols", + "explorecode", "ls", "grep", "showcontext", diff --git a/cecli/prompts/agent.yml b/cecli/prompts/agent.yml index c97c7402382..180b8addcb2 100644 --- a/cecli/prompts/agent.yml +++ b/cecli/prompts/agent.yml @@ -39,7 +39,7 @@ main_system: | ## Core Workflow 1. **Plan**: Start by using `UpdateTodoList` to outline the task. - 2. **Explore**: Use discovery tools (`ExploreSymbols`, `Grep`, `Ls`) to research and gather understanding for you task. Use `ContextManager` to add files contents as context. + 2. **Explore**: Use discovery tools (`ExploreCode`, `Grep`, `Ls`) to research and gather understanding for you task. Use `ContextManager` to add files contents as context. 3. **Execute**: Use the appropriate editing tool. Mark files as editable with `ContextManager` before attempting edits. Proactively use skills if they are available. 4. **Verify & Recover**: Review the diff output of every edit. If an edit fails or introduces linting errors, use `UndoChange` immediately. 5. **Finished**: Use the `Finished` tool only after verifying the solution. Briefly summarize the changes for the user. diff --git a/cecli/tools/__init__.py b/cecli/tools/__init__.py index 0327bf24a9a..22c3efaff32 100644 --- a/cecli/tools/__init__.py +++ b/cecli/tools/__init__.py @@ -7,7 +7,7 @@ command_interactive, context_manager, delete_text, - explore_symbols, + explore_code, finished, git_branch, git_diff, @@ -33,7 +33,7 @@ command_interactive, context_manager, delete_text, - explore_symbols, + explore_code, finished, git_branch, git_diff, diff --git a/cecli/tools/explore_symbols.py b/cecli/tools/explore_code.py similarity index 98% rename from cecli/tools/explore_symbols.py rename to cecli/tools/explore_code.py index 930ede70ce1..dbe12c60377 100644 --- a/cecli/tools/explore_symbols.py +++ b/cecli/tools/explore_code.py @@ -18,11 +18,11 @@ class Tool(BaseTool): - NORM_NAME = "exploresymbols" + NORM_NAME = "explorecode" SCHEMA = { "type": "function", "function": { - "name": "ExploreSymbols", + "name": "ExploreCode", "description": ( "Search, investigate, and find references to symbols using the Cymbal code indexing" " library. This is the preferred tool for analyzing code structure." @@ -174,7 +174,7 @@ def execute(cls, coder, queries, **kwargs): return "\n\n" + "=" * 40 + "\n\n".join(all_results) except Exception as e: - coder.io.tool_error(f"Error in ExploreSymbols: {str(e)}") + coder.io.tool_error(f"Error in ExploreCode: {str(e)}") return f"Error: {str(e)}" finally: if "c" in locals(): @@ -293,7 +293,7 @@ def _format_reference_results(cls, references, symbol): @classmethod def format_output(cls, coder, mcp_server, tool_response): - """Format output for ExploreSymbols tool.""" + """Format output for ExploreCode tool.""" color_start, color_end = color_markers(coder) try: From 8334f35b29ba90bc5cdd3fcd3278da747b780390 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 26 Apr 2026 00:23:02 -0400 Subject: [PATCH 5/9] Update summarization prompts for observation/compression and compaction --- cecli/history.py | 21 ++------------------- cecli/models.py | 1 - cecli/prompts/base.yml | 12 +++++++----- 3 files changed, 9 insertions(+), 25 deletions(-) diff --git a/cecli/history.py b/cecli/history.py index 0d0cac6b089..74b61d735c4 100644 --- a/cecli/history.py +++ b/cecli/history.py @@ -136,28 +136,11 @@ async def summarize_all(self, messages): raise ValueError(err) async def summarize_all_as_text(self, messages, prompt, max_tokens=None): - content = "" - for msg in messages: - role = msg["role"].upper() - if role not in ("USER", "ASSISTANT"): - continue - if not msg.get("content"): - continue - content += f"# {role}\n" - content += msg["content"] - if not content.endswith("\n"): - content += "\n" - - summarize_messages = [ - dict(role="system", content=prompt), - dict(role="user", content=content), - ] + messages.append(dict(role="user", content=prompt)) for model in self.models: try: - summary = await model.simple_send_with_retries( - summarize_messages, max_tokens=max_tokens - ) + summary = await model.simple_send_with_retries(messages, max_tokens=max_tokens) if summary is not None: return summary except Exception as e: diff --git a/cecli/models.py b/cecli/models.py index 8cf915860eb..08bc9996f80 100644 --- a/cecli/models.py +++ b/cecli/models.py @@ -1128,7 +1128,6 @@ async def simple_send_with_retries(self, messages, max_tokens=None): from cecli.exceptions import LiteLLMExceptions litellm_ex = LiteLLMExceptions() - messages = model_request_parser(self, messages, None) retry_delay = 0.125 if self.verbose: dump(messages) diff --git a/cecli/prompts/base.yml b/cecli/prompts/base.yml index f9ada82d002..11c54ad8096 100644 --- a/cecli/prompts/base.yml +++ b/cecli/prompts/base.yml @@ -84,8 +84,9 @@ rename_with_shell: "" go_ahead_tip: "" compaction_prompt: | + --- # Instruction: Context Compaction & State Preservation - The following conversation is exceeding the context limit. Transform this history into a "Mission Intent" summary that allows a new LLM instance to resume with zero loss of technical momentum. + The current conversation is exceeding the context limit. Transform this history into a "Mission Intent" summary that allows a new LLM instance to resume with zero loss of technical momentum. ## Required Output Format: @@ -100,11 +101,11 @@ compaction_prompt: | ### 3. Strategic Pivot & Next Steps - **Current Intent**: What is the model currently trying to prove or implement? - **Immediate Next Steps**: The prioritized next tool calls and logic steps. - --- - ## Conversation History to Compact: + observation_prompt: | - Review the following conversation history and extract an + --- + Review the current conversation stream and extract an event-based log of key decisions, tool outputs, and user requests. Focus on "what happened" rather than a narrative summary. Use a bulleted list of concise, factual statements. @@ -112,7 +113,8 @@ observation_prompt: | Do not describe tool syntax; describe what was learned or changed in one sentence per bullet. reflection_prompt: | - Condense the following observation log into a consolidated set of + --- + Condense this observation log into a consolidated set of key historical facts. Additionally, explicitly state the CURRENT GOALS and NEXT STEPS based on the history. From 6e3a9756718bea5189d7a6a220dd880ba121e116 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 26 Apr 2026 12:19:01 -0400 Subject: [PATCH 6/9] Add leak detection utility for development (needs to be dialed in) --- cecli/helpers/leak_detect.py | 351 ++++++++++++++++++++++++++++ requirements/common-constraints.txt | 4 + requirements/requirements-dev.in | 2 + requirements/requirements-dev.txt | 8 + 4 files changed, 365 insertions(+) create mode 100644 cecli/helpers/leak_detect.py diff --git a/cecli/helpers/leak_detect.py b/cecli/helpers/leak_detect.py new file mode 100644 index 00000000000..d36fd9e8afa --- /dev/null +++ b/cecli/helpers/leak_detect.py @@ -0,0 +1,351 @@ +"""Memory leak detection and heap analysis utilities. + +Provides a MemorySnapshot class to inspect the largest allocated objects +in the Python heap: dicts, lists, and custom class instances. + +Usage: + from cecli.helpers.leak_detect import MemorySnapshot + + snap = MemorySnapshot() + snap.print_report() + + # Programmatic access: + for item in snap.largest_dicts(5): + print(item["size_kb"], item["keys"]) +""" + +from __future__ import annotations + +import gc +import sys +from collections import Counter +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + +# ── Optional dependency wrappers ── + + +def _has_pympler() -> bool: + try: + import pympler # noqa: F401 + + return True + except ImportError: + return False + + +def _pympler_deep_size(obj: Any) -> int: + """Return deep size of *obj* in bytes using pympler (if available).""" + from pympler import asizeof + + return asizeof.asizeof(obj) + + +def _has_guppy() -> bool: + try: + from guppy import hpy # noqa: F401 + + return True + except ImportError: + return False + + +def _guppy_heap_summary() -> str: + """Return per-type heap summary as a formatted string (guppy).""" + from guppy import hpy + + hp = hpy() + heap = hp.heap() + return str(heap) + + +# ── Data structures ── + + +@dataclass +class ObjectInfo: + """Describes a single allocated object found during a heap scan.""" + + type_name: str + size_bytes: int + size_kb: float + repr_str: str + obj: Any = field(repr=False) + + @property + def size_mb(self) -> float: + return self.size_bytes / (1024 * 1024) + + +@dataclass +class TypeSummary: + """Aggregated statistics for a single type across the heap.""" + + type_name: str + count: int + total_size_bytes: int + + @property + def total_size_kb(self) -> float: + return self.total_size_bytes / 1024 + + @property + def total_size_mb(self) -> float: + return self.total_size_bytes / (1024 * 1024) + + +# ── Snapshot class ── + + +class MemorySnapshot: + """Capture and analyse the current Python heap. + + Uses pympler when available for deep (recursive) size calculations; + falls back to sys.getsizeof (shallow) otherwise. + + Example + ------- + >>> snap = MemorySnapshot() + >>> snap.print_report() + + Usage + ------- + from cecli.helpers.leak_detect import MemorySnapshot + MemorySnapshot().print_report() + """ + + def __init__(self, force_gc: bool = True): + if force_gc: + gc.collect() + self._all_objects: List[Any] = gc.get_objects() + self._has_pympler = _has_pympler() + self._has_guppy = _has_guppy() + + # ── Size helpers ── + + def _size_of(self, obj: Any) -> int: + # if self._has_pympler: + # return _pympler_deep_size(obj) + return sys.getsizeof(obj) + + # ── Public query methods ── + + def largest_objects(self, n: int = 15) -> List[ObjectInfo]: + """Return the *n* largest objects in the heap (any type).""" + seen: set = set() + results: List[ObjectInfo] = [] + + try: + for obj in self._all_objects: + obj_id = id(obj) + if obj_id in seen: + continue + seen.add(obj_id) + size = self._size_of(obj) + if size < 1024: + continue + try: + short = repr(obj)[:80] + except Exception: + short = "" + results.append( + ObjectInfo( + type_name=type(obj).__name__, + size_bytes=size, + size_kb=size / 1024, + repr_str=short, + obj=obj, + ) + ) + except Exception as e: + print(e) + + results.sort(key=lambda x: x.size_bytes, reverse=True) + print("result") + return results[:n] + + def largest_dicts(self, n: int = 5) -> List[ObjectInfo]: + """Return the *n* largest ``dict`` objects.""" + return self._filter_type(dict, n) + + def largest_lists(self, n: int = 5) -> List[ObjectInfo]: + """Return the *n* largest ``list`` objects.""" + return self._filter_type(list, n) + + def largest_class_instances( + self, classes: Optional[tuple] = None, n: int = 5 + ) -> List[ObjectInfo]: + """Return the *n* largest instances of the given *classes*. + + When *classes* is ``None`` all instances of user-defined classes + (excluding builtins) are considered. + """ + if classes is not None: + targets = tuple(c for c in classes if isinstance(c, type)) + else: + # Heuristic: any type whose module is not a built-in. + targets = None + + seen: set = set() + results: List[ObjectInfo] = [] + + for obj in self._all_objects: + obj_id = id(obj) + if obj_id in seen: + continue + seen.add(obj_id) + if not isinstance(obj, type) and not callable(obj): + if targets is not None: + if not isinstance(obj, targets): + continue + else: + mod = type(obj).__module__ + if mod in ("builtins", "_abc", "abc"): + continue + if type(obj).__name__.startswith("_"): + continue + + size = self._size_of(obj) + if size < 512: + continue + try: + short = repr(obj)[:80] + except Exception: + short = "" + results.append( + ObjectInfo( + type_name=type(obj).__name__, + size_bytes=size, + size_kb=size / 1024, + repr_str=short, + obj=obj, + ) + ) + + results.sort(key=lambda x: x.size_bytes, reverse=True) + return results[:n] + + def type_summary(self, n: int = 15) -> List[TypeSummary]: + """Aggregate heap usage by type (total count and size).""" + counter: Counter = Counter() + size_map: Dict[str, int] = {} + + seen: set = set() + for obj in self._all_objects: + obj_id = id(obj) + if obj_id in seen: + continue + seen.add(obj_id) + tn = type(obj).__name__ + counter[tn] += 1 + size_map[tn] = size_map.get(tn, 0) + self._size_of(obj) + + sorted_types = sorted(counter, key=lambda t: size_map[t], reverse=True) + + return [ + TypeSummary( + type_name=tn, + count=counter[tn], + total_size_bytes=size_map[tn], + ) + for tn in sorted_types[:n] + ] + + def guppy_summary(self) -> Optional[str]: + """Return per-type heap summary string via guppy (if installed).""" + if not self._has_guppy: + return None + return _guppy_heap_summary() + + # ── Report ── + + def print_report(self) -> None: + """Print a human-readable memory report to stdout.""" + print("=" * 70) + print("MemorySnapshot Report") + print(f" Objects scanned : {len(self._all_objects):,}") + print( + f" Deep sizes : {'yes (pympler)' if self._has_pympler else 'no (sys.getsizeof)'}" + ) + print(f" Guppy available : {'yes' if self._has_guppy else 'no'}") + print("=" * 70) + + # 1. Largest objects + print("\n>> TOP 15 LARGEST OBJECTS (any type)") + print(" " + "-" * 60) + for item in self.largest_objects(15): + print(f" {item.size_kb:>10.1f} KB {item.type_name:<15s} {item.repr_str}") + + # 2. Largest dicts + print("\n>> LARGEST DICTS") + print(" " + "-" * 60) + for item in self.largest_dicts(5): + d = item.obj + keys_preview = list(d.keys())[:5] if isinstance(d, dict) else [] + print(f" {item.size_kb:>10.1f} KB dict[{len(d)} keys] keys={keys_preview!r}") + + # 3. Largest lists + print("\n>> LARGEST LISTS") + print(" " + "-" * 60) + for item in self.largest_lists(5): + lst = item.obj + first = lst[0] if lst else "empty" + print(f" {item.size_kb:>10.1f} KB list[{len(lst)} items] first={first!r}") + + # 4. Largest class instances + print("\n>> LARGEST CLASS INSTANCES (custom)") + print(" " + "-" * 60) + for item in self.largest_class_instances(n=10): + print(f" {item.size_kb:>10.1f} KB {item.type_name}") + + # 5. Type summary + print("\n>> TYPE SUMMARY (total size per type)") + print(" " + "-" * 60) + for ts in self.type_summary(12): + print(f" {ts.total_size_kb:>10.1f} KB ({ts.count:>7,} objs) {ts.type_name}") + + # 6. Guppy summary if available + if self._has_guppy: + print("\n>> GUPPY HEAP SUMMARY") + print(" " + "-" * 60) + summary = self.guppy_summary() + if summary: + for line in summary.split("\n"): + print(f" {line}") + + print() + + # ── Internal helpers ── + + def _filter_type(self, typ: type, n: int) -> List[ObjectInfo]: + """Return the *n* largest objects of a specific built-in type.""" + seen: set = set() + results: List[ObjectInfo] = [] + + for obj in self._all_objects: + obj_id = id(obj) + if obj_id in seen: + continue + seen.add(obj_id) + if not isinstance(obj, typ): + continue + + size = self._size_of(obj) + if size < 512: + continue + try: + short = repr(obj)[:80] + except Exception: + short = "" + results.append( + ObjectInfo( + type_name=type(obj).__name__, + size_bytes=size, + size_kb=size / 1024, + repr_str=short, + obj=obj, + ) + ) + + results.sort(key=lambda x: x.size_bytes, reverse=True) + return results[:n] diff --git a/requirements/common-constraints.txt b/requirements/common-constraints.txt index fd9e4dc92c8..32acc862775 100644 --- a/requirements/common-constraints.txt +++ b/requirements/common-constraints.txt @@ -119,6 +119,8 @@ greenlet==3.2.4 # sqlalchemy griffe==1.15.0 # via banks +guppy3==3.1.6 + # via -r requirements/requirements-dev.in h11==0.16.0 # via # httpcore @@ -374,6 +376,8 @@ pygments==2.19.2 # textual pyjwt[crypto]==2.10.1 # via mcp +pympler==1.1 + # via -r requirements/requirements-dev.in pypandoc==1.16.2 # via -r requirements/requirements.in pyparsing==3.2.5 diff --git a/requirements/requirements-dev.in b/requirements/requirements-dev.in index 80e721de972..760baa3ee39 100644 --- a/requirements/requirements-dev.in +++ b/requirements/requirements-dev.in @@ -15,3 +15,5 @@ codespell uv memray objgraph +pympler +guppy3 diff --git a/requirements/requirements-dev.txt b/requirements/requirements-dev.txt index a9c64454b87..0a7917c341f 100644 --- a/requirements/requirements-dev.txt +++ b/requirements/requirements-dev.txt @@ -41,6 +41,10 @@ fonttools==4.60.1 # via # -c requirements/common-constraints.txt # matplotlib +guppy3==3.1.6 + # via + # -c requirements/common-constraints.txt + # -r requirements/requirements-dev.in identify==2.6.15 # via # -c requirements/common-constraints.txt @@ -150,6 +154,10 @@ pygments==2.19.2 # pytest # rich # textual +pympler==1.1 + # via + # -c requirements/common-constraints.txt + # -r requirements/requirements-dev.in pyparsing==3.2.5 # via # -c requirements/common-constraints.txt From 1f980f03c9ff47cc1c9a7937ac80f7e3accdb210 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 26 Apr 2026 12:27:15 -0400 Subject: [PATCH 7/9] Update CommandInteractive description to aid models in knowing when to use it --- cecli/tools/command_interactive.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/cecli/tools/command_interactive.py b/cecli/tools/command_interactive.py index 45d3251bdcb..1c591e51a37 100644 --- a/cecli/tools/command_interactive.py +++ b/cecli/tools/command_interactive.py @@ -12,7 +12,11 @@ class Tool(BaseTool): "type": "function", "function": { "name": "CommandInteractive", - "description": "Execute a shell command interactively.", + "description": ( + "Execute a shell command interactively." + " Useful when you need the user to provide inputs like passwords" + " or navigating terminal interfaces." + ), "parameters": { "type": "object", "properties": { From 74cd5ca5a4607e7e41ad069e68f47280fef043a4 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 26 Apr 2026 15:41:33 -0400 Subject: [PATCH 8/9] Clear agent folders on program shutdown --- cecli/main.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/cecli/main.py b/cecli/main.py index 76b92cb0870..f4a4b2843ed 100644 --- a/cecli/main.py +++ b/cecli/main.py @@ -23,6 +23,7 @@ import json import os import re +import shutil import threading import time import traceback @@ -1511,6 +1512,36 @@ async def graceful_exit(coder=None, exit_code=0): if coder.mcp_manager and coder.mcp_manager.is_connected: await coder.mcp_manager.disconnect_all() + # Cleanup old agent directories + try: + git_root = get_git_root() + if git_root: + agents_dir = Path(git_root) / ".cecli" / "agents" + else: + agents_dir = Path(".cecli") / "agents" + + if agents_dir.exists(): + now = time.time() + week_seconds = 7 * 24 * 60 * 60 + + for agent_folder in agents_dir.iterdir(): + if agent_folder.is_dir(): + try: + mtime = agent_folder.stat().st_mtime + if (now - mtime) > week_seconds: + shutil.rmtree(agent_folder, ignore_errors=True) + else: + # Remove empty sub-folders in remaining folders + for sub_folder in agent_folder.iterdir(): + if sub_folder.is_dir(): + try: + sub_folder.rmdir() + except OSError: + pass + except (OSError, PermissionError): + pass + except Exception: + pass return exit_code From ee663bd4c971a28fdca104153850d22318a7bfc1 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 26 Apr 2026 16:02:24 -0400 Subject: [PATCH 9/9] Offload `lCommand` tool long responses to file system to be accessed with `ContextManager` as needed --- cecli/helpers/background_commands.py | 50 ++++++++++++++++++++++++- cecli/tools/command.py | 52 ++++++++++++++++++++++---- cecli/tools/context_manager.py | 56 ++++++++++++++++++++-------- 3 files changed, 133 insertions(+), 25 deletions(-) diff --git a/cecli/helpers/background_commands.py b/cecli/helpers/background_commands.py index b2f731377a9..2789fec0ebc 100644 --- a/cecli/helpers/background_commands.py +++ b/cecli/helpers/background_commands.py @@ -11,7 +11,7 @@ import subprocess import threading from collections import deque -from typing import Dict, Optional, Tuple +from typing import Dict, List, Optional, Tuple try: import pty @@ -624,3 +624,51 @@ def list_background_commands(cls) -> Dict[str, Dict[str, any]]: ), } return result + + @staticmethod + def save_paginated_output( + output: str, + command_key: str, + page_size: int, + abs_root_path_func, + local_agent_folder_func, + ) -> Optional[Tuple[str, List[str], List[str]]]: + """ + Save long output to paginated files in a folder for later access. + + When output exceeds the page_size threshold, it is split into multiple + files named `{page_number}.txt` inside a folder named `{command_key}` + in the agent's local folder. + + Args: + output: Full output text to save + command_key: Command key used for both folder naming and as the filename root + page_size: Maximum characters per page (typically large_file_token_threshold) + abs_root_path_func: Callable to convert relative path to absolute (e.g., coder.abs_root_path) + local_agent_folder_func: Callable to generate relative paths (e.g., coder.local_agent_folder) + + Returns: + Tuple of (folder path, rel file paths list, alias paths list), or None if output fits in one page. + """ + if not output or len(output) <= page_size: + return None + + folder_path = local_agent_folder_func(command_key) + abs_folder = abs_root_path_func(folder_path) + os.makedirs(abs_folder, exist_ok=True) + + num_pages = (len(output) + page_size - 1) // page_size + + for i in range(num_pages): + page_num = i + 1 + filename = f"{page_num}.txt" + abs_path = os.path.join(abs_folder, filename) + page_content = output[i * page_size : (i + 1) * page_size] + with open(abs_path, "w") as f: + f.write(page_content) + + file_paths = [f"{folder_path}/{page_num}.txt" for page_num in range(1, num_pages + 1)] + alias_paths = [ + f"command_key::{command_key}/{page_num}.txt" for page_num in range(1, num_pages + 1) + ] + return (folder_path, file_paths, alias_paths) diff --git a/cecli/tools/command.py b/cecli/tools/command.py index 3b541eb8c8b..8ddc5e2cbb5 100644 --- a/cecli/tools/command.py +++ b/cecli/tools/command.py @@ -107,6 +107,12 @@ async def execute( return "Error: 'command' must be provided." # Check for implicit background (trailing & on Linux) + if ".cecli/agents" in command: + return ( + "Error: Do not attempt to access internal files with " + "standard cli tools. Please use the tools you have been provided." + ) + if not background and command.strip().endswith("&"): background = True command = command.strip()[:-1].strip() @@ -231,11 +237,26 @@ async def _execute_with_timeout(cls, coder, command_string, timeout, use_pty=Fal # Format output output_content = output or "" output_limit = coder.large_file_token_threshold - if coder.context_management_enabled and len(output_content) > output_limit: + if coder.context_management_enabled and len(output_content) > output_limit * 1.25: + # Save full output to paginated files instead of truncating + folder_path, file_list, alias_paths = ( + BackgroundCommandManager.save_paginated_output( + output=output_content, + command_key=command_key, + page_size=output_limit, + abs_root_path_func=coder.abs_root_path, + local_agent_folder_func=coder.local_agent_folder, + ) + ) + # Build a summary with full file list + total_size = len(output_content) + alias_list_str = "\n".join(f" - {a}" for a in alias_paths) output_content = ( - output_content[:output_limit] - + f"\n... (output truncated at {output_limit} characters, based on" - " large_file_token_threshold)" + f"[Large Response ({total_size} characters). " + "Output saved to paginated files.]\n" + f"File Aliases (for use with ContextManager):\n{alias_list_str}\n" + "Use the `ContextManager` tool to view these files and to remove them " + "when done reading. Do not use standard cli tools to view these files." ) # Remove from background tracking since it's done @@ -300,11 +321,26 @@ async def _execute_foreground(cls, coder, command_string): # Format the output for the result message output_content = combined_output or "" output_limit = coder.large_file_token_threshold - if coder.context_management_enabled and len(output_content) > output_limit: + if coder.context_management_enabled and len(output_content) > output_limit * 1.25: + # Generate a unique key for file naming + fg_key = BackgroundCommandManager._generate_command_key(command_string) + # Save full output to paginated files instead of truncating + folder_path, file_list, alias_paths = BackgroundCommandManager.save_paginated_output( + output=output_content, + command_key=fg_key, + page_size=output_limit, + abs_root_path_func=coder.abs_root_path, + local_agent_folder_func=coder.local_agent_folder, + ) + # Build a summary with full file list + total_size = len(output_content) + alias_list_str = "\n".join(f" - {a}" for a in alias_paths) output_content = ( - output_content[:output_limit] - + f"\n... (output truncated at {output_limit} characters, based on" - " large_file_token_threshold)" + f"[Large Response ({total_size} characters). " + "Output saved to paginated files.]\n" + f"File Aliases (for use with ContextManager):\n{alias_list_str}\n" + "Use the `ContextManager` tool to view these files and to remove them " + "when done reading. Do not use standard cli tools to view these files." ) if tui: diff --git a/cecli/tools/context_manager.py b/cecli/tools/context_manager.py index 6dfa12c3a69..6a0ed86808a 100644 --- a/cecli/tools/context_manager.py +++ b/cecli/tools/context_manager.py @@ -1,5 +1,6 @@ import json import os +import re import time from cecli.tools.utils.base_tool import BaseTool @@ -69,10 +70,10 @@ def execute(cls, coder, remove=None, editable=None, view=None, create=None, **kw create: list[str] | None Files to create and make editable. """ - remove_files = parse_arg_as_list(remove) - editable_files = parse_arg_as_list(editable) - view_files = parse_arg_as_list(view) - create_files = parse_arg_as_list(create) + remove_files = sorted(parse_arg_as_list(remove), key=cls._natural_sort_key) + editable_files = sorted(parse_arg_as_list(editable), key=cls._natural_sort_key) + view_files = sorted(parse_arg_as_list(view), key=cls._natural_sort_key) + create_files = sorted(parse_arg_as_list(create), key=cls._natural_sort_key) if not remove_files and not editable_files and not view_files and not create_files: raise ToolError("You must specify at least one of: remove, editable, view, or create") @@ -120,18 +121,18 @@ def format_output(cls, coder, mcp_server, tool_response): # Output each action with comma-separated file list for action_key, display_name in action_names.items(): - files = parse_arg_as_list(params.get(action_key)) + files = sorted(parse_arg_as_list(params.get(action_key)), key=cls._natural_sort_key) if files: file_list = ", ".join(files) coder.io.tool_output(f"{color_start}{display_name}:{color_end} {file_list}") tool_footer(coder=coder, tool_response=tool_response) - @staticmethod - def _remove(coder, file_path): + @classmethod + def _remove(cls, coder, file_path): """Remove a file from the coder's context.""" try: - abs_path = coder.abs_root_path(file_path) + abs_path = cls._resolve_file_path(coder, file_path) rel_path = coder.get_rel_fname(abs_path) removed = False @@ -153,11 +154,11 @@ def _remove(coder, file_path): coder.io.tool_error(f"Error removing file '{file_path}': {str(e)}") return f"Error removing {file_path}: {e}" - @staticmethod - def _editable(coder, file_path): + @classmethod + def _editable(cls, coder, file_path): """Make a file editable in the coder's context.""" try: - abs_path = coder.abs_root_path(file_path) + abs_path = cls._resolve_file_path(coder, file_path) if abs_path in coder.abs_fnames: coder.io.tool_output(f"📝 File '{file_path}' is already editable") return f"Already editable: {file_path}" @@ -179,17 +180,18 @@ def _editable(coder, file_path): coder.io.tool_error(f"Error making editable '{file_path}': {str(e)}") return f"Error making editable {file_path}: {e}" - @staticmethod - def _view(coder, file_path): + @classmethod + def _view(cls, coder, file_path): """View a file (add as read‑only) in the coder's context.""" try: - return coder._add_file_to_context(file_path, explicit=True) + resolved_path = cls._resolve_file_path(coder, file_path) + return coder._add_file_to_context(resolved_path, explicit=True) except Exception as e: coder.io.tool_error(f"Error viewing file '{file_path}': {str(e)}") return f"Error viewing {file_path}: {e}" - @staticmethod - def _create(coder, file_path): + @classmethod + def _create(cls, coder, file_path): """Create a new file on the file system and make it editable in the coder's context.""" try: abs_path = coder.abs_root_path(file_path) @@ -215,3 +217,25 @@ def _create(coder, file_path): except Exception as e: coder.io.tool_error(f"Error creating file '{file_path}': {str(e)}") return f"Error creating {file_path}: {e}" + + @classmethod + def _resolve_file_path(cls, coder, file_path): + """Resolve a file path, handling command_key:: aliases. + + command_key::{command_key}/{filename} resolves to the actual + file path under the agent's local agent folder. + """ + if file_path.startswith("command_key::"): + alias_path = file_path[len("command_key::") :] + parts = alias_path.split("/", 1) + if len(parts) == 2: + command_key = parts[0] + filename = parts[1] + rel_path = coder.local_agent_folder(f"{command_key}/{filename}") + return coder.abs_root_path(rel_path) + return coder.abs_root_path(file_path) + + @classmethod + def _natural_sort_key(cls, s: str) -> list: + """Natural sort key that splits "a10b2" into ["a", 10, "b", 2].""" + return [int(text) if text.isdigit() else text.lower() for text in re.split(r"(\d+)", s)]