aru-code 0.15.0__tar.gz → 0.16.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.16.0}/PKG-INFO +6 -7
  2. {aru_code-0.15.0 → aru_code-0.16.0}/README.md +5 -6
  3. aru_code-0.16.0/aru/__init__.py +1 -0
  4. {aru_code-0.15.0 → aru_code-0.16.0}/aru/agent_factory.py +8 -18
  5. aru_code-0.16.0/aru/cache_patch.py +133 -0
  6. {aru_code-0.15.0 → aru_code-0.16.0}/aru/cli.py +21 -4
  7. {aru_code-0.15.0 → aru_code-0.16.0}/aru/completers.py +29 -19
  8. {aru_code-0.15.0 → aru_code-0.16.0}/aru/context.py +73 -54
  9. {aru_code-0.15.0 → aru_code-0.16.0}/aru/session.py +2 -0
  10. {aru_code-0.15.0 → aru_code-0.16.0}/aru/tools/codebase.py +9 -9
  11. {aru_code-0.15.0 → aru_code-0.16.0/aru_code.egg-info}/PKG-INFO +6 -7
  12. {aru_code-0.15.0 → aru_code-0.16.0}/aru_code.egg-info/SOURCES.txt +1 -0
  13. {aru_code-0.15.0 → aru_code-0.16.0}/pyproject.toml +1 -1
  14. {aru_code-0.15.0 → aru_code-0.16.0}/tests/test_cli.py +22 -17
  15. {aru_code-0.15.0 → aru_code-0.16.0}/tests/test_cli_completers.py +28 -36
  16. aru_code-0.15.0/aru/__init__.py +0 -1
  17. {aru_code-0.15.0 → aru_code-0.16.0}/LICENSE +0 -0
  18. {aru_code-0.15.0 → aru_code-0.16.0}/aru/agents/__init__.py +0 -0
  19. {aru_code-0.15.0 → aru_code-0.16.0}/aru/agents/base.py +0 -0
  20. {aru_code-0.15.0 → aru_code-0.16.0}/aru/agents/executor.py +0 -0
  21. {aru_code-0.15.0 → aru_code-0.16.0}/aru/agents/planner.py +0 -0
  22. {aru_code-0.15.0 → aru_code-0.16.0}/aru/commands.py +0 -0
  23. {aru_code-0.15.0 → aru_code-0.16.0}/aru/config.py +0 -0
  24. {aru_code-0.15.0 → aru_code-0.16.0}/aru/display.py +0 -0
  25. {aru_code-0.15.0 → aru_code-0.16.0}/aru/permissions.py +0 -0
  26. {aru_code-0.15.0 → aru_code-0.16.0}/aru/providers.py +0 -0
  27. {aru_code-0.15.0 → aru_code-0.16.0}/aru/runner.py +0 -0
  28. {aru_code-0.15.0 → aru_code-0.16.0}/aru/runtime.py +0 -0
  29. {aru_code-0.15.0 → aru_code-0.16.0}/aru/tools/__init__.py +0 -0
  30. {aru_code-0.15.0 → aru_code-0.16.0}/aru/tools/ast_tools.py +0 -0
  31. {aru_code-0.15.0 → aru_code-0.16.0}/aru/tools/gitignore.py +0 -0
  32. {aru_code-0.15.0 → aru_code-0.16.0}/aru/tools/mcp_client.py +0 -0
  33. {aru_code-0.15.0 → aru_code-0.16.0}/aru/tools/ranker.py +0 -0
  34. {aru_code-0.15.0 → aru_code-0.16.0}/aru/tools/tasklist.py +0 -0
  35. {aru_code-0.15.0 → aru_code-0.16.0}/aru_code.egg-info/dependency_links.txt +0 -0
  36. {aru_code-0.15.0 → aru_code-0.16.0}/aru_code.egg-info/entry_points.txt +0 -0
  37. {aru_code-0.15.0 → aru_code-0.16.0}/aru_code.egg-info/requires.txt +0 -0
  38. {aru_code-0.15.0 → aru_code-0.16.0}/aru_code.egg-info/top_level.txt +0 -0
  39. {aru_code-0.15.0 → aru_code-0.16.0}/setup.cfg +0 -0
  40. {aru_code-0.15.0 → aru_code-0.16.0}/tests/test_agents_base.py +0 -0
  41. {aru_code-0.15.0 → aru_code-0.16.0}/tests/test_cli_advanced.py +0 -0
  42. {aru_code-0.15.0 → aru_code-0.16.0}/tests/test_cli_base.py +0 -0
  43. {aru_code-0.15.0 → aru_code-0.16.0}/tests/test_cli_new.py +0 -0
  44. {aru_code-0.15.0 → aru_code-0.16.0}/tests/test_cli_run_cli.py +0 -0
  45. {aru_code-0.15.0 → aru_code-0.16.0}/tests/test_cli_session.py +0 -0
  46. {aru_code-0.15.0 → aru_code-0.16.0}/tests/test_cli_shell.py +0 -0
  47. {aru_code-0.15.0 → aru_code-0.16.0}/tests/test_codebase.py +0 -0
  48. {aru_code-0.15.0 → aru_code-0.16.0}/tests/test_config.py +0 -0
  49. {aru_code-0.15.0 → aru_code-0.16.0}/tests/test_context.py +0 -0
  50. {aru_code-0.15.0 → aru_code-0.16.0}/tests/test_executor.py +0 -0
  51. {aru_code-0.15.0 → aru_code-0.16.0}/tests/test_gitignore.py +0 -0
  52. {aru_code-0.15.0 → aru_code-0.16.0}/tests/test_main.py +0 -0
  53. {aru_code-0.15.0 → aru_code-0.16.0}/tests/test_mcp_client.py +0 -0
  54. {aru_code-0.15.0 → aru_code-0.16.0}/tests/test_permissions.py +0 -0
  55. {aru_code-0.15.0 → aru_code-0.16.0}/tests/test_planner.py +0 -0
  56. {aru_code-0.15.0 → aru_code-0.16.0}/tests/test_providers.py +0 -0
  57. {aru_code-0.15.0 → aru_code-0.16.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.16.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.16.0"
@@ -21,12 +21,16 @@ 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
- extra = config.get_extra_instructions() if config else ""
28
+ # Only include AGENTS.md/project instructions on first turn to save ~1.6K tokens/turn
29
+ if config and not session.extra_instructions_sent:
30
+ extra = config.get_extra_instructions()
31
+ session.extra_instructions_sent = True
32
+ else:
33
+ extra = ""
30
34
  if env_context:
31
35
  extra = f"{extra}\n\n{env_context}" if extra else env_context
32
36
  model_ref = model_override or session.model_ref
@@ -34,15 +38,9 @@ def create_general_agent(
34
38
  return Agent(
35
39
  name="Aru",
36
40
  model=create_model(model_ref, max_tokens=8192),
37
- tools=GENERAL_TOOLS,
41
+ tools=tools,
38
42
  instructions=_build_instructions("general", extra),
39
43
  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
44
  tool_call_limit=20,
47
45
  )
48
46
 
@@ -52,10 +50,8 @@ def create_custom_agent_instance(agent_def: CustomAgent, session: Session,
52
50
  env_context: str = ""):
53
51
  """Create an Agno Agent from a CustomAgent definition."""
54
52
  from agno.agent import Agent
55
- from agno.compression.manager import CompressionManager
56
53
  from aru.agents.base import BASE_INSTRUCTIONS
57
54
  from aru.tools.codebase import resolve_tools
58
- from aru.runtime import get_ctx
59
55
 
60
56
  model_ref = agent_def.model or session.model_ref
61
57
  tools = resolve_tools(agent_def.tools)
@@ -74,11 +70,5 @@ def create_custom_agent_instance(agent_def: CustomAgent, session: Session,
74
70
  tools=tools,
75
71
  instructions=instructions,
76
72
  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
73
  tool_call_limit=agent_def.max_turns or 20,
84
74
  )
@@ -0,0 +1,133 @@
1
+ """Monkey-patch Agno's model layer to reduce token consumption.
2
+
3
+ Two 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
+ These patches intercept Agno's internal loop so they work transparently
13
+ regardless of which provider is used.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ # Max chars to keep from old tool results
19
+ _TOOL_RESULT_KEEP_CHARS = 200
20
+ # Number of recent tool results to keep in full
21
+ _KEEP_RECENT_RESULTS = 1
22
+
23
+
24
+ def _prune_tool_messages(messages):
25
+ """Truncate old tool result content in the message list.
26
+
27
+ Keeps only the last N tool results in full. Older ones are truncated
28
+ to a short preview. This runs BEFORE each API call, so accumulated
29
+ tool results don't bloat the context on every re-send.
30
+ """
31
+ # Find all tool message indices
32
+ tool_indices = [
33
+ i for i, msg in enumerate(messages)
34
+ if getattr(msg, "role", None) == "tool"
35
+ ]
36
+
37
+ if len(tool_indices) <= _KEEP_RECENT_RESULTS:
38
+ return
39
+
40
+ # Prune all except the last N
41
+ for idx in tool_indices[:-_KEEP_RECENT_RESULTS]:
42
+ msg = messages[idx]
43
+ content = getattr(msg, "content", None)
44
+ if content is None:
45
+ continue
46
+
47
+ content_str = str(content)
48
+ if len(content_str) <= _TOOL_RESULT_KEEP_CHARS:
49
+ continue
50
+
51
+ truncated = content_str[:_TOOL_RESULT_KEEP_CHARS] + "\n[...truncated]"
52
+ try:
53
+ msg.content = truncated
54
+ if hasattr(msg, "compressed_content"):
55
+ msg.compressed_content = None
56
+ except (AttributeError, TypeError):
57
+ pass
58
+
59
+
60
+ def apply_cache_patch():
61
+ """Apply all patches to reduce Agno's token consumption."""
62
+ _patch_tool_result_pruning()
63
+ _patch_claude_cache_breakpoints()
64
+
65
+
66
+ def _patch_tool_result_pruning():
67
+ """Patch format_function_call_results to prune old tool results.
68
+
69
+ This is called after each tool execution, right before the next API call.
70
+ Works for ALL providers (Claude, OpenAI, Qwen, etc.) since it patches
71
+ the base Model class.
72
+ """
73
+ from agno.models.base import Model
74
+
75
+ _original_format_results = Model.format_function_call_results
76
+
77
+ def _patched_format_results(self, messages, function_call_results, **kwargs):
78
+ # First: prune old tool results already in messages
79
+ _prune_tool_messages(messages)
80
+ # Then: add new results normally
81
+ return _original_format_results(self, messages, function_call_results, **kwargs)
82
+
83
+ Model.format_function_call_results = _patched_format_results
84
+
85
+
86
+ def _patch_claude_cache_breakpoints():
87
+ """Patch Claude's format_messages to add cache breakpoints.
88
+
89
+ Marks the last 2 messages with cache_control for Anthropic's prompt
90
+ caching. Non-Anthropic providers ignore these fields.
91
+ """
92
+ try:
93
+ import agno.utils.models.claude as claude_utils
94
+ except ImportError:
95
+ return
96
+
97
+ _original_format = claude_utils.format_messages
98
+
99
+ def _patched_format_messages(messages, compress_tool_results=False):
100
+ chat_messages, system_message = _original_format(
101
+ messages, compress_tool_results=compress_tool_results
102
+ )
103
+
104
+ if not chat_messages:
105
+ return chat_messages, system_message
106
+
107
+ # Add cache_control to last 2 messages
108
+ cache_marker = {"type": "ephemeral"}
109
+ marked = 0
110
+ for msg in reversed(chat_messages):
111
+ if marked >= 2:
112
+ break
113
+ content = msg.get("content")
114
+ if isinstance(content, list) and content:
115
+ last_item = content[-1]
116
+ if isinstance(last_item, dict):
117
+ last_item["cache_control"] = cache_marker
118
+ marked += 1
119
+ elif hasattr(last_item, "type"):
120
+ try:
121
+ as_dict = last_item.model_dump() if hasattr(last_item, "model_dump") else dict(last_item)
122
+ as_dict["cache_control"] = cache_marker
123
+ content[-1] = as_dict
124
+ marked += 1
125
+ except Exception:
126
+ pass
127
+ elif isinstance(content, str):
128
+ msg["content"] = [{"type": "text", "text": content, "cache_control": cache_marker}]
129
+ marked += 1
130
+
131
+ return chat_messages, system_message
132
+
133
+ claude_utils.format_messages = _patched_format_messages
@@ -50,6 +50,7 @@ from aru.display import ( # noqa: F401
50
50
  from aru.completers import ( # noqa: F401
51
51
  AruCompleter,
52
52
  FileMentionCompleter,
53
+ MentionResult,
53
54
  PasteState,
54
55
  SlashCommandCompleter,
55
56
  TIPS,
@@ -110,6 +111,11 @@ async def run_cli(skip_permissions: bool = False, resume_id: str | None = None):
110
111
  from aru.permissions import parse_permission_config, reset_session as perm_reset_session
111
112
  from aru.tools.codebase import cleanup_processes
112
113
 
114
+ # Inject cache breakpoints into Agno's Claude API calls — reduces token
115
+ # consumption by ~40% on multi-tool-call interactions via prompt caching.
116
+ from aru.cache_patch import apply_cache_patch
117
+ apply_cache_patch()
118
+
113
119
  ctx = init_ctx(console=console, skip_permissions=skip_permissions)
114
120
 
115
121
  store = SessionStore()
@@ -253,16 +259,19 @@ async def run_cli(skip_permissions: bool = False, resume_id: str | None = None):
253
259
 
254
260
  # Resolve @file mentions (skip known agent names)
255
261
  _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:
262
+ mention_result = _resolve_mentions(user_input, os.getcwd(), _agent_names)
263
+ attached_images = mention_result.images
264
+ # File contents go into history as separate prunable messages (not inline)
265
+ mention_file_msgs = mention_result.file_messages
266
+ if mention_result.count > 0:
258
267
  parts = []
259
- text_count = injected - len(attached_images)
268
+ text_count = mention_result.count - len(attached_images)
260
269
  if text_count > 0:
261
270
  parts.append(f"{text_count} file(s)")
262
271
  if attached_images:
263
272
  parts.append(f"{len(attached_images)} image(s)")
264
273
  console.print(f"[dim]Attached {', '.join(parts)} from @ mentions[/dim]")
265
- user_input = resolved
274
+ user_input = mention_result.text
266
275
 
267
276
  if paste_state.pasted_content and user_text:
268
277
  console.print(
@@ -276,6 +285,14 @@ async def run_cli(skip_permissions: bool = False, resume_id: str | None = None):
276
285
  if not user_input:
277
286
  continue
278
287
 
288
+ # Inject @file contents as prunable history entries BEFORE the user message.
289
+ # These look like simulated read_file tool calls and can be pruned/compacted
290
+ # normally, unlike inline content which bloats the user message permanently.
291
+ if mention_file_msgs:
292
+ for msg in mention_file_msgs:
293
+ session.add_message(msg["role"], msg["content"])
294
+ mention_file_msgs = [] # consumed
295
+
279
296
  # Reset "allow all" approvals for each new user message
280
297
  perm_reset_session()
281
298
 
@@ -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(
@@ -11,15 +11,15 @@ from __future__ import annotations
11
11
  # ── Constants ──────────────────────────────────────────────────────
12
12
 
13
13
  # Pruning: minimum chars that must be freeable to justify a prune pass
14
- PRUNE_MINIMUM_CHARS = 12_000 # ~3K tokens (lower = prune sooner)
14
+ PRUNE_MINIMUM_CHARS = 8_000 # ~2K tokens (was 12K prune sooner)
15
15
  # Placeholder that replaces evicted content
16
- PRUNED_PLACEHOLDER = "[previous output cleared to save context]"
16
+ PRUNED_PLACEHOLDER = "[cleared]"
17
17
  # User messages larger than this threshold are truncated when outside protection window
18
- PRUNE_USER_MSG_THRESHOLD = 2_000 # ~570 tokens — catches @file mentions
18
+ PRUNE_USER_MSG_THRESHOLD = 1_200 # ~340 tokens (was 2K catch file contents earlier)
19
19
  # How many chars to keep from the start of a pruned user message
20
- PRUNE_USER_MSG_KEEP = 500 # ~140 tokens — enough to understand the request
20
+ PRUNE_USER_MSG_KEEP = 300 # ~85 tokens (was 500 — enough for the request intent)
21
21
  # Minimum number of recent user turns always protected (regardless of char budget)
22
- PRUNE_PROTECT_TURNS = 2
22
+ PRUNE_PROTECT_TURNS = 1 # was 2 — only protect the very last turn
23
23
  # Tool result markers that should never be pruned (critical context)
24
24
  PRUNE_PROTECTED_MARKERS = {"[SubAgent-", "delegate_task"}
25
25
  # Tool names whose outputs should never be pruned (like OpenCode's PRUNE_PROTECTED_TOOLS)
@@ -27,16 +27,20 @@ PRUNE_PROTECTED_MARKERS = {"[SubAgent-", "delegate_task"}
27
27
  PRUNE_PROTECTED_TOOLS = {"delegate_task"}
28
28
 
29
29
  # Truncation: universal limits for any tool output
30
- TRUNCATE_MAX_LINES = 300
31
- TRUNCATE_MAX_BYTES = 15 * 1024 # 15 KB (was 20KBtighter to prevent context bloat)
32
- TRUNCATE_KEEP_START = 200 # lines to keep from the start
33
- TRUNCATE_KEEP_END = 60 # lines to keep from the end
34
- TRUNCATE_MAX_LINE_LENGTH = 2000 # chars per individual line (prevents minified files)
30
+ TRUNCATE_MAX_LINES = 200 # was 300 — tighter to save context
31
+ TRUNCATE_MAX_BYTES = 10 * 1024 # 10 KB (was 15KBsave full to disk instead)
32
+ TRUNCATE_KEEP_START = 150 # lines to keep from the start
33
+ TRUNCATE_KEEP_END = 30 # lines to keep from the end (was 60)
34
+ TRUNCATE_MAX_LINE_LENGTH = 1500 # chars per individual line (prevents minified files)
35
+ # Directory for saving full truncated outputs (like OpenCode pattern)
36
+ TRUNCATE_SAVE_DIR = ".aru/truncated"
35
37
 
36
38
  # Compaction: trigger when per-run input tokens exceed this fraction of model limit
37
- COMPACTION_THRESHOLD_RATIO = 0.70 # was 0.85 — compact earlier to avoid hitting limits
39
+ COMPACTION_THRESHOLD_RATIO = 0.50 # was 0.70 — compact much earlier to stay lean
38
40
  # Compaction: target post-compaction size as fraction of model context limit
39
- COMPACTION_TARGET_RATIO = 0.15
41
+ COMPACTION_TARGET_RATIO = 0.10 # was 0.15 — more aggressive compaction target
42
+ # Compaction: also trigger after this many user turns (regardless of token count)
43
+ COMPACTION_MAX_TURNS = 8
40
44
  # Compaction: reserve buffer for the compaction process itself (like OpenCode's 20K)
41
45
  COMPACTION_BUFFER_TOKENS = 20_000
42
46
  # Default model context limits (input tokens)
@@ -111,10 +115,10 @@ def _get_prune_protect_chars(model_id: str = "default") -> int:
111
115
  to prevent context overflow. Returns ~7% of the model's context in chars.
112
116
  """
113
117
  limit = MODEL_CONTEXT_LIMITS.get(model_id, MODEL_CONTEXT_LIMITS["default"])
114
- # ~4 chars per token, protect ~7% of context (was 10% — tighter budget)
115
- protect = int(limit * 0.07 * 4)
116
- # Clamp between 15K (minimum usable) and 60K (diminishing returns)
117
- return max(15_000, min(protect, 60_000))
118
+ # ~4 chars per token, protect ~5% of context (was 7% — tighter budget)
119
+ protect = int(limit * 0.05 * 4)
120
+ # Clamp between 10K (minimum usable) and 40K (diminishing returns)
121
+ return max(10_000, min(protect, 40_000))
118
122
 
119
123
 
120
124
  def prune_history(
@@ -214,42 +218,50 @@ def _truncate_long_lines(lines: list[str]) -> list[str]:
214
218
  return result
215
219
 
216
220
 
221
+ def _save_truncated_output(text: str) -> str | None:
222
+ """Save full truncated output to disk and return the file path.
223
+
224
+ Returns None if saving fails (non-fatal — hint will omit path).
225
+ """
226
+ import os
227
+ import time
228
+
229
+ save_dir = os.path.join(os.getcwd(), TRUNCATE_SAVE_DIR)
230
+ try:
231
+ os.makedirs(save_dir, exist_ok=True)
232
+ filename = f"output_{int(time.time() * 1000)}.txt"
233
+ filepath = os.path.join(save_dir, filename)
234
+ with open(filepath, "w", encoding="utf-8") as f:
235
+ f.write(text)
236
+ return filepath
237
+ except OSError:
238
+ return None
239
+
240
+
217
241
  def _build_truncation_hint(
218
242
  source_file: str = "",
219
243
  source_tool: str = "",
220
244
  lines_shown: int = 0,
245
+ saved_path: str | None = None,
221
246
  ) -> str:
222
- """Build a context-aware truncation hint that guides the LLM to save tokens.
247
+ """Build a context-aware truncation hint.
223
248
 
224
- When the source file is known, provides a direct read_file reference with
225
- the next offset. Otherwise falls back to generic tool suggestions.
226
- Always suggests delegate_task for large exploration work.
249
+ When output was saved to disk, points to the saved file.
250
+ When the source file is known, provides a direct read_file reference.
227
251
  """
228
- parts = ["\n[Hint: Output was truncated."]
252
+ parts = ["[Truncated."]
229
253
 
230
- if source_file:
231
- # File-specific: tell the LLM exactly how to access the rest
254
+ if saved_path:
255
+ parts.append(f" Full output saved to: {saved_path}")
256
+ parts.append(" Use grep_search or read_file with start_line/end_line to inspect.")
257
+ elif source_file:
232
258
  next_line = lines_shown + 1 if lines_shown else 1
233
- parts.append(
234
- f' To see more: read_file("{source_file}", start_line={next_line}).'
235
- f" Use grep_search to find specific content instead of reading everything."
236
- )
237
- elif source_tool == "bash":
238
- parts.append(
239
- " Use grep_search to find specific content in project files."
240
- " Do NOT re-run the command to get full output."
241
- )
259
+ parts.append(f' read_file("{source_file}", start_line={next_line}) for more.')
242
260
  else:
243
- parts.append(
244
- " Use grep_search to find specific content, or read_file with"
245
- " start_line/end_line for incremental reading."
246
- )
261
+ parts.append(" Use grep_search to find specific content.")
247
262
 
248
- # Always suggest delegation for large outputs
249
- parts.append(
250
- " For large exploration tasks, use delegate_task to keep your context clean.]"
251
- )
252
- return "".join(parts)
263
+ parts.append("]")
264
+ return " ".join(parts)
253
265
 
254
266
 
255
267
  def truncate_output(
@@ -282,17 +294,18 @@ def truncate_output(
282
294
  if byte_len <= TRUNCATE_MAX_BYTES and line_count <= TRUNCATE_MAX_LINES:
283
295
  return "".join(lines)
284
296
 
297
+ # Save full output to disk before truncating (like OpenCode)
298
+ saved_path = _save_truncated_output(text)
299
+
285
300
  # Truncate by lines
286
301
  if line_count > TRUNCATE_MAX_LINES:
287
302
  head = lines[:TRUNCATE_KEEP_START]
288
- tail = lines[-TRUNCATE_KEEP_END:]
289
- omitted = line_count - TRUNCATE_KEEP_START - TRUNCATE_KEEP_END
290
- hint = _build_truncation_hint(source_file, source_tool, TRUNCATE_KEEP_START)
303
+ omitted = line_count - TRUNCATE_KEEP_START
304
+ hint = _build_truncation_hint(source_file, source_tool, TRUNCATE_KEEP_START, saved_path)
291
305
  return (
292
306
  "".join(head)
293
- + f"\n\n[... {omitted:,} lines omitted ({line_count:,} total)]"
294
- + hint + "\n\n"
295
- + "".join(tail)
307
+ + f"\n\n[... {omitted:,} lines omitted ({line_count:,} total)]\n"
308
+ + hint + "\n"
296
309
  )
297
310
 
298
311
  # Truncate by bytes (lines fit but total bytes too large)
@@ -306,11 +319,11 @@ def truncate_output(
306
319
  total += line_bytes
307
320
 
308
321
  remaining = line_count - len(kept_lines)
309
- hint = _build_truncation_hint(source_file, source_tool, len(kept_lines))
322
+ hint = _build_truncation_hint(source_file, source_tool, len(kept_lines), saved_path)
310
323
  return (
311
324
  "".join(kept_lines)
312
325
  + f"\n\n[... truncated at ~{TRUNCATE_MAX_BYTES // 1024}KB — "
313
- f"{remaining:,} more lines]"
326
+ f"{remaining:,} more lines]\n"
314
327
  + hint + "\n"
315
328
  )
316
329
 
@@ -329,16 +342,22 @@ def should_compact(
329
342
  ) -> bool:
330
343
  """Check if the conversation should be compacted.
331
344
 
332
- Uses OpenCode's approach: usable = model_limit - buffer, then
333
- trigger when tokens >= usable * threshold_ratio.
345
+ Triggers on EITHER condition:
346
+ 1. Token-based: tokens >= usable_context * threshold_ratio
347
+ 2. Turn-based: user turns >= COMPACTION_MAX_TURNS (prevents slow token creep)
334
348
 
335
- Accepts either an estimated token count (int) or the history list
336
- (from which tokens are estimated via char count).
349
+ Accepts either an estimated token count (int) or the history list.
337
350
  """
338
351
  if isinstance(history_or_tokens, list):
339
- tokens = estimate_history_tokens(history_or_tokens)
352
+ history = history_or_tokens
353
+ tokens = estimate_history_tokens(history)
354
+ # Turn-based trigger: count user messages
355
+ user_turns = sum(1 for m in history if m["role"] == "user")
356
+ if user_turns >= COMPACTION_MAX_TURNS:
357
+ return True
340
358
  else:
341
359
  tokens = history_or_tokens
360
+
342
361
  limit = MODEL_CONTEXT_LIMITS.get(model_id, MODEL_CONTEXT_LIMITS["default"])
343
362
  usable = limit - COMPACTION_BUFFER_TOKENS
344
363
  threshold = int(usable * COMPACTION_THRESHOLD_RATIO)
@@ -145,6 +145,8 @@ class Session:
145
145
  self._cached_tree: str | None = None
146
146
  self._cached_git_status: str | None = None
147
147
  self._context_dirty: bool = True
148
+ # Track whether AGENTS.md/extra instructions were already sent (skip on subsequent turns)
149
+ self.extra_instructions_sent: bool = False
148
150
  # Tree depth for env context (configurable via aru.json "tree_depth")
149
151
  self._tree_max_depth: int = 2
150
152
  # Token budget (0 = unlimited)
@@ -54,23 +54,23 @@ def _format_diff(old_string: str, new_string: str) -> Group:
54
54
 
55
55
 
56
56
 
57
- # Hard ceiling per tool result (~10K tokens). Even max_size=0 respects this per chunk.
58
- _READ_HARD_CAP = 40_000 # bytes (was 60Ktighter to protect context)
57
+ # Hard ceiling per tool result (~7K tokens). Even max_size=0 respects this per chunk.
58
+ _READ_HARD_CAP = 25_000 # bytes (was 40Keach tool result re-sent on next API call)
59
59
 
60
60
  def clear_read_cache():
61
61
  """Clear the read cache. Call after file mutations to avoid stale data."""
62
62
  get_ctx().read_cache.clear()
63
63
 
64
64
 
65
- def read_file(file_path: str, start_line: int = 0, end_line: int = 0, max_size: int = 12_000) -> str:
65
+ def read_file(file_path: str, start_line: int = 0, end_line: int = 0, max_size: int = 8_000) -> str:
66
66
  """Read file contents. Returns chunked output for large files.
67
67
 
68
68
  Args:
69
69
  file_path: Path to the file (absolute or relative).
70
70
  start_line: First line (1-indexed, inclusive). 0 = beginning.
71
71
  end_line: Last line (1-indexed, inclusive). 0 = end.
72
- max_size: Max bytes before truncation. Default 12KB.
73
- Set to 0 to read the full file in chunks — each chunk up to ~40KB.
72
+ max_size: Max bytes before truncation. Default 8KB.
73
+ Set to 0 to read the full file in chunks — each chunk up to ~25KB.
74
74
  The first chunk includes a continuation hint so you can call again
75
75
  with start_line to get the next chunk.
76
76
  """
@@ -505,15 +505,15 @@ def glob_search(pattern: str, directory: str = ".") -> str:
505
505
  return "\n".join(matches)
506
506
 
507
507
 
508
- def grep_search(pattern: str, directory: str = ".", file_glob: str = "", context_lines: int = 10) -> str:
508
+ def grep_search(pattern: str, directory: str = ".", file_glob: str = "", context_lines: int = 5) -> str:
509
509
  """Search for a regex pattern in file contents.
510
510
 
511
511
  Args:
512
512
  pattern: Regular expression pattern to search for.
513
513
  directory: Directory to search in. Defaults to current directory.
514
514
  file_glob: Optional glob to filter which files to search (e.g. '*.py').
515
- context_lines: Lines of context before and after each match (like grep -C). Default 10.
516
- Use 0 for file-level matches only. Use 30+ for full function bodies.
515
+ context_lines: Lines of context before and after each match (like grep -C). Default 5.
516
+ Use 0 for file-level matches only. Use 20+ for full function bodies.
517
517
  """
518
518
  import re
519
519
 
@@ -1158,7 +1158,7 @@ EXECUTOR_TOOLS = [
1158
1158
  delegate_task,
1159
1159
  ]
1160
1160
 
1161
- # General-purpose tools
1161
+ # General-purpose tools (full set — used as fallback)
1162
1162
  GENERAL_TOOLS = [
1163
1163
  read_file,
1164
1164
  read_file_smart,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aru-code
3
- Version: 0.15.0
3
+ Version: 0.16.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
@@ -3,6 +3,7 @@ README.md
3
3
  pyproject.toml
4
4
  aru/__init__.py
5
5
  aru/agent_factory.py
6
+ aru/cache_patch.py
6
7
  aru/cli.py
7
8
  aru/commands.py
8
9
  aru/completers.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "aru-code"
7
- version = "0.15.0"
7
+ version = "0.16.0"
8
8
  description = "A Claude Code clone built with Agno agents"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -46,35 +46,40 @@ class TestSanitizeInput:
46
46
 
47
47
  class TestResolveMentions:
48
48
  def test_no_mentions(self, tmp_path):
49
- result, count, _imgs = _resolve_mentions("hello world", str(tmp_path))
50
- assert result == "hello world"
51
- assert count == 0
49
+ mr = _resolve_mentions("hello world", str(tmp_path))
50
+ assert mr.text == "hello world"
51
+ assert mr.count == 0
52
+ assert mr.file_messages == []
52
53
 
53
54
  def test_resolves_file_mention(self, tmp_path):
54
55
  (tmp_path / "config.py").write_text("DEBUG = True")
55
- result, count, _imgs = _resolve_mentions("check @config.py", str(tmp_path))
56
- assert "DEBUG = True" in result
57
- assert "Contents of config.py" in result
58
- assert count == 1
56
+ mr = _resolve_mentions("check @config.py", str(tmp_path))
57
+ # File content now goes into file_messages, not inline text
58
+ assert mr.count == 1
59
+ assert len(mr.file_messages) == 2 # assistant label + user content
60
+ assert "read_file: config.py" in mr.file_messages[0]["content"]
61
+ assert "DEBUG = True" in mr.file_messages[1]["content"]
59
62
 
60
63
  def test_nonexistent_file_ignored(self, tmp_path):
61
- result, count, _imgs = _resolve_mentions("check @missing.py", str(tmp_path))
62
- assert result == "check @missing.py"
63
- assert count == 0
64
+ mr = _resolve_mentions("check @missing.py", str(tmp_path))
65
+ assert mr.text == "check @missing.py"
66
+ assert mr.count == 0
64
67
 
65
68
  def test_deduplicates_mentions(self, tmp_path):
66
69
  (tmp_path / "file.py").write_text("code")
67
- result, count, _imgs = _resolve_mentions("@file.py and @file.py", str(tmp_path))
68
- assert result.count("Contents of file.py") == 1
69
- assert count == 1
70
+ mr = _resolve_mentions("@file.py and @file.py", str(tmp_path))
71
+ assert mr.count == 1
72
+ assert len(mr.file_messages) == 2 # one pair
70
73
 
71
74
  def test_multiple_files(self, tmp_path):
72
75
  (tmp_path / "a.py").write_text("aaa")
73
76
  (tmp_path / "b.py").write_text("bbb")
74
- result, count, _imgs = _resolve_mentions("@a.py and @b.py", str(tmp_path))
75
- assert "Contents of a.py" in result
76
- assert "Contents of b.py" in result
77
- assert count == 2
77
+ mr = _resolve_mentions("@a.py and @b.py", str(tmp_path))
78
+ assert mr.count == 2
79
+ assert len(mr.file_messages) == 4 # two pairs
80
+ all_content = " ".join(m["content"] for m in mr.file_messages)
81
+ assert "aaa" in all_content
82
+ assert "bbb" in all_content
78
83
 
79
84
  def test_mention_regex_pattern(self):
80
85
  matches = _MENTION_RE.findall("check @file.py now")
@@ -606,61 +606,53 @@ class TestExtractAgentMention:
606
606
  class TestImageMentions:
607
607
  """Tests for image file detection in @mentions."""
608
608
 
609
- def test_resolve_mentions_returns_three_tuple(self, tmp_path):
610
- result = _resolve_mentions("hello", str(tmp_path))
611
- assert len(result) == 3
612
- text, count, images = result
613
- assert text == "hello"
614
- assert count == 0
615
- assert images == []
609
+ def test_resolve_mentions_returns_mention_result(self, tmp_path):
610
+ mr = _resolve_mentions("hello", str(tmp_path))
611
+ assert mr.text == "hello"
612
+ assert mr.count == 0
613
+ assert mr.images == []
614
+ assert mr.file_messages == []
616
615
 
617
616
  def test_resolve_mentions_image_file(self, tmp_path):
618
617
  img = tmp_path / "screenshot.png"
619
618
  img.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 100)
620
619
 
621
- text, count, images = _resolve_mentions(
622
- "analyze @screenshot.png", str(tmp_path)
623
- )
624
- assert count == 1
625
- assert len(images) == 1
626
- assert isinstance(images[0], Image)
627
- assert images[0].id == "screenshot.png"
628
- # Image content should NOT be appended as text
629
- assert "```" not in text
620
+ mr = _resolve_mentions("analyze @screenshot.png", str(tmp_path))
621
+ assert mr.count == 1
622
+ assert len(mr.images) == 1
623
+ assert isinstance(mr.images[0], Image)
624
+ assert mr.images[0].id == "screenshot.png"
625
+ # Image content should NOT be in file_messages
626
+ assert len(mr.file_messages) == 0
630
627
 
631
628
  def test_resolve_mentions_mixed_files_and_images(self, tmp_path):
632
629
  (tmp_path / "code.py").write_text("print('hello')", encoding="utf-8")
633
630
  (tmp_path / "diagram.jpg").write_bytes(b"\xff\xd8\xff" + b"\x00" * 100)
634
631
 
635
- text, count, images = _resolve_mentions(
636
- "review @code.py and @diagram.jpg", str(tmp_path)
637
- )
638
- assert count == 2
639
- assert len(images) == 1
640
- assert images[0].id == "diagram.jpg"
641
- # Text file content should be appended
642
- assert "print('hello')" in text
632
+ mr = _resolve_mentions("review @code.py and @diagram.jpg", str(tmp_path))
633
+ assert mr.count == 2
634
+ assert len(mr.images) == 1
635
+ assert mr.images[0].id == "diagram.jpg"
636
+ # Text file content goes into file_messages
637
+ all_content = " ".join(m["content"] for m in mr.file_messages)
638
+ assert "print('hello')" in all_content
643
639
 
644
640
  def test_resolve_mentions_multiple_images(self, tmp_path):
645
641
  (tmp_path / "a.png").write_bytes(b"\x89PNG" + b"\x00" * 100)
646
642
  (tmp_path / "b.webp").write_bytes(b"RIFF" + b"\x00" * 100)
647
643
 
648
- text, count, images = _resolve_mentions(
649
- "compare @a.png @b.webp", str(tmp_path)
650
- )
651
- assert count == 2
652
- assert len(images) == 2
644
+ mr = _resolve_mentions("compare @a.png @b.webp", str(tmp_path))
645
+ assert mr.count == 2
646
+ assert len(mr.images) == 2
653
647
 
654
648
  def test_resolve_mentions_image_too_large(self, tmp_path):
655
649
  img = tmp_path / "huge.png"
656
650
  # Write just over the 20MB limit header
657
651
  img.write_bytes(b"\x89PNG" + b"\x00" * (20 * 1024 * 1024 + 1))
658
652
 
659
- text, count, images = _resolve_mentions(
660
- "analyze @huge.png", str(tmp_path)
661
- )
662
- assert count == 0
663
- assert len(images) == 0
653
+ mr = _resolve_mentions("analyze @huge.png", str(tmp_path))
654
+ assert mr.count == 0
655
+ assert len(mr.images) == 0
664
656
 
665
657
  def test_resolve_mentions_all_image_extensions(self, tmp_path):
666
658
  for ext in _IMAGE_EXTENSIONS:
@@ -668,8 +660,8 @@ class TestImageMentions:
668
660
  (tmp_path / fname).write_bytes(b"\x00" * 100)
669
661
 
670
662
  mentions = " ".join(f"@test{ext}" for ext in _IMAGE_EXTENSIONS)
671
- text, count, images = _resolve_mentions(mentions, str(tmp_path))
672
- assert len(images) == len(_IMAGE_EXTENSIONS)
663
+ mr = _resolve_mentions(mentions, str(tmp_path))
664
+ assert len(mr.images) == len(_IMAGE_EXTENSIONS)
673
665
 
674
666
  def test_image_completer_shows_image_metadata(self, tmp_path):
675
667
  (tmp_path / "photo.png").touch()
@@ -1 +0,0 @@
1
- __version__ = "0.15.0"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes