aru-code 0.13.2__tar.gz → 0.14.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.13.2/aru_code.egg-info → aru_code-0.14.0}/PKG-INFO +1 -1
  2. aru_code-0.14.0/aru/__init__.py +1 -0
  3. {aru_code-0.13.2 → aru_code-0.14.0}/aru/agents/base.py +18 -1
  4. {aru_code-0.13.2 → aru_code-0.14.0}/aru/context.py +206 -49
  5. {aru_code-0.13.2 → aru_code-0.14.0}/aru/runner.py +24 -5
  6. {aru_code-0.13.2 → aru_code-0.14.0}/aru/tools/codebase.py +45 -2
  7. {aru_code-0.13.2 → aru_code-0.14.0/aru_code.egg-info}/PKG-INFO +1 -1
  8. {aru_code-0.13.2 → aru_code-0.14.0}/pyproject.toml +1 -1
  9. {aru_code-0.13.2 → aru_code-0.14.0}/tests/test_codebase.py +5 -5
  10. aru_code-0.13.2/aru/__init__.py +0 -1
  11. {aru_code-0.13.2 → aru_code-0.14.0}/LICENSE +0 -0
  12. {aru_code-0.13.2 → aru_code-0.14.0}/README.md +0 -0
  13. {aru_code-0.13.2 → aru_code-0.14.0}/aru/agent_factory.py +0 -0
  14. {aru_code-0.13.2 → aru_code-0.14.0}/aru/agents/__init__.py +0 -0
  15. {aru_code-0.13.2 → aru_code-0.14.0}/aru/agents/executor.py +0 -0
  16. {aru_code-0.13.2 → aru_code-0.14.0}/aru/agents/planner.py +0 -0
  17. {aru_code-0.13.2 → aru_code-0.14.0}/aru/cli.py +0 -0
  18. {aru_code-0.13.2 → aru_code-0.14.0}/aru/commands.py +0 -0
  19. {aru_code-0.13.2 → aru_code-0.14.0}/aru/completers.py +0 -0
  20. {aru_code-0.13.2 → aru_code-0.14.0}/aru/config.py +0 -0
  21. {aru_code-0.13.2 → aru_code-0.14.0}/aru/display.py +0 -0
  22. {aru_code-0.13.2 → aru_code-0.14.0}/aru/permissions.py +0 -0
  23. {aru_code-0.13.2 → aru_code-0.14.0}/aru/providers.py +0 -0
  24. {aru_code-0.13.2 → aru_code-0.14.0}/aru/runtime.py +0 -0
  25. {aru_code-0.13.2 → aru_code-0.14.0}/aru/session.py +0 -0
  26. {aru_code-0.13.2 → aru_code-0.14.0}/aru/tools/__init__.py +0 -0
  27. {aru_code-0.13.2 → aru_code-0.14.0}/aru/tools/ast_tools.py +0 -0
  28. {aru_code-0.13.2 → aru_code-0.14.0}/aru/tools/gitignore.py +0 -0
  29. {aru_code-0.13.2 → aru_code-0.14.0}/aru/tools/mcp_client.py +0 -0
  30. {aru_code-0.13.2 → aru_code-0.14.0}/aru/tools/ranker.py +0 -0
  31. {aru_code-0.13.2 → aru_code-0.14.0}/aru/tools/tasklist.py +0 -0
  32. {aru_code-0.13.2 → aru_code-0.14.0}/aru_code.egg-info/SOURCES.txt +0 -0
  33. {aru_code-0.13.2 → aru_code-0.14.0}/aru_code.egg-info/dependency_links.txt +0 -0
  34. {aru_code-0.13.2 → aru_code-0.14.0}/aru_code.egg-info/entry_points.txt +0 -0
  35. {aru_code-0.13.2 → aru_code-0.14.0}/aru_code.egg-info/requires.txt +0 -0
  36. {aru_code-0.13.2 → aru_code-0.14.0}/aru_code.egg-info/top_level.txt +0 -0
  37. {aru_code-0.13.2 → aru_code-0.14.0}/setup.cfg +0 -0
  38. {aru_code-0.13.2 → aru_code-0.14.0}/tests/test_agents_base.py +0 -0
  39. {aru_code-0.13.2 → aru_code-0.14.0}/tests/test_ast_tools.py +0 -0
  40. {aru_code-0.13.2 → aru_code-0.14.0}/tests/test_cli.py +0 -0
  41. {aru_code-0.13.2 → aru_code-0.14.0}/tests/test_cli_advanced.py +0 -0
  42. {aru_code-0.13.2 → aru_code-0.14.0}/tests/test_cli_base.py +0 -0
  43. {aru_code-0.13.2 → aru_code-0.14.0}/tests/test_cli_completers.py +0 -0
  44. {aru_code-0.13.2 → aru_code-0.14.0}/tests/test_cli_new.py +0 -0
  45. {aru_code-0.13.2 → aru_code-0.14.0}/tests/test_cli_run_cli.py +0 -0
  46. {aru_code-0.13.2 → aru_code-0.14.0}/tests/test_cli_session.py +0 -0
  47. {aru_code-0.13.2 → aru_code-0.14.0}/tests/test_cli_shell.py +0 -0
  48. {aru_code-0.13.2 → aru_code-0.14.0}/tests/test_config.py +0 -0
  49. {aru_code-0.13.2 → aru_code-0.14.0}/tests/test_context.py +0 -0
  50. {aru_code-0.13.2 → aru_code-0.14.0}/tests/test_executor.py +0 -0
  51. {aru_code-0.13.2 → aru_code-0.14.0}/tests/test_gitignore.py +0 -0
  52. {aru_code-0.13.2 → aru_code-0.14.0}/tests/test_main.py +0 -0
  53. {aru_code-0.13.2 → aru_code-0.14.0}/tests/test_mcp_client.py +0 -0
  54. {aru_code-0.13.2 → aru_code-0.14.0}/tests/test_permissions.py +0 -0
  55. {aru_code-0.13.2 → aru_code-0.14.0}/tests/test_planner.py +0 -0
  56. {aru_code-0.13.2 → aru_code-0.14.0}/tests/test_providers.py +0 -0
  57. {aru_code-0.13.2 → aru_code-0.14.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.13.2
3
+ Version: 0.14.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
@@ -0,0 +1 @@
1
+ __version__ = "0.14.0"
@@ -3,9 +3,26 @@
3
3
  # Common rules shared across all agents (planner, executor, general).
4
4
  # Each agent appends its role-specific instructions to this base.
5
5
  BASE_INSTRUCTIONS = """\
6
- Be concise and direct. Focus on doing the work, not explaining what you'll do.
6
+ ## Output rules CRITICAL for token efficiency
7
+
8
+ Minimize output tokens. Your responses should be fewer than 4 lines unless the user \
9
+ asks for detail or you are writing code. One word answers are best when they suffice.
10
+
11
+ Do NOT add unnecessary preamble or postamble. Avoid introductions, conclusions, \
12
+ and explanations of what you will do or just did. Do not add code explanation \
13
+ summaries unless the user requests them. Only address the specific query or task at hand.
14
+
7
15
  NEVER write narration before calling tools. Do NOT say "I will analyze...", "Let me check...", \
8
16
  "Now I will...", or any similar preamble. Call the tool immediately and silently.
17
+
18
+ Examples of ideal responses:
19
+ - user: "2 + 2" → assistant: "4"
20
+ - user: "is 11 prime?" → assistant: "Yes"
21
+ - user: "what command lists files?" → assistant: "ls"
22
+ - user: "fix the typo in line 5" → [call edit_file immediately, no narration]
23
+
24
+ ## Scope rules
25
+
9
26
  NEVER create documentation files (*.md) unless the user explicitly asks for them.
10
27
  Focus on writing working code, not documentation.
11
28
  Deliver EXACTLY what was asked — no more, no less. \
@@ -10,8 +10,6 @@ from __future__ import annotations
10
10
 
11
11
  # ── Constants ──────────────────────────────────────────────────────
12
12
 
13
- # Pruning: protect the most recent N chars of content from eviction
14
- PRUNE_PROTECT_CHARS = 50_000 # ~14K tokens
15
13
  # Pruning: minimum chars that must be freeable to justify a prune pass
16
14
  PRUNE_MINIMUM_CHARS = 20_000 # ~5.7K tokens
17
15
  # Placeholder that replaces evicted content
@@ -20,15 +18,22 @@ PRUNED_PLACEHOLDER = "[previous output cleared to save context]"
20
18
  PRUNE_USER_MSG_THRESHOLD = 2_000 # ~570 tokens — catches @file mentions
21
19
  # How many chars to keep from the start of a pruned user message
22
20
  PRUNE_USER_MSG_KEEP = 500 # ~140 tokens — enough to understand the request
21
+ # Minimum number of recent user turns always protected (regardless of char budget)
22
+ PRUNE_PROTECT_TURNS = 2
23
+ # Tool result markers that should never be pruned (critical context)
24
+ PRUNE_PROTECTED_MARKERS = {"[SubAgent-", "delegate_task"}
23
25
 
24
26
  # Truncation: universal limits for any tool output
25
27
  TRUNCATE_MAX_LINES = 500
26
28
  TRUNCATE_MAX_BYTES = 20 * 1024 # 20 KB
27
29
  TRUNCATE_KEEP_START = 350 # lines to keep from the start
28
30
  TRUNCATE_KEEP_END = 100 # lines to keep from the end
31
+ TRUNCATE_MAX_LINE_LENGTH = 2000 # chars per individual line (prevents minified files)
29
32
 
30
- # Compaction: trigger when cumulative input tokens exceed this fraction of model limit
33
+ # Compaction: trigger when per-run input tokens exceed this fraction of model limit
31
34
  COMPACTION_THRESHOLD_RATIO = 0.85
35
+ # Compaction: target post-compaction size as fraction of model context limit
36
+ COMPACTION_TARGET_RATIO = 0.15
32
37
  # Default model context limits (input tokens)
33
38
  MODEL_CONTEXT_LIMITS: dict[str, int] = {
34
39
  # Anthropic
@@ -70,40 +75,85 @@ MODEL_CONTEXT_LIMITS: dict[str, int] = {
70
75
  }
71
76
 
72
77
  COMPACTION_TEMPLATE = """\
73
- Summarize this conversation concisely. Preserve:
74
- 1. **Goal**: What the user wants to accomplish
75
- 2. **Key decisions**: Important choices made during the conversation
76
- 3. **Discoveries**: What was learned about the codebase or problem
77
- 4. **Accomplished**: What has been done so far (be specific about files changed)
78
- 5. **Relevant files**: File paths that are important for continuing the work
79
- 6. **Next steps**: What remains to be done
78
+ Summarize this conversation into the EXACT sections below. Be concise but complete — \
79
+ this summary replaces the full conversation history. Output ONLY these sections:
80
80
 
81
- Be concise but complete. This summary replaces the full conversation history."""
81
+ ## Goal
82
+ What the user is trying to accomplish (1-2 sentences).
83
+
84
+ ## Instructions
85
+ Important instructions or preferences the user stated (bullet list). \
86
+ If none, write "None stated."
87
+
88
+ ## Discoveries
89
+ Notable things learned about the codebase, bugs, or architecture (bullet list). \
90
+ If none, write "None."
91
+
92
+ ## Accomplished
93
+ What was done so far — be specific about files created/changed and functions added/modified. \
94
+ List what is in progress and what remains (bullet list).
95
+
96
+ ## Relevant files / directories
97
+ Structured list of file paths relevant to continuing the work (one per line)."""
82
98
 
83
99
 
84
100
  # ── Layer 1: Pruning ──────────────────────────────────────────────
85
101
 
86
- def prune_history(history: list[dict[str, str]]) -> list[dict[str, str]]:
102
+ def _get_prune_protect_chars(model_id: str = "default") -> int:
103
+ """Scale protection window based on model context size.
104
+
105
+ Larger models get more protection; smaller models prune more aggressively
106
+ to delay compaction. Returns ~10% of the model's context in chars (~3.5 chars/token).
107
+ """
108
+ limit = MODEL_CONTEXT_LIMITS.get(model_id, MODEL_CONTEXT_LIMITS["default"])
109
+ # ~3.5 chars per token, protect ~10% of context
110
+ protect = int(limit * 0.10 * 3.5)
111
+ # Clamp between 20K (minimum usable) and 80K (diminishing returns)
112
+ return max(20_000, min(protect, 80_000))
113
+
114
+
115
+ def prune_history(
116
+ history: list[dict[str, str]], model_id: str = "default"
117
+ ) -> list[dict[str, str]]:
87
118
  """Replace old messages with a short placeholder to reduce tokens.
88
119
 
89
120
  Walks backward through history, protecting the most recent content
90
- (up to PRUNE_PROTECT_CHARS total across both roles). Older messages
91
- beyond that budget are pruned:
92
- - Assistant messages: replaced entirely with placeholder
121
+ (scaled to the model's context size). Older messages beyond that
122
+ budget are pruned:
123
+ - Assistant messages: replaced entirely with placeholder (unless protected)
93
124
  - User messages over PRUNE_USER_MSG_THRESHOLD: truncated to first N chars
94
125
 
126
+ Protection layers:
127
+ 1. Turn-based: last PRUNE_PROTECT_TURNS user turns always kept
128
+ 2. Char-based: recent content within the protection window
129
+ 3. Content-based: messages containing PRUNE_PROTECTED_MARKERS never pruned
130
+
95
131
  Returns a new list (does not mutate the input).
96
132
  """
97
133
  if len(history) <= 2:
98
134
  return list(history)
99
135
 
136
+ protect_chars = _get_prune_protect_chars(model_id)
137
+
100
138
  # Calculate total prunable chars (both roles)
101
139
  total_chars = sum(len(msg["content"]) for msg in history)
102
140
 
103
141
  # Not enough to prune
104
- if total_chars < PRUNE_PROTECT_CHARS + PRUNE_MINIMUM_CHARS:
142
+ if total_chars < protect_chars + PRUNE_MINIMUM_CHARS:
105
143
  return list(history)
106
144
 
145
+ # Identify indices of last N user turns (always protected)
146
+ turn_protected: set[int] = set()
147
+ user_turns_seen = 0
148
+ for i in range(len(history) - 1, -1, -1):
149
+ if history[i]["role"] == "user":
150
+ user_turns_seen += 1
151
+ if user_turns_seen <= PRUNE_PROTECT_TURNS:
152
+ turn_protected.add(i)
153
+ # Also protect the assistant response right after this user turn
154
+ if i + 1 < len(history):
155
+ turn_protected.add(i + 1)
156
+
107
157
  # Walk backward, protecting recent content
108
158
  result = list(history)
109
159
  protected = 0
@@ -112,17 +162,25 @@ def prune_history(history: list[dict[str, str]]) -> list[dict[str, str]]:
112
162
  msg = result[i]
113
163
  msg_len = len(msg["content"])
114
164
 
115
- if protected + msg_len <= PRUNE_PROTECT_CHARS:
165
+ # Turn-based protection: never prune last N user turns
166
+ if i in turn_protected:
167
+ protected += msg_len
168
+ continue
169
+
170
+ if protected + msg_len <= protect_chars:
116
171
  # Still within protection window
117
172
  protected += msg_len
118
173
  else:
174
+ # Check protected markers before pruning
175
+ if any(marker in msg["content"] for marker in PRUNE_PROTECTED_MARKERS):
176
+ protected += msg_len
177
+ continue
178
+
119
179
  # Beyond protection window — prune
120
180
  if msg["role"] == "assistant":
121
181
  if msg["content"] != PRUNED_PLACEHOLDER:
122
182
  result[i] = {"role": "assistant", "content": PRUNED_PLACEHOLDER}
123
183
  elif msg["role"] == "user" and msg_len > PRUNE_USER_MSG_THRESHOLD:
124
- # Large user messages (e.g. @file mentions with file contents)
125
- # are truncated to keep a brief summary of the original request
126
184
  truncated = msg["content"][:PRUNE_USER_MSG_KEEP] + \
127
185
  f"\n\n[... {msg_len - PRUNE_USER_MSG_KEEP:,} chars pruned to save context ...]"
128
186
  result[i] = {"role": "user", "content": truncated}
@@ -132,11 +190,36 @@ def prune_history(history: list[dict[str, str]]) -> list[dict[str, str]]:
132
190
 
133
191
  # ── Layer 2: Truncation ───────────────────────────────────────────
134
192
 
193
+ def _truncate_long_lines(lines: list[str]) -> list[str]:
194
+ """Truncate individual lines that exceed MAX_LINE_LENGTH.
195
+
196
+ Prevents minified JS/CSS or log lines from consuming massive tokens.
197
+ """
198
+ result = []
199
+ for line in lines:
200
+ if len(line) > TRUNCATE_MAX_LINE_LENGTH:
201
+ result.append(
202
+ line[:TRUNCATE_MAX_LINE_LENGTH]
203
+ + f"... (line truncated to {TRUNCATE_MAX_LINE_LENGTH} chars)\n"
204
+ )
205
+ else:
206
+ result.append(line)
207
+ return result
208
+
209
+
210
+ _TRUNCATION_HINT = (
211
+ "\n[Hint: Use grep_search to find specific content, or read_file with "
212
+ "start_line/end_line for incremental reading. "
213
+ "For large exploration tasks, use delegate_task to keep your context clean.]"
214
+ )
215
+
216
+
135
217
  def truncate_output(text: str) -> str:
136
218
  """Universal truncation for tool outputs.
137
219
 
138
220
  Caps output at TRUNCATE_MAX_BYTES / TRUNCATE_MAX_LINES, keeping the
139
221
  start and end with a middle marker showing what was cut.
222
+ Also truncates individual lines exceeding TRUNCATE_MAX_LINE_LENGTH.
140
223
  """
141
224
  if not text:
142
225
  return text
@@ -146,8 +229,11 @@ def truncate_output(text: str) -> str:
146
229
  lines = text.splitlines(keepends=True)
147
230
  line_count = len(lines)
148
231
 
232
+ # Truncate individual long lines first
233
+ lines = _truncate_long_lines(lines)
234
+
149
235
  if byte_len <= TRUNCATE_MAX_BYTES and line_count <= TRUNCATE_MAX_LINES:
150
- return text
236
+ return "".join(lines)
151
237
 
152
238
  # Truncate by lines
153
239
  if line_count > TRUNCATE_MAX_LINES:
@@ -156,8 +242,8 @@ def truncate_output(text: str) -> str:
156
242
  omitted = line_count - TRUNCATE_KEEP_START - TRUNCATE_KEEP_END
157
243
  return (
158
244
  "".join(head)
159
- + f"\n\n[... {omitted:,} lines omitted ({line_count:,} total)"
160
- f"use offset/limit or a more specific query ...]\n\n"
245
+ + f"\n\n[... {omitted:,} lines omitted ({line_count:,} total)]"
246
+ + _TRUNCATION_HINT + "\n\n"
161
247
  + "".join(tail)
162
248
  )
163
249
 
@@ -175,27 +261,86 @@ def truncate_output(text: str) -> str:
175
261
  return (
176
262
  "".join(kept_lines)
177
263
  + f"\n\n[... truncated at ~{TRUNCATE_MAX_BYTES // 1024}KB — "
178
- f"{remaining:,} more lines — use offset/limit to read further ...]\n"
264
+ f"{remaining:,} more lines]"
265
+ + _TRUNCATION_HINT + "\n"
179
266
  )
180
267
 
181
268
 
182
269
  # ── Layer 3: Compaction ───────────────────────────────────────────
183
270
 
184
- def should_compact(total_input_tokens: int, model_id: str = "default") -> bool:
185
- """Check if the conversation should be compacted based on token usage."""
271
+ def estimate_history_tokens(history: list[dict[str, str]]) -> int:
272
+ """Estimate token count from conversation history chars (~3.5 chars/token)."""
273
+ total_chars = sum(len(msg["content"]) for msg in history)
274
+ return int(total_chars / 3.5)
275
+
276
+
277
+ def should_compact(
278
+ history_or_tokens: int | list[dict[str, str]],
279
+ model_id: str = "default",
280
+ ) -> bool:
281
+ """Check if the conversation should be compacted (reactive, post-run).
282
+
283
+ Accepts either an estimated token count (int) or the history list
284
+ (from which tokens are estimated via char count).
285
+ """
286
+ if isinstance(history_or_tokens, list):
287
+ tokens = estimate_history_tokens(history_or_tokens)
288
+ else:
289
+ tokens = history_or_tokens
186
290
  limit = MODEL_CONTEXT_LIMITS.get(model_id, MODEL_CONTEXT_LIMITS["default"])
187
291
  threshold = int(limit * COMPACTION_THRESHOLD_RATIO)
188
- return total_input_tokens >= threshold
292
+ return tokens >= threshold
293
+
294
+
295
+ def would_prune(history: list[dict[str, str]], model_id: str = "default") -> bool:
296
+ """Check if prune_history would discard content from this history.
297
+
298
+ Uses the exact same criteria as prune_history: total chars exceed
299
+ the protection window + minimum prunable threshold.
300
+ """
301
+ if len(history) <= 2:
302
+ return False
303
+ total_chars = sum(len(msg["content"]) for msg in history)
304
+ protect_chars = _get_prune_protect_chars(model_id)
305
+ return total_chars >= protect_chars + PRUNE_MINIMUM_CHARS
306
+
307
+
308
+ def _split_history(history: list[dict[str, str]], model_id: str = "default") -> tuple[list[dict[str, str]], list[dict[str, str]]]:
309
+ """Split history into old (to summarize) and recent (to keep intact).
310
+
311
+ Uses the same protection window as pruning.
312
+ """
313
+ protect_chars = _get_prune_protect_chars(model_id)
314
+ protected = 0
315
+ split_idx = len(history)
316
+ for i in range(len(history) - 1, -1, -1):
317
+ msg_len = len(history[i]["content"])
318
+ if protected + msg_len <= protect_chars:
319
+ protected += msg_len
320
+ split_idx = i
321
+ else:
322
+ break
323
+ return history[:split_idx], history[split_idx:]
324
+
325
+
326
+ def build_compaction_prompt(
327
+ history: list[dict[str, str]],
328
+ plan_task: str | None = None,
329
+ model_id: str = "default",
330
+ ) -> str:
331
+ """Build the prompt sent to the compaction agent.
189
332
 
333
+ Only includes OLD messages (outside the protection window) for
334
+ summarization. Recent messages are kept intact by apply_compaction.
335
+ """
336
+ old_msgs, _ = _split_history(history, model_id)
190
337
 
191
- def build_compaction_prompt(history: list[dict[str, str]], plan_task: str | None = None) -> str:
192
- """Build the prompt sent to the compaction agent to summarize the conversation."""
193
338
  parts = [COMPACTION_TEMPLATE, "\n\n---\n\n## Conversation to summarize:\n"]
194
339
 
195
340
  if plan_task:
196
341
  parts.append(f"**Active task:** {plan_task}\n\n")
197
342
 
198
- for msg in history:
343
+ for msg in old_msgs:
199
344
  role = msg["role"].upper()
200
345
  content = msg["content"]
201
346
  # Cap individual messages in the compaction input to avoid blowing up
@@ -206,26 +351,37 @@ def build_compaction_prompt(history: list[dict[str, str]], plan_task: str | None
206
351
  return "".join(parts)
207
352
 
208
353
 
209
- def apply_compaction(history: list[dict[str, str]], summary: str) -> list[dict[str, str]]:
210
- """Replace history with a compaction summary + the most recent exchange."""
354
+
355
+ def apply_compaction(
356
+ history: list[dict[str, str]], summary: str, model_id: str = "default"
357
+ ) -> list[dict[str, str]]:
358
+ """Replace OLD messages with a summary, keep RECENT messages intact.
359
+
360
+ Uses the same protection window as pruning: recent messages within
361
+ the window are preserved as-is, older messages are replaced by a
362
+ compaction summary. Replays the last user message to maintain continuity.
363
+ """
364
+ _, recent = _split_history(history, model_id)
365
+
211
366
  compacted = [
212
367
  {"role": "user", "content": f"[Conversation compacted]\n\n{summary}"}
213
368
  ]
214
- # Keep the last user message and last assistant message for continuity
215
- last_user = None
216
- last_assistant = None
217
- for msg in reversed(history):
218
- if msg["role"] == "user" and last_user is None:
219
- last_user = msg
220
- elif msg["role"] == "assistant" and last_assistant is None:
221
- last_assistant = msg
222
- if last_user and last_assistant:
223
- break
224
-
225
- if last_assistant:
226
- compacted.append(last_assistant)
227
- if last_user and last_user != compacted[0]:
228
- compacted.append(last_user)
369
+ compacted.extend(recent)
370
+
371
+ # Replay: ensure the last message is from the user so the LLM continues naturally
372
+ if not compacted or compacted[-1]["role"] != "user":
373
+ # Find last user message in original history for replay
374
+ last_user = None
375
+ for msg in reversed(history):
376
+ if msg["role"] == "user":
377
+ last_user = msg["content"]
378
+ break
379
+ if last_user:
380
+ # Truncate replayed message to avoid re-bloating context
381
+ replay = last_user[:1000] if len(last_user) > 1000 else last_user
382
+ compacted.append({"role": "user", "content": replay})
383
+ else:
384
+ compacted.append({"role": "user", "content": "Continue if you have next steps, or stop and ask for clarification."})
229
385
 
230
386
  return compacted
231
387
 
@@ -234,6 +390,7 @@ async def compact_conversation(
234
390
  history: list[dict[str, str]],
235
391
  model_ref: str,
236
392
  plan_task: str | None = None,
393
+ model_id: str = "default",
237
394
  ) -> list[dict[str, str]]:
238
395
  """Run the compaction agent to summarize and replace history.
239
396
 
@@ -243,7 +400,7 @@ async def compact_conversation(
243
400
  from aru.runtime import get_ctx
244
401
  from aru.providers import create_model
245
402
 
246
- prompt = build_compaction_prompt(history, plan_task)
403
+ prompt = build_compaction_prompt(history, plan_task, model_id=model_id)
247
404
 
248
405
  try:
249
406
  from agno.agent import Agent
@@ -263,12 +420,12 @@ async def compact_conversation(
263
420
  # Fallback: simple mechanical summary
264
421
  summary = _fallback_summary(history, plan_task)
265
422
 
266
- return apply_compaction(history, summary)
423
+ return apply_compaction(history, summary, model_id=model_id)
267
424
 
268
425
  except Exception:
269
426
  # Fallback if agent fails
270
427
  summary = _fallback_summary(history, plan_task)
271
- return apply_compaction(history, summary)
428
+ return apply_compaction(history, summary, model_id=model_id)
272
429
 
273
430
 
274
431
  def _fallback_summary(history: list[dict[str, str]], plan_task: str | None = None) -> str:
@@ -115,11 +115,25 @@ async def run_agent_capture(agent, message: str, session=None, lightweight: bool
115
115
  run_message = message
116
116
 
117
117
  # Build conversation history as real messages for the LLM
118
- from aru.context import prune_history
118
+ # Compact BEFORE pruning: if the history is large enough that pruning
119
+ # would discard content, compact first to preserve context via summary
120
+ # instead of losing it to placeholders.
121
+ from aru.context import prune_history, should_compact, compact_conversation, would_prune
122
+ if session and session.history and not lightweight:
123
+ if would_prune(session.history, model_id=session.model_id):
124
+ try:
125
+ session.history = await compact_conversation(
126
+ session.history, session.model_ref, session.plan_task,
127
+ model_id=session.model_id,
128
+ )
129
+ console.print("[dim]Context compacted to save tokens.[/dim]")
130
+ except Exception:
131
+ pass
132
+
119
133
  history_messages: list[Message] = []
120
134
  if session and session.history and not lightweight:
121
135
  prior_history = session.history[:-1]
122
- pruned = prune_history(prior_history)
136
+ pruned = prune_history(prior_history, model_id=session.model_id)
123
137
  for msg in pruned:
124
138
  history_messages.append(Message(role=msg["role"], content=msg["content"], from_history=True))
125
139
 
@@ -228,11 +242,16 @@ async def run_agent_capture(agent, message: str, session=None, lightweight: bool
228
242
  if run_output and session and hasattr(run_output, "metrics"):
229
243
  session.track_tokens(run_output.metrics)
230
244
 
231
- from aru.context import should_compact, compact_conversation
232
- if should_compact(session.total_input_tokens, session.model_id):
245
+ # Reactive compaction: use per-run input_tokens (sum of all API
246
+ # calls within this arun) as a conservative proxy for context pressure.
247
+ # session.history doesn't include tool results, so char-based estimates
248
+ # would miss the bulk of the context sent to the model.
249
+ run_input_tokens = getattr(run_output.metrics, "input_tokens", 0) or 0
250
+ if should_compact(run_input_tokens, session.model_id):
233
251
  try:
234
252
  session.history = await compact_conversation(
235
- session.history, session.model_ref, session.plan_task
253
+ session.history, session.model_ref, session.plan_task,
254
+ model_id=session.model_id,
236
255
  )
237
256
  console.print("[dim]Context compacted to save tokens.[/dim]")
238
257
  except Exception:
@@ -321,6 +321,35 @@ def write_files(file_list: list[dict]) -> str:
321
321
  return "\n".join(parts) or "No files to write."
322
322
 
323
323
 
324
+ def _compact_diff(old_string: str, new_string: str, file_path: str = "") -> str:
325
+ """Generate a compact unified diff string for the LLM context.
326
+
327
+ Returns only the changed lines (not the full file), saving tokens while
328
+ giving the LLM enough context to continue working.
329
+ """
330
+ old_lines = old_string.splitlines(keepends=True)
331
+ new_lines = new_string.splitlines(keepends=True)
332
+ # Ensure trailing newlines for clean diff
333
+ if old_lines and not old_lines[-1].endswith("\n"):
334
+ old_lines[-1] += "\n"
335
+ if new_lines and not new_lines[-1].endswith("\n"):
336
+ new_lines[-1] += "\n"
337
+
338
+ import difflib
339
+ diff_lines = list(difflib.unified_diff(
340
+ old_lines, new_lines,
341
+ fromfile=file_path, tofile=file_path,
342
+ lineterm="",
343
+ ))
344
+ if not diff_lines:
345
+ return ""
346
+ # Cap diff output to avoid huge diffs bloating context
347
+ MAX_DIFF_LINES = 40
348
+ if len(diff_lines) > MAX_DIFF_LINES:
349
+ return "\n".join(diff_lines[:MAX_DIFF_LINES]) + f"\n... ({len(diff_lines) - MAX_DIFF_LINES} more diff lines)"
350
+ return "\n".join(diff_lines)
351
+
352
+
324
353
  def edit_file(file_path: str, old_string: str, new_string: str) -> str:
325
354
  """Replace an exact string in a file. The old_string must appear exactly once.
326
355
 
@@ -347,7 +376,12 @@ def edit_file(file_path: str, old_string: str, new_string: str) -> str:
347
376
  with open(file_path, "w", encoding="utf-8") as f:
348
377
  f.write(new_content)
349
378
  _notify_file_mutation()
350
- return f"Successfully edited {file_path}"
379
+
380
+ # Return compact diff instead of just success message
381
+ diff_text = _compact_diff(old_string, new_string, file_path)
382
+ if diff_text:
383
+ return f"Edited {file_path}\n{diff_text}"
384
+ return f"Edited {file_path}"
351
385
  except FileNotFoundError:
352
386
  return f"Error: File not found: {file_path}"
353
387
  except Exception as e:
@@ -423,7 +457,16 @@ def edit_files(edits: list[dict]) -> str:
423
457
  if results:
424
458
  _notify_file_mutation()
425
459
  unique = list(dict.fromkeys(results)) # preserve order, dedupe
426
- parts.append(f"Successfully applied {len(results)} edits across {len(unique)} files: {', '.join(unique)}")
460
+ parts.append(f"Applied {len(results)} edits across {len(unique)} files: {', '.join(unique)}")
461
+ # Append compact diffs for each edit
462
+ for entry in edits:
463
+ old = entry.get("old_string", "")
464
+ new = entry.get("new_string", "")
465
+ path = entry.get("path", "")
466
+ if old and path in written:
467
+ diff_text = _compact_diff(old, new, path)
468
+ if diff_text:
469
+ parts.append(diff_text)
427
470
  if errors:
428
471
  parts.append("\n".join(errors))
429
472
  return "\n".join(parts) or "No edits to apply."
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aru-code
3
- Version: 0.13.2
3
+ Version: 0.14.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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "aru-code"
7
- version = "0.13.2"
7
+ version = "0.14.0"
8
8
  description = "A Claude Code clone built with Agno agents"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -209,7 +209,7 @@ def test_edit_file_basic(tmp_path):
209
209
  finally:
210
210
  set_skip_permissions(False)
211
211
 
212
- assert "Successfully edited" in result
212
+ assert "Edited" in result
213
213
  assert f.read_text() == "def hello():\n return 'earth'\n"
214
214
 
215
215
 
@@ -234,7 +234,7 @@ def test_edit_file_search_replace(tmp_path):
234
234
  finally:
235
235
  set_skip_permissions(False)
236
236
 
237
- assert "Successfully edited" in result
237
+ assert "Edited" in result
238
238
  updated = f.read_text()
239
239
  assert "DB_HOST = 'production.example.com'" in updated
240
240
  assert "DB_PORT = 5433" in updated
@@ -427,7 +427,7 @@ class TestEditFiles:
427
427
  finally:
428
428
  set_skip_permissions(False)
429
429
 
430
- assert "Successfully" in result
430
+ assert "Applied" in result or "Edited" in result
431
431
  assert f1.read_text() == "alpha = 10"
432
432
  assert f2.read_text() == "beta = 20"
433
433
 
@@ -446,7 +446,7 @@ class TestEditFiles:
446
446
  finally:
447
447
  set_skip_permissions(False)
448
448
 
449
- assert "Successfully" in result
449
+ assert "Applied" in result or "Edited" in result
450
450
  content = f.read_text()
451
451
  assert "HOST = 'prod.example.com'" in content
452
452
  assert "PORT = 8080" in content
@@ -501,7 +501,7 @@ class TestEditFiles:
501
501
  finally:
502
502
  set_skip_permissions(False)
503
503
 
504
- assert "Successfully" in result
504
+ assert "Applied" in result or "Edited" in result
505
505
 
506
506
  content_a = f1.read_text()
507
507
  assert "import logging" in content_a
@@ -1 +0,0 @@
1
- __version__ = "0.13.2"
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
File without changes
File without changes
File without changes