aru-code 0.13.3__tar.gz → 0.14.1__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.3/aru_code.egg-info → aru_code-0.14.1}/PKG-INFO +1 -1
  2. aru_code-0.14.1/aru/__init__.py +1 -0
  3. {aru_code-0.13.3 → aru_code-0.14.1}/aru/agents/base.py +18 -1
  4. {aru_code-0.13.3 → aru_code-0.14.1}/aru/context.py +120 -27
  5. {aru_code-0.13.3 → aru_code-0.14.1}/aru/runner.py +3 -0
  6. {aru_code-0.13.3 → aru_code-0.14.1}/aru/tools/codebase.py +51 -8
  7. {aru_code-0.13.3 → aru_code-0.14.1/aru_code.egg-info}/PKG-INFO +1 -1
  8. {aru_code-0.13.3 → aru_code-0.14.1}/pyproject.toml +1 -1
  9. {aru_code-0.13.3 → aru_code-0.14.1}/tests/test_codebase.py +5 -5
  10. aru_code-0.13.3/aru/__init__.py +0 -1
  11. {aru_code-0.13.3 → aru_code-0.14.1}/LICENSE +0 -0
  12. {aru_code-0.13.3 → aru_code-0.14.1}/README.md +0 -0
  13. {aru_code-0.13.3 → aru_code-0.14.1}/aru/agent_factory.py +0 -0
  14. {aru_code-0.13.3 → aru_code-0.14.1}/aru/agents/__init__.py +0 -0
  15. {aru_code-0.13.3 → aru_code-0.14.1}/aru/agents/executor.py +0 -0
  16. {aru_code-0.13.3 → aru_code-0.14.1}/aru/agents/planner.py +0 -0
  17. {aru_code-0.13.3 → aru_code-0.14.1}/aru/cli.py +0 -0
  18. {aru_code-0.13.3 → aru_code-0.14.1}/aru/commands.py +0 -0
  19. {aru_code-0.13.3 → aru_code-0.14.1}/aru/completers.py +0 -0
  20. {aru_code-0.13.3 → aru_code-0.14.1}/aru/config.py +0 -0
  21. {aru_code-0.13.3 → aru_code-0.14.1}/aru/display.py +0 -0
  22. {aru_code-0.13.3 → aru_code-0.14.1}/aru/permissions.py +0 -0
  23. {aru_code-0.13.3 → aru_code-0.14.1}/aru/providers.py +0 -0
  24. {aru_code-0.13.3 → aru_code-0.14.1}/aru/runtime.py +0 -0
  25. {aru_code-0.13.3 → aru_code-0.14.1}/aru/session.py +0 -0
  26. {aru_code-0.13.3 → aru_code-0.14.1}/aru/tools/__init__.py +0 -0
  27. {aru_code-0.13.3 → aru_code-0.14.1}/aru/tools/ast_tools.py +0 -0
  28. {aru_code-0.13.3 → aru_code-0.14.1}/aru/tools/gitignore.py +0 -0
  29. {aru_code-0.13.3 → aru_code-0.14.1}/aru/tools/mcp_client.py +0 -0
  30. {aru_code-0.13.3 → aru_code-0.14.1}/aru/tools/ranker.py +0 -0
  31. {aru_code-0.13.3 → aru_code-0.14.1}/aru/tools/tasklist.py +0 -0
  32. {aru_code-0.13.3 → aru_code-0.14.1}/aru_code.egg-info/SOURCES.txt +0 -0
  33. {aru_code-0.13.3 → aru_code-0.14.1}/aru_code.egg-info/dependency_links.txt +0 -0
  34. {aru_code-0.13.3 → aru_code-0.14.1}/aru_code.egg-info/entry_points.txt +0 -0
  35. {aru_code-0.13.3 → aru_code-0.14.1}/aru_code.egg-info/requires.txt +0 -0
  36. {aru_code-0.13.3 → aru_code-0.14.1}/aru_code.egg-info/top_level.txt +0 -0
  37. {aru_code-0.13.3 → aru_code-0.14.1}/setup.cfg +0 -0
  38. {aru_code-0.13.3 → aru_code-0.14.1}/tests/test_agents_base.py +0 -0
  39. {aru_code-0.13.3 → aru_code-0.14.1}/tests/test_ast_tools.py +0 -0
  40. {aru_code-0.13.3 → aru_code-0.14.1}/tests/test_cli.py +0 -0
  41. {aru_code-0.13.3 → aru_code-0.14.1}/tests/test_cli_advanced.py +0 -0
  42. {aru_code-0.13.3 → aru_code-0.14.1}/tests/test_cli_base.py +0 -0
  43. {aru_code-0.13.3 → aru_code-0.14.1}/tests/test_cli_completers.py +0 -0
  44. {aru_code-0.13.3 → aru_code-0.14.1}/tests/test_cli_new.py +0 -0
  45. {aru_code-0.13.3 → aru_code-0.14.1}/tests/test_cli_run_cli.py +0 -0
  46. {aru_code-0.13.3 → aru_code-0.14.1}/tests/test_cli_session.py +0 -0
  47. {aru_code-0.13.3 → aru_code-0.14.1}/tests/test_cli_shell.py +0 -0
  48. {aru_code-0.13.3 → aru_code-0.14.1}/tests/test_config.py +0 -0
  49. {aru_code-0.13.3 → aru_code-0.14.1}/tests/test_context.py +0 -0
  50. {aru_code-0.13.3 → aru_code-0.14.1}/tests/test_executor.py +0 -0
  51. {aru_code-0.13.3 → aru_code-0.14.1}/tests/test_gitignore.py +0 -0
  52. {aru_code-0.13.3 → aru_code-0.14.1}/tests/test_main.py +0 -0
  53. {aru_code-0.13.3 → aru_code-0.14.1}/tests/test_mcp_client.py +0 -0
  54. {aru_code-0.13.3 → aru_code-0.14.1}/tests/test_permissions.py +0 -0
  55. {aru_code-0.13.3 → aru_code-0.14.1}/tests/test_planner.py +0 -0
  56. {aru_code-0.13.3 → aru_code-0.14.1}/tests/test_providers.py +0 -0
  57. {aru_code-0.13.3 → aru_code-0.14.1}/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.3
3
+ Version: 0.14.1
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.1"
@@ -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. \
@@ -11,24 +11,31 @@ 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 = 20_000 # ~5.7K tokens
14
+ PRUNE_MINIMUM_CHARS = 12_000 # ~3K tokens (lower = prune sooner)
15
15
  # Placeholder that replaces evicted content
16
16
  PRUNED_PLACEHOLDER = "[previous output cleared to save context]"
17
17
  # User messages larger than this threshold are truncated when outside protection window
18
18
  PRUNE_USER_MSG_THRESHOLD = 2_000 # ~570 tokens — catches @file mentions
19
19
  # How many chars to keep from the start of a pruned user message
20
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"}
21
25
 
22
26
  # Truncation: universal limits for any tool output
23
- TRUNCATE_MAX_LINES = 500
24
- TRUNCATE_MAX_BYTES = 20 * 1024 # 20 KB
25
- TRUNCATE_KEEP_START = 350 # lines to keep from the start
26
- TRUNCATE_KEEP_END = 100 # lines to keep from the end
27
+ TRUNCATE_MAX_LINES = 300
28
+ TRUNCATE_MAX_BYTES = 15 * 1024 # 15 KB (was 20KB — tighter to prevent context bloat)
29
+ TRUNCATE_KEEP_START = 200 # lines to keep from the start
30
+ TRUNCATE_KEEP_END = 60 # lines to keep from the end
31
+ TRUNCATE_MAX_LINE_LENGTH = 2000 # chars per individual line (prevents minified files)
27
32
 
28
33
  # Compaction: trigger when per-run input tokens exceed this fraction of model limit
29
- COMPACTION_THRESHOLD_RATIO = 0.85
34
+ COMPACTION_THRESHOLD_RATIO = 0.70 # was 0.85 — compact earlier to avoid hitting limits
30
35
  # Compaction: target post-compaction size as fraction of model context limit
31
36
  COMPACTION_TARGET_RATIO = 0.15
37
+ # Compaction: reserve buffer for the compaction process itself (like OpenCode's 20K)
38
+ COMPACTION_BUFFER_TOKENS = 20_000
32
39
  # Default model context limits (input tokens)
33
40
  MODEL_CONTEXT_LIMITS: dict[str, int] = {
34
41
  # Anthropic
@@ -70,15 +77,26 @@ MODEL_CONTEXT_LIMITS: dict[str, int] = {
70
77
  }
71
78
 
72
79
  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
80
+ Summarize this conversation into the EXACT sections below. Be concise but complete — \
81
+ this summary replaces the full conversation history. Output ONLY these sections:
80
82
 
81
- Be concise but complete. This summary replaces the full conversation history."""
83
+ ## Goal
84
+ What the user is trying to accomplish (1-2 sentences).
85
+
86
+ ## Instructions
87
+ Important instructions or preferences the user stated (bullet list). \
88
+ If none, write "None stated."
89
+
90
+ ## Discoveries
91
+ Notable things learned about the codebase, bugs, or architecture (bullet list). \
92
+ If none, write "None."
93
+
94
+ ## Accomplished
95
+ What was done so far — be specific about files created/changed and functions added/modified. \
96
+ List what is in progress and what remains (bullet list).
97
+
98
+ ## Relevant files / directories
99
+ Structured list of file paths relevant to continuing the work (one per line)."""
82
100
 
83
101
 
84
102
  # ── Layer 1: Pruning ──────────────────────────────────────────────
@@ -87,13 +105,13 @@ def _get_prune_protect_chars(model_id: str = "default") -> int:
87
105
  """Scale protection window based on model context size.
88
106
 
89
107
  Larger models get more protection; smaller models prune more aggressively
90
- to delay compaction. Returns ~10% of the model's context in chars (~3.5 chars/token).
108
+ to prevent context overflow. Returns ~7% of the model's context in chars.
91
109
  """
92
110
  limit = MODEL_CONTEXT_LIMITS.get(model_id, MODEL_CONTEXT_LIMITS["default"])
93
- # ~3.5 chars per token, protect ~10% of context
94
- protect = int(limit * 0.10 * 3.5)
95
- # Clamp between 20K (minimum usable) and 80K (diminishing returns)
96
- return max(20_000, min(protect, 80_000))
111
+ # ~4 chars per token, protect ~7% of context (was 10% — tighter budget)
112
+ protect = int(limit * 0.07 * 4)
113
+ # Clamp between 15K (minimum usable) and 60K (diminishing returns)
114
+ return max(15_000, min(protect, 60_000))
97
115
 
98
116
 
99
117
  def prune_history(
@@ -104,9 +122,14 @@ def prune_history(
104
122
  Walks backward through history, protecting the most recent content
105
123
  (scaled to the model's context size). Older messages beyond that
106
124
  budget are pruned:
107
- - Assistant messages: replaced entirely with placeholder
125
+ - Assistant messages: replaced entirely with placeholder (unless protected)
108
126
  - User messages over PRUNE_USER_MSG_THRESHOLD: truncated to first N chars
109
127
 
128
+ Protection layers:
129
+ 1. Turn-based: last PRUNE_PROTECT_TURNS user turns always kept
130
+ 2. Char-based: recent content within the protection window
131
+ 3. Content-based: messages containing PRUNE_PROTECTED_MARKERS never pruned
132
+
110
133
  Returns a new list (does not mutate the input).
111
134
  """
112
135
  if len(history) <= 2:
@@ -121,6 +144,18 @@ def prune_history(
121
144
  if total_chars < protect_chars + PRUNE_MINIMUM_CHARS:
122
145
  return list(history)
123
146
 
147
+ # Identify indices of last N user turns (always protected)
148
+ turn_protected: set[int] = set()
149
+ user_turns_seen = 0
150
+ for i in range(len(history) - 1, -1, -1):
151
+ if history[i]["role"] == "user":
152
+ user_turns_seen += 1
153
+ if user_turns_seen <= PRUNE_PROTECT_TURNS:
154
+ turn_protected.add(i)
155
+ # Also protect the assistant response right after this user turn
156
+ if i + 1 < len(history):
157
+ turn_protected.add(i + 1)
158
+
124
159
  # Walk backward, protecting recent content
125
160
  result = list(history)
126
161
  protected = 0
@@ -129,10 +164,20 @@ def prune_history(
129
164
  msg = result[i]
130
165
  msg_len = len(msg["content"])
131
166
 
167
+ # Turn-based protection: never prune last N user turns
168
+ if i in turn_protected:
169
+ protected += msg_len
170
+ continue
171
+
132
172
  if protected + msg_len <= protect_chars:
133
173
  # Still within protection window
134
174
  protected += msg_len
135
175
  else:
176
+ # Check protected markers before pruning
177
+ if any(marker in msg["content"] for marker in PRUNE_PROTECTED_MARKERS):
178
+ protected += msg_len
179
+ continue
180
+
136
181
  # Beyond protection window — prune
137
182
  if msg["role"] == "assistant":
138
183
  if msg["content"] != PRUNED_PLACEHOLDER:
@@ -147,11 +192,36 @@ def prune_history(
147
192
 
148
193
  # ── Layer 2: Truncation ───────────────────────────────────────────
149
194
 
195
+ def _truncate_long_lines(lines: list[str]) -> list[str]:
196
+ """Truncate individual lines that exceed MAX_LINE_LENGTH.
197
+
198
+ Prevents minified JS/CSS or log lines from consuming massive tokens.
199
+ """
200
+ result = []
201
+ for line in lines:
202
+ if len(line) > TRUNCATE_MAX_LINE_LENGTH:
203
+ result.append(
204
+ line[:TRUNCATE_MAX_LINE_LENGTH]
205
+ + f"... (line truncated to {TRUNCATE_MAX_LINE_LENGTH} chars)\n"
206
+ )
207
+ else:
208
+ result.append(line)
209
+ return result
210
+
211
+
212
+ _TRUNCATION_HINT = (
213
+ "\n[Hint: Use grep_search to find specific content, or read_file with "
214
+ "start_line/end_line for incremental reading. "
215
+ "For large exploration tasks, use delegate_task to keep your context clean.]"
216
+ )
217
+
218
+
150
219
  def truncate_output(text: str) -> str:
151
220
  """Universal truncation for tool outputs.
152
221
 
153
222
  Caps output at TRUNCATE_MAX_BYTES / TRUNCATE_MAX_LINES, keeping the
154
223
  start and end with a middle marker showing what was cut.
224
+ Also truncates individual lines exceeding TRUNCATE_MAX_LINE_LENGTH.
155
225
  """
156
226
  if not text:
157
227
  return text
@@ -161,8 +231,11 @@ def truncate_output(text: str) -> str:
161
231
  lines = text.splitlines(keepends=True)
162
232
  line_count = len(lines)
163
233
 
234
+ # Truncate individual long lines first
235
+ lines = _truncate_long_lines(lines)
236
+
164
237
  if byte_len <= TRUNCATE_MAX_BYTES and line_count <= TRUNCATE_MAX_LINES:
165
- return text
238
+ return "".join(lines)
166
239
 
167
240
  # Truncate by lines
168
241
  if line_count > TRUNCATE_MAX_LINES:
@@ -171,8 +244,8 @@ def truncate_output(text: str) -> str:
171
244
  omitted = line_count - TRUNCATE_KEEP_START - TRUNCATE_KEEP_END
172
245
  return (
173
246
  "".join(head)
174
- + f"\n\n[... {omitted:,} lines omitted ({line_count:,} total)"
175
- f"use offset/limit or a more specific query ...]\n\n"
247
+ + f"\n\n[... {omitted:,} lines omitted ({line_count:,} total)]"
248
+ + _TRUNCATION_HINT + "\n\n"
176
249
  + "".join(tail)
177
250
  )
178
251
 
@@ -190,7 +263,8 @@ def truncate_output(text: str) -> str:
190
263
  return (
191
264
  "".join(kept_lines)
192
265
  + f"\n\n[... truncated at ~{TRUNCATE_MAX_BYTES // 1024}KB — "
193
- f"{remaining:,} more lines — use offset/limit to read further ...]\n"
266
+ f"{remaining:,} more lines]"
267
+ + _TRUNCATION_HINT + "\n"
194
268
  )
195
269
 
196
270
 
@@ -206,7 +280,10 @@ def should_compact(
206
280
  history_or_tokens: int | list[dict[str, str]],
207
281
  model_id: str = "default",
208
282
  ) -> bool:
209
- """Check if the conversation should be compacted (reactive, post-run).
283
+ """Check if the conversation should be compacted.
284
+
285
+ Uses OpenCode's approach: usable = model_limit - buffer, then
286
+ trigger when tokens >= usable * threshold_ratio.
210
287
 
211
288
  Accepts either an estimated token count (int) or the history list
212
289
  (from which tokens are estimated via char count).
@@ -216,7 +293,8 @@ def should_compact(
216
293
  else:
217
294
  tokens = history_or_tokens
218
295
  limit = MODEL_CONTEXT_LIMITS.get(model_id, MODEL_CONTEXT_LIMITS["default"])
219
- threshold = int(limit * COMPACTION_THRESHOLD_RATIO)
296
+ usable = limit - COMPACTION_BUFFER_TOKENS
297
+ threshold = int(usable * COMPACTION_THRESHOLD_RATIO)
220
298
  return tokens >= threshold
221
299
 
222
300
 
@@ -287,7 +365,7 @@ def apply_compaction(
287
365
 
288
366
  Uses the same protection window as pruning: recent messages within
289
367
  the window are preserved as-is, older messages are replaced by a
290
- compaction summary. This preserves the natural conversation flow.
368
+ compaction summary. Replays the last user message to maintain continuity.
291
369
  """
292
370
  _, recent = _split_history(history, model_id)
293
371
 
@@ -296,6 +374,21 @@ def apply_compaction(
296
374
  ]
297
375
  compacted.extend(recent)
298
376
 
377
+ # Replay: ensure the last message is from the user so the LLM continues naturally
378
+ if not compacted or compacted[-1]["role"] != "user":
379
+ # Find last user message in original history for replay
380
+ last_user = None
381
+ for msg in reversed(history):
382
+ if msg["role"] == "user":
383
+ last_user = msg["content"]
384
+ break
385
+ if last_user:
386
+ # Truncate replayed message to avoid re-bloating context
387
+ replay = last_user[:1000] if len(last_user) > 1000 else last_user
388
+ compacted.append({"role": "user", "content": replay})
389
+ else:
390
+ compacted.append({"role": "user", "content": "Continue if you have next steps, or stop and ask for clarification."})
391
+
299
392
  return compacted
300
393
 
301
394
 
@@ -249,12 +249,15 @@ async def run_agent_capture(agent, message: str, session=None, lightweight: bool
249
249
  run_input_tokens = getattr(run_output.metrics, "input_tokens", 0) or 0
250
250
  if should_compact(run_input_tokens, session.model_id):
251
251
  try:
252
+ # Always prune first to shrink history before compaction
253
+ session.history = prune_history(session.history, model_id=session.model_id)
252
254
  session.history = await compact_conversation(
253
255
  session.history, session.model_ref, session.plan_task,
254
256
  model_id=session.model_id,
255
257
  )
256
258
  console.print("[dim]Context compacted to save tokens.[/dim]")
257
259
  except Exception:
260
+ # Even if compaction fails, keep the pruned history
258
261
  pass
259
262
 
260
263
  final_content = accumulated or final_content
@@ -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 (~15K tokens). Even max_size=0 respects this per chunk.
58
- _READ_HARD_CAP = 60_000 # bytes
57
+ # Hard ceiling per tool result (~10K tokens). Even max_size=0 respects this per chunk.
58
+ _READ_HARD_CAP = 40_000 # bytes (was 60K — tighter to protect context)
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 = 15_000) -> str:
65
+ def read_file(file_path: str, start_line: int = 0, end_line: int = 0, max_size: int = 12_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 15KB.
73
- Set to 0 to read the full file in chunks — each chunk up to ~60KB.
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.
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
  """
@@ -204,7 +204,7 @@ async def read_file_smart(file_path: str, query: str) -> str:
204
204
  query: The specific question you want answered about this file.
205
205
  """
206
206
  # Read raw content first (reuse existing read_file logic)
207
- raw = read_file(file_path, max_size=20_000)
207
+ raw = read_file(file_path, max_size=15_000)
208
208
 
209
209
  if raw.startswith("Error:"):
210
210
  return raw
@@ -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.3
3
+ Version: 0.14.1
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.3"
7
+ version = "0.14.1"
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.3"
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