aru-code 0.15.0__tar.gz → 0.17.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. {aru_code-0.15.0/aru_code.egg-info → aru_code-0.17.0}/PKG-INFO +6 -7
  2. {aru_code-0.15.0 → aru_code-0.17.0}/README.md +5 -6
  3. aru_code-0.17.0/aru/__init__.py +1 -0
  4. {aru_code-0.15.0 → aru_code-0.17.0}/aru/agent_factory.py +2 -17
  5. {aru_code-0.15.0 → aru_code-0.17.0}/aru/agents/base.py +5 -4
  6. aru_code-0.17.0/aru/cache_patch.py +201 -0
  7. {aru_code-0.15.0 → aru_code-0.17.0}/aru/cli.py +32 -5
  8. {aru_code-0.15.0 → aru_code-0.17.0}/aru/commands.py +1 -0
  9. {aru_code-0.15.0 → aru_code-0.17.0}/aru/completers.py +29 -19
  10. {aru_code-0.15.0 → aru_code-0.17.0}/aru/context.py +68 -49
  11. {aru_code-0.15.0 → aru_code-0.17.0}/aru/runner.py +13 -18
  12. {aru_code-0.15.0 → aru_code-0.17.0}/aru/session.py +131 -6
  13. {aru_code-0.15.0 → aru_code-0.17.0}/aru/tools/codebase.py +9 -9
  14. {aru_code-0.15.0 → aru_code-0.17.0/aru_code.egg-info}/PKG-INFO +6 -7
  15. {aru_code-0.15.0 → aru_code-0.17.0}/aru_code.egg-info/SOURCES.txt +1 -0
  16. {aru_code-0.15.0 → aru_code-0.17.0}/pyproject.toml +1 -1
  17. {aru_code-0.15.0 → aru_code-0.17.0}/tests/test_cli.py +42 -21
  18. {aru_code-0.15.0 → aru_code-0.17.0}/tests/test_cli_completers.py +28 -36
  19. aru_code-0.15.0/aru/__init__.py +0 -1
  20. {aru_code-0.15.0 → aru_code-0.17.0}/LICENSE +0 -0
  21. {aru_code-0.15.0 → aru_code-0.17.0}/aru/agents/__init__.py +0 -0
  22. {aru_code-0.15.0 → aru_code-0.17.0}/aru/agents/executor.py +0 -0
  23. {aru_code-0.15.0 → aru_code-0.17.0}/aru/agents/planner.py +0 -0
  24. {aru_code-0.15.0 → aru_code-0.17.0}/aru/config.py +0 -0
  25. {aru_code-0.15.0 → aru_code-0.17.0}/aru/display.py +0 -0
  26. {aru_code-0.15.0 → aru_code-0.17.0}/aru/permissions.py +0 -0
  27. {aru_code-0.15.0 → aru_code-0.17.0}/aru/providers.py +0 -0
  28. {aru_code-0.15.0 → aru_code-0.17.0}/aru/runtime.py +0 -0
  29. {aru_code-0.15.0 → aru_code-0.17.0}/aru/tools/__init__.py +0 -0
  30. {aru_code-0.15.0 → aru_code-0.17.0}/aru/tools/ast_tools.py +0 -0
  31. {aru_code-0.15.0 → aru_code-0.17.0}/aru/tools/gitignore.py +0 -0
  32. {aru_code-0.15.0 → aru_code-0.17.0}/aru/tools/mcp_client.py +0 -0
  33. {aru_code-0.15.0 → aru_code-0.17.0}/aru/tools/ranker.py +0 -0
  34. {aru_code-0.15.0 → aru_code-0.17.0}/aru/tools/tasklist.py +0 -0
  35. {aru_code-0.15.0 → aru_code-0.17.0}/aru_code.egg-info/dependency_links.txt +0 -0
  36. {aru_code-0.15.0 → aru_code-0.17.0}/aru_code.egg-info/entry_points.txt +0 -0
  37. {aru_code-0.15.0 → aru_code-0.17.0}/aru_code.egg-info/requires.txt +0 -0
  38. {aru_code-0.15.0 → aru_code-0.17.0}/aru_code.egg-info/top_level.txt +0 -0
  39. {aru_code-0.15.0 → aru_code-0.17.0}/setup.cfg +0 -0
  40. {aru_code-0.15.0 → aru_code-0.17.0}/tests/test_agents_base.py +0 -0
  41. {aru_code-0.15.0 → aru_code-0.17.0}/tests/test_cli_advanced.py +0 -0
  42. {aru_code-0.15.0 → aru_code-0.17.0}/tests/test_cli_base.py +0 -0
  43. {aru_code-0.15.0 → aru_code-0.17.0}/tests/test_cli_new.py +0 -0
  44. {aru_code-0.15.0 → aru_code-0.17.0}/tests/test_cli_run_cli.py +0 -0
  45. {aru_code-0.15.0 → aru_code-0.17.0}/tests/test_cli_session.py +0 -0
  46. {aru_code-0.15.0 → aru_code-0.17.0}/tests/test_cli_shell.py +0 -0
  47. {aru_code-0.15.0 → aru_code-0.17.0}/tests/test_codebase.py +0 -0
  48. {aru_code-0.15.0 → aru_code-0.17.0}/tests/test_config.py +0 -0
  49. {aru_code-0.15.0 → aru_code-0.17.0}/tests/test_context.py +0 -0
  50. {aru_code-0.15.0 → aru_code-0.17.0}/tests/test_executor.py +0 -0
  51. {aru_code-0.15.0 → aru_code-0.17.0}/tests/test_gitignore.py +0 -0
  52. {aru_code-0.15.0 → aru_code-0.17.0}/tests/test_main.py +0 -0
  53. {aru_code-0.15.0 → aru_code-0.17.0}/tests/test_mcp_client.py +0 -0
  54. {aru_code-0.15.0 → aru_code-0.17.0}/tests/test_permissions.py +0 -0
  55. {aru_code-0.15.0 → aru_code-0.17.0}/tests/test_planner.py +0 -0
  56. {aru_code-0.15.0 → aru_code-0.17.0}/tests/test_providers.py +0 -0
  57. {aru_code-0.15.0 → aru_code-0.17.0}/tests/test_ranker.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aru-code
3
- Version: 0.15.0
3
+ Version: 0.17.0
4
4
  Summary: A Claude Code clone built with Agno agents
5
5
  Author-email: Estevao <estevaofon@gmail.com>
6
6
  License-Expression: MIT
@@ -56,7 +56,7 @@ An intelligent coding assistant for the terminal, powered by LLMs and [Agno](htt
56
56
  - **Multi-Agent Architecture** — Specialized agents for planning, execution, and conversation
57
57
  - **Interactive CLI** — Streaming responses, multi-line paste, session management
58
58
  - **Image Support** — Attach images via `@` mentions for multimodal analysis (Claude, GPT-4o, Gemini)
59
- - **16 Integrated Tools** — File operations, code search, shell, web search, task delegation
59
+ - **11 Integrated Tools** — File operations, code search, shell, web search, task delegation
60
60
  - **Task Planning** — Break down complex tasks into steps with automatic execution
61
61
  - **Multi-Provider** — Anthropic, OpenAI, Ollama, Groq, OpenRouter, DeepSeek, and others via custom configuration
62
62
  - **Custom Commands, Skills, and Agents** — Extend aru via the `.agents/` directory
@@ -479,15 +479,14 @@ Aru can load tools from MCP servers. Configure in `.aru/mcp_config.json`:
479
479
 
480
480
  ### File Operations
481
481
  - `read_file` — Reads files with line range support and binary detection
482
- - `read_file_smart` — Smart file reading focused on relevant snippets for the query
483
- - `write_file` — Writes files
484
- - `edit_file` — Find-replace edits
482
+ - `read_file_smart` — Answers specific questions about a file without returning raw content
483
+ - `write_file` — Writes content to files, creating directories as needed
484
+ - `edit_file` — Find-and-replace edits on files
485
485
 
486
486
  ### Search & Discovery
487
487
  - `glob_search` — Find files by pattern (respects .gitignore)
488
488
  - `grep_search` — Content search with regex and file filtering
489
489
  - `list_directory` — Directory listing with gitignore filtering
490
- - `rank_files` — Multi-factor file relevance ranking (name, structure, recency)
491
490
 
492
491
  ### Shell & Web
493
492
  - `bash` — Executes shell commands with permission gates
@@ -517,7 +516,7 @@ aru-code/
517
516
  │ │ ├── planner.py # Planning agent
518
517
  │ │ └── executor.py # Execution agent
519
518
  │ └── tools/
520
- │ ├── codebase.py # 16 core tools
519
+ │ ├── codebase.py # 11 core tools
521
520
  │ ├── ast_tools.py # Tree-sitter code analysis
522
521
  │ ├── ranker.py # File relevance ranking
523
522
  │ ├── mcp_client.py # MCP client
@@ -9,7 +9,7 @@ An intelligent coding assistant for the terminal, powered by LLMs and [Agno](htt
9
9
  - **Multi-Agent Architecture** — Specialized agents for planning, execution, and conversation
10
10
  - **Interactive CLI** — Streaming responses, multi-line paste, session management
11
11
  - **Image Support** — Attach images via `@` mentions for multimodal analysis (Claude, GPT-4o, Gemini)
12
- - **16 Integrated Tools** — File operations, code search, shell, web search, task delegation
12
+ - **11 Integrated Tools** — File operations, code search, shell, web search, task delegation
13
13
  - **Task Planning** — Break down complex tasks into steps with automatic execution
14
14
  - **Multi-Provider** — Anthropic, OpenAI, Ollama, Groq, OpenRouter, DeepSeek, and others via custom configuration
15
15
  - **Custom Commands, Skills, and Agents** — Extend aru via the `.agents/` directory
@@ -432,15 +432,14 @@ Aru can load tools from MCP servers. Configure in `.aru/mcp_config.json`:
432
432
 
433
433
  ### File Operations
434
434
  - `read_file` — Reads files with line range support and binary detection
435
- - `read_file_smart` — Smart file reading focused on relevant snippets for the query
436
- - `write_file` — Writes files
437
- - `edit_file` — Find-replace edits
435
+ - `read_file_smart` — Answers specific questions about a file without returning raw content
436
+ - `write_file` — Writes content to files, creating directories as needed
437
+ - `edit_file` — Find-and-replace edits on files
438
438
 
439
439
  ### Search & Discovery
440
440
  - `glob_search` — Find files by pattern (respects .gitignore)
441
441
  - `grep_search` — Content search with regex and file filtering
442
442
  - `list_directory` — Directory listing with gitignore filtering
443
- - `rank_files` — Multi-factor file relevance ranking (name, structure, recency)
444
443
 
445
444
  ### Shell & Web
446
445
  - `bash` — Executes shell commands with permission gates
@@ -470,7 +469,7 @@ aru-code/
470
469
  │ │ ├── planner.py # Planning agent
471
470
  │ │ └── executor.py # Execution agent
472
471
  │ └── tools/
473
- │ ├── codebase.py # 16 core tools
472
+ │ ├── codebase.py # 11 core tools
474
473
  │ ├── ast_tools.py # Tree-sitter code analysis
475
474
  │ ├── ranker.py # File relevance ranking
476
475
  │ ├── mcp_client.py # MCP client
@@ -0,0 +1 @@
1
+ __version__ = "0.17.0"
@@ -21,10 +21,9 @@ def create_general_agent(
21
21
  in the system prompt. Placed in instructions so it's cacheable.
22
22
  """
23
23
  from agno.agent import Agent
24
- from agno.compression.manager import CompressionManager
25
24
 
26
25
  from aru.tools.codebase import GENERAL_TOOLS
27
- from aru.runtime import get_ctx
26
+ tools = GENERAL_TOOLS
28
27
 
29
28
  extra = config.get_extra_instructions() if config else ""
30
29
  if env_context:
@@ -34,15 +33,9 @@ def create_general_agent(
34
33
  return Agent(
35
34
  name="Aru",
36
35
  model=create_model(model_ref, max_tokens=8192),
37
- tools=GENERAL_TOOLS,
36
+ tools=tools,
38
37
  instructions=_build_instructions("general", extra),
39
38
  markdown=True,
40
- compress_tool_results=True,
41
- compression_manager=CompressionManager(
42
- model=create_model(get_ctx().small_model_ref, max_tokens=1024),
43
- compress_tool_results=True,
44
- compress_tool_results_limit=25,
45
- ),
46
39
  tool_call_limit=20,
47
40
  )
48
41
 
@@ -52,10 +45,8 @@ def create_custom_agent_instance(agent_def: CustomAgent, session: Session,
52
45
  env_context: str = ""):
53
46
  """Create an Agno Agent from a CustomAgent definition."""
54
47
  from agno.agent import Agent
55
- from agno.compression.manager import CompressionManager
56
48
  from aru.agents.base import BASE_INSTRUCTIONS
57
49
  from aru.tools.codebase import resolve_tools
58
- from aru.runtime import get_ctx
59
50
 
60
51
  model_ref = agent_def.model or session.model_ref
61
52
  tools = resolve_tools(agent_def.tools)
@@ -74,11 +65,5 @@ def create_custom_agent_instance(agent_def: CustomAgent, session: Session,
74
65
  tools=tools,
75
66
  instructions=instructions,
76
67
  markdown=True,
77
- compress_tool_results=True,
78
- compression_manager=CompressionManager(
79
- model=create_model(get_ctx().small_model_ref, max_tokens=1024),
80
- compress_tool_results=True,
81
- compress_tool_results_limit=25,
82
- ),
83
68
  tool_call_limit=agent_def.max_turns or 20,
84
69
  )
@@ -48,12 +48,12 @@ Default shows 10 lines of context — use `context_lines=30` for full function b
48
48
  2. **Understand a file** → `read_file_smart(path, query)` — returns a concise answer, not raw content
49
49
  3. **Need raw content** → `read_file(path)` — returns first chunk + outline for large files
50
50
 
51
- **Stop early**: Once you have enough information to write the plan, STOP making tool calls \
52
- immediately. Do not exhaustively explore.
53
-
54
51
  **Batch independent tool calls**: When you need answers from multiple independent sources, \
55
52
  emit ALL those tool calls in a single response.
56
53
 
54
+ **Stop early**: Once you have enough information to write the plan, stop exploring and write it. \
55
+ Do not exhaustively read every file — batch what you need, then produce the plan.
56
+
57
57
  ## Output format — STRICT
58
58
 
59
59
  Your ONLY output is the plan below. Do NOT write analysis, coverage reports, summaries of
@@ -182,7 +182,8 @@ Every tool call accumulates its result in your context window. Use the minimum n
182
182
 
183
183
  **Batch independent tool calls**: emit ALL independent tool calls in a single response.
184
184
 
185
- **Stop early**: Once you have enough information to do the work, STOP exploring and start working.
185
+ **Stop early**: Once you have enough information to act, stop exploring and start working. \
186
+ Batch what you need upfront, then execute.
186
187
 
187
188
  **When adding or modifying unit tests, ALWAYS run them to verify they pass before finishing.**
188
189
 
@@ -0,0 +1,201 @@
1
+ """Monkey-patch Agno's model layer to reduce token consumption.
2
+
3
+ Three optimizations:
4
+
5
+ 1. **Tool result pruning** (ALL providers): After each tool execution, old tool
6
+ results in the message list are truncated to a short summary. This prevents
7
+ O(n²) token growth where each API call re-sends all previous tool results.
8
+
9
+ 2. **Cache breakpoints** (Anthropic only): Marks the last 2 messages with
10
+ cache_control for Anthropic's prompt caching.
11
+
12
+ 3. **Per-call metrics** (ALL providers): Captures input/output tokens of the
13
+ last API call (context window size), exposed via get_last_call_metrics().
14
+
15
+ These patches intercept Agno's internal loop so they work transparently
16
+ regardless of which provider is used.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ # Token-budget pruning (aligned with OpenCode's strategy):
22
+ # - Protect recent tool results within a token budget
23
+ # - Only prune if there's enough to free (avoid churn)
24
+ # - Walk backwards, protecting recent content first
25
+ # OpenCode uses 40K protect / 20K minimum; we use chars (~4 chars/token)
26
+ _PRUNE_PROTECT_CHARS = 160_000 # ~40K tokens — recent content always kept
27
+ _PRUNE_MINIMUM_CHARS = 80_000 # ~20K tokens — only prune if this much is freeable
28
+ _PRUNED_PLACEHOLDER = "[Old tool result cleared]"
29
+
30
+ # Last API call metrics (updated on every internal API call)
31
+ _last_call_input_tokens: int = 0
32
+ _last_call_output_tokens: int = 0
33
+ _last_call_cache_read: int = 0
34
+ _last_call_cache_write: int = 0
35
+
36
+
37
+ def get_last_call_metrics() -> tuple[int, int, int, int]:
38
+ """Return (input, output, cache_read, cache_write) from the most recent API call."""
39
+ return _last_call_input_tokens, _last_call_output_tokens, _last_call_cache_read, _last_call_cache_write
40
+
41
+
42
+ def _prune_tool_messages(messages):
43
+ """Clear old tool result content using a token-budget approach.
44
+
45
+ Walks backwards through messages, protecting recent content up to
46
+ PRUNE_PROTECT_CHARS. Older tool results beyond that budget are replaced
47
+ with a short placeholder. Only prunes if total freeable chars exceed
48
+ PRUNE_MINIMUM_CHARS (avoids unnecessary churn on small conversations).
49
+
50
+ Aligned with OpenCode's strategy: budget-based, not fixed-N.
51
+ """
52
+ # Collect tool message indices and their content sizes
53
+ tool_indices = []
54
+ for i, msg in enumerate(messages):
55
+ if getattr(msg, "role", None) == "tool":
56
+ content = getattr(msg, "content", None)
57
+ content_len = len(str(content)) if content is not None else 0
58
+ tool_indices.append((i, content_len))
59
+
60
+ if not tool_indices:
61
+ return
62
+
63
+ # Walk backwards, accumulating protected chars
64
+ protected_chars = 0
65
+ prune_candidates = [] # (index, content_len) of messages outside protection
66
+
67
+ for idx, content_len in reversed(tool_indices):
68
+ if protected_chars + content_len <= _PRUNE_PROTECT_CHARS:
69
+ protected_chars += content_len
70
+ else:
71
+ prune_candidates.append((idx, content_len))
72
+
73
+ # Only prune if there's enough to free
74
+ freeable = sum(cl for _, cl in prune_candidates)
75
+ if freeable < _PRUNE_MINIMUM_CHARS:
76
+ return
77
+
78
+ # Replace old tool results with placeholder
79
+ for idx, _ in prune_candidates:
80
+ msg = messages[idx]
81
+ content = getattr(msg, "content", None)
82
+ if content is None:
83
+ continue
84
+ # Skip if already pruned
85
+ if str(content) == _PRUNED_PLACEHOLDER:
86
+ continue
87
+ try:
88
+ msg.content = _PRUNED_PLACEHOLDER
89
+ if hasattr(msg, "compressed_content"):
90
+ msg.compressed_content = None
91
+ except (AttributeError, TypeError):
92
+ pass
93
+
94
+
95
+ def apply_cache_patch():
96
+ """Apply all patches to reduce Agno's token consumption."""
97
+ _patch_tool_result_pruning()
98
+ _patch_claude_cache_breakpoints()
99
+ _patch_per_call_metrics()
100
+
101
+
102
+ def _patch_tool_result_pruning():
103
+ """Patch format_function_call_results to prune old tool results.
104
+
105
+ This is called after each tool execution, right before the next API call.
106
+ Works for ALL providers (Claude, OpenAI, Qwen, etc.) since it patches
107
+ the base Model class.
108
+ """
109
+ from agno.models.base import Model
110
+
111
+ _original_format_results = Model.format_function_call_results
112
+
113
+ def _patched_format_results(self, messages, function_call_results, **kwargs):
114
+ # First: prune old tool results already in messages
115
+ _prune_tool_messages(messages)
116
+ # Then: add new results normally
117
+ return _original_format_results(self, messages, function_call_results, **kwargs)
118
+
119
+ Model.format_function_call_results = _patched_format_results
120
+
121
+
122
+ def _patch_claude_cache_breakpoints():
123
+ """Patch Claude's format_messages to add cache breakpoints.
124
+
125
+ Marks the last 2 messages with cache_control for Anthropic's prompt
126
+ caching. Non-Anthropic providers ignore these fields.
127
+ """
128
+ try:
129
+ import agno.utils.models.claude as claude_utils
130
+ except ImportError:
131
+ return
132
+
133
+ _original_format = claude_utils.format_messages
134
+
135
+ def _patched_format_messages(messages, compress_tool_results=False):
136
+ chat_messages, system_message = _original_format(
137
+ messages, compress_tool_results=compress_tool_results
138
+ )
139
+
140
+ if not chat_messages:
141
+ return chat_messages, system_message
142
+
143
+ # Add cache_control to last 2 messages
144
+ cache_marker = {"type": "ephemeral"}
145
+ marked = 0
146
+ for msg in reversed(chat_messages):
147
+ if marked >= 2:
148
+ break
149
+ content = msg.get("content")
150
+ if isinstance(content, list) and content:
151
+ last_item = content[-1]
152
+ if isinstance(last_item, dict):
153
+ last_item["cache_control"] = cache_marker
154
+ marked += 1
155
+ elif hasattr(last_item, "type"):
156
+ try:
157
+ as_dict = last_item.model_dump() if hasattr(last_item, "model_dump") else dict(last_item)
158
+ as_dict["cache_control"] = cache_marker
159
+ content[-1] = as_dict
160
+ marked += 1
161
+ except Exception:
162
+ pass
163
+ elif isinstance(content, str):
164
+ msg["content"] = [{"type": "text", "text": content, "cache_control": cache_marker}]
165
+ marked += 1
166
+
167
+ return chat_messages, system_message
168
+
169
+ claude_utils.format_messages = _patched_format_messages
170
+
171
+
172
+ def _patch_per_call_metrics():
173
+ """Patch accumulate_model_metrics to capture per-API-call token counts.
174
+
175
+ After each internal API call, Agno calls this function to sum tokens
176
+ into RunMetrics. We intercept it to snapshot the last call's tokens,
177
+ giving us the actual context window size (comparable to OpenCode/Claude Code).
178
+ """
179
+ from agno.metrics import accumulate_model_metrics as _original_accumulate
180
+
181
+ import agno.metrics as _metrics_module
182
+
183
+ def _patched_accumulate(model_response, model, model_type, run_metrics=None):
184
+ global _last_call_input_tokens, _last_call_output_tokens
185
+ global _last_call_cache_read, _last_call_cache_write
186
+ usage = getattr(model_response, "response_usage", None)
187
+ if usage is not None:
188
+ _last_call_input_tokens = getattr(usage, "input_tokens", 0) or 0
189
+ _last_call_output_tokens = getattr(usage, "output_tokens", 0) or 0
190
+ _last_call_cache_read = getattr(usage, "cache_read_tokens", 0) or 0
191
+ _last_call_cache_write = getattr(usage, "cache_write_tokens", 0) or 0
192
+ return _original_accumulate(model_response, model, model_type, run_metrics)
193
+
194
+ _metrics_module.accumulate_model_metrics = _patched_accumulate
195
+
196
+ # Also patch the reference in base.py since it may have imported directly
197
+ try:
198
+ import agno.models.base as _base_module
199
+ _base_module.accumulate_model_metrics = _patched_accumulate
200
+ except (ImportError, AttributeError):
201
+ pass
@@ -14,6 +14,7 @@ import os
14
14
  import sys
15
15
 
16
16
  from rich.markdown import Markdown
17
+ from rich.panel import Panel
17
18
 
18
19
  # ── Re-exports for backward compatibility ─────────────────────────────
19
20
  # Tests and external code import these from aru.cli; keep them accessible.
@@ -50,6 +51,7 @@ from aru.display import ( # noqa: F401
50
51
  from aru.completers import ( # noqa: F401
51
52
  AruCompleter,
52
53
  FileMentionCompleter,
54
+ MentionResult,
53
55
  PasteState,
54
56
  SlashCommandCompleter,
55
57
  TIPS,
@@ -110,6 +112,11 @@ async def run_cli(skip_permissions: bool = False, resume_id: str | None = None):
110
112
  from aru.permissions import parse_permission_config, reset_session as perm_reset_session
111
113
  from aru.tools.codebase import cleanup_processes
112
114
 
115
+ # Inject cache breakpoints into Agno's Claude API calls — reduces token
116
+ # consumption by ~40% on multi-tool-call interactions via prompt caching.
117
+ from aru.cache_patch import apply_cache_patch
118
+ apply_cache_patch()
119
+
113
120
  ctx = init_ctx(console=console, skip_permissions=skip_permissions)
114
121
 
115
122
  store = SessionStore()
@@ -253,16 +260,19 @@ async def run_cli(skip_permissions: bool = False, resume_id: str | None = None):
253
260
 
254
261
  # Resolve @file mentions (skip known agent names)
255
262
  _agent_names = set(config.custom_agents.keys()) if config.custom_agents else set()
256
- resolved, injected, attached_images = _resolve_mentions(user_input, os.getcwd(), _agent_names)
257
- if injected > 0:
263
+ mention_result = _resolve_mentions(user_input, os.getcwd(), _agent_names)
264
+ attached_images = mention_result.images
265
+ # File contents go into history as separate prunable messages (not inline)
266
+ mention_file_msgs = mention_result.file_messages
267
+ if mention_result.count > 0:
258
268
  parts = []
259
- text_count = injected - len(attached_images)
269
+ text_count = mention_result.count - len(attached_images)
260
270
  if text_count > 0:
261
271
  parts.append(f"{text_count} file(s)")
262
272
  if attached_images:
263
273
  parts.append(f"{len(attached_images)} image(s)")
264
274
  console.print(f"[dim]Attached {', '.join(parts)} from @ mentions[/dim]")
265
- user_input = resolved
275
+ user_input = mention_result.text
266
276
 
267
277
  if paste_state.pasted_content and user_text:
268
278
  console.print(
@@ -276,6 +286,14 @@ async def run_cli(skip_permissions: bool = False, resume_id: str | None = None):
276
286
  if not user_input:
277
287
  continue
278
288
 
289
+ # Inject @file contents as prunable history entries BEFORE the user message.
290
+ # These look like simulated read_file tool calls and can be pruned/compacted
291
+ # normally, unlike inline content which bloats the user message permanently.
292
+ if mention_file_msgs:
293
+ for msg in mention_file_msgs:
294
+ session.add_message(msg["role"], msg["content"])
295
+ mention_file_msgs = [] # consumed
296
+
279
297
  # Reset "allow all" approvals for each new user message
280
298
  perm_reset_session()
281
299
 
@@ -396,6 +414,15 @@ async def run_cli(skip_permissions: bool = False, resume_id: str | None = None):
396
414
  _show_help(config)
397
415
  continue
398
416
 
417
+ if user_input.lower() == "/cost":
418
+ console.print(Panel(
419
+ session.cost_summary,
420
+ title="[bold]Token Usage & Cost[/bold]",
421
+ border_style="cyan",
422
+ padding=(1, 2),
423
+ ))
424
+ continue
425
+
399
426
  if user_input.startswith("! "):
400
427
  cmd = user_input[2:].strip()
401
428
  if not cmd:
@@ -501,7 +528,7 @@ async def run_cli(skip_permissions: bool = False, resume_id: str | None = None):
501
528
  session.add_message("assistant", run_result.with_tools_summary())
502
529
  else:
503
530
  console.print(f"[yellow]Unknown command: /{cmd_name}[/yellow]")
504
- console.print(f"[dim]Built-in: /plan, /model, /sessions, /commands, /skills, /agents, /quit[/dim]")
531
+ console.print(f"[dim]Built-in: /plan, /model, /sessions, /commands, /skills, /agents, /cost, /quit[/dim]")
505
532
  if config.commands:
506
533
  console.print(f"[dim]Custom: {', '.join(f'/{k}' for k in config.commands)}[/dim]")
507
534
  if config.skills:
@@ -21,6 +21,7 @@ SLASH_COMMANDS = [
21
21
  ("/skills", "List available skills", "/skills"),
22
22
  ("/agents", "List custom agents", "/agents"),
23
23
  ("/mcp", "List loaded MCP tools", "/mcp"),
24
+ ("/cost", "Show detailed token usage and cost", "/cost"),
24
25
  ("/quit", "Exit aru", "/quit"),
25
26
  ]
26
27
 
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import os
6
6
  import re
7
+ from dataclasses import dataclass
7
8
 
8
9
  from prompt_toolkit import PromptSession
9
10
  from prompt_toolkit.completion import Completer, Completion
@@ -18,24 +19,36 @@ from aru.commands import SLASH_COMMANDS
18
19
  from aru.config import AgentConfig
19
20
 
20
21
  _MENTION_RE = re.compile(r'(?<!\S)@([a-zA-Z0-9_./\\:-]+)')
21
- _MENTION_MAX_SIZE = 30_000 # bytes, same limit as read_file
22
+ _MENTION_MAX_SIZE = 10_000 # bytes smaller to protect context (model uses read_file for large files)
22
23
  _IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"}
23
24
  _IMAGE_MAX_SIZE = 20 * 1024 * 1024 # 20MB
24
25
 
25
26
 
26
- def _resolve_mentions(text: str, cwd: str, agent_names: set[str] | None = None) -> tuple[str, int, list[Image]]:
27
- """Resolve @file mentions by appending file contents to the message.
27
+ @dataclass
28
+ class MentionResult:
29
+ """Result of resolving @file mentions."""
30
+ text: str # User text (without file contents)
31
+ file_messages: list[dict[str, str]] # Simulated tool-call pairs for history
32
+ images: list[Image]
33
+ count: int # Total attached (files + images)
28
34
 
29
- Image files (png, jpg, etc.) are returned as Image objects instead of text.
35
+
36
+ def _resolve_mentions(text: str, cwd: str, agent_names: set[str] | None = None) -> MentionResult:
37
+ """Resolve @file mentions as simulated read_file tool calls.
38
+
39
+ Instead of inlining file contents into the user message (which bloats
40
+ history and can't be pruned), we return separate assistant+tool_result
41
+ message pairs that the session can prune/compact like normal tool outputs.
42
+
43
+ Image files are returned as Image objects.
30
44
  Skips @mentions that match known agent names.
31
- Returns (resolved_text, number_of_files_attached, images).
32
45
  """
33
46
  agent_names = agent_names or set()
34
47
  matches = list(_MENTION_RE.finditer(text))
35
48
  if not matches:
36
- return text, 0, []
49
+ return MentionResult(text=text, file_messages=[], images=[], count=0)
37
50
 
38
- appendix_parts = []
51
+ file_messages: list[dict[str, str]] = []
39
52
  images: list[Image] = []
40
53
  seen = set()
41
54
  for m in matches:
@@ -64,21 +77,18 @@ def _resolve_mentions(text: str, cwd: str, agent_names: set[str] | None = None)
64
77
  size = os.path.getsize(abs_path)
65
78
  with open(abs_path, "r", encoding="utf-8", errors="replace") as f:
66
79
  content = f.read(_MENTION_MAX_SIZE)
67
- if size > _MENTION_MAX_SIZE:
68
- appendix_parts.append(
69
- f"\n\n---\nContents of {rel_path} (truncated to {_MENTION_MAX_SIZE // 1000}KB):\n```\n{content}\n```"
70
- )
71
- else:
72
- appendix_parts.append(
73
- f"\n\n---\nContents of {rel_path}:\n```\n{content}\n```"
74
- )
80
+ truncated = size > _MENTION_MAX_SIZE
81
+ label = f"[read_file: {rel_path}]"
82
+ if truncated:
83
+ label += f" (truncated to {_MENTION_MAX_SIZE // 1000}KB of {size // 1000}KB — use read_file for the rest)"
84
+ # Simulated tool call pair — can be pruned like normal tool outputs
85
+ file_messages.append({"role": "assistant", "content": label})
86
+ file_messages.append({"role": "user", "content": content})
75
87
  except OSError:
76
88
  continue
77
89
 
78
- attached = len(appendix_parts) + len(images)
79
- if appendix_parts:
80
- return text + "".join(appendix_parts), attached, images
81
- return text, attached, images
90
+ count = len(file_messages) // 2 + len(images)
91
+ return MentionResult(text=text, file_messages=file_messages, images=images, count=count)
82
92
 
83
93
 
84
94
  def _extract_agent_mention(