aru-code 0.13.3__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.3/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.3 → aru_code-0.14.0}/aru/agents/base.py +18 -1
  4. {aru_code-0.13.3 → aru_code-0.14.0}/aru/context.py +101 -14
  5. {aru_code-0.13.3 → aru_code-0.14.0}/aru/tools/codebase.py +45 -2
  6. {aru_code-0.13.3 → aru_code-0.14.0/aru_code.egg-info}/PKG-INFO +1 -1
  7. {aru_code-0.13.3 → aru_code-0.14.0}/pyproject.toml +1 -1
  8. {aru_code-0.13.3 → aru_code-0.14.0}/tests/test_codebase.py +5 -5
  9. aru_code-0.13.3/aru/__init__.py +0 -1
  10. {aru_code-0.13.3 → aru_code-0.14.0}/LICENSE +0 -0
  11. {aru_code-0.13.3 → aru_code-0.14.0}/README.md +0 -0
  12. {aru_code-0.13.3 → aru_code-0.14.0}/aru/agent_factory.py +0 -0
  13. {aru_code-0.13.3 → aru_code-0.14.0}/aru/agents/__init__.py +0 -0
  14. {aru_code-0.13.3 → aru_code-0.14.0}/aru/agents/executor.py +0 -0
  15. {aru_code-0.13.3 → aru_code-0.14.0}/aru/agents/planner.py +0 -0
  16. {aru_code-0.13.3 → aru_code-0.14.0}/aru/cli.py +0 -0
  17. {aru_code-0.13.3 → aru_code-0.14.0}/aru/commands.py +0 -0
  18. {aru_code-0.13.3 → aru_code-0.14.0}/aru/completers.py +0 -0
  19. {aru_code-0.13.3 → aru_code-0.14.0}/aru/config.py +0 -0
  20. {aru_code-0.13.3 → aru_code-0.14.0}/aru/display.py +0 -0
  21. {aru_code-0.13.3 → aru_code-0.14.0}/aru/permissions.py +0 -0
  22. {aru_code-0.13.3 → aru_code-0.14.0}/aru/providers.py +0 -0
  23. {aru_code-0.13.3 → aru_code-0.14.0}/aru/runner.py +0 -0
  24. {aru_code-0.13.3 → aru_code-0.14.0}/aru/runtime.py +0 -0
  25. {aru_code-0.13.3 → aru_code-0.14.0}/aru/session.py +0 -0
  26. {aru_code-0.13.3 → aru_code-0.14.0}/aru/tools/__init__.py +0 -0
  27. {aru_code-0.13.3 → aru_code-0.14.0}/aru/tools/ast_tools.py +0 -0
  28. {aru_code-0.13.3 → aru_code-0.14.0}/aru/tools/gitignore.py +0 -0
  29. {aru_code-0.13.3 → aru_code-0.14.0}/aru/tools/mcp_client.py +0 -0
  30. {aru_code-0.13.3 → aru_code-0.14.0}/aru/tools/ranker.py +0 -0
  31. {aru_code-0.13.3 → aru_code-0.14.0}/aru/tools/tasklist.py +0 -0
  32. {aru_code-0.13.3 → aru_code-0.14.0}/aru_code.egg-info/SOURCES.txt +0 -0
  33. {aru_code-0.13.3 → aru_code-0.14.0}/aru_code.egg-info/dependency_links.txt +0 -0
  34. {aru_code-0.13.3 → aru_code-0.14.0}/aru_code.egg-info/entry_points.txt +0 -0
  35. {aru_code-0.13.3 → aru_code-0.14.0}/aru_code.egg-info/requires.txt +0 -0
  36. {aru_code-0.13.3 → aru_code-0.14.0}/aru_code.egg-info/top_level.txt +0 -0
  37. {aru_code-0.13.3 → aru_code-0.14.0}/setup.cfg +0 -0
  38. {aru_code-0.13.3 → aru_code-0.14.0}/tests/test_agents_base.py +0 -0
  39. {aru_code-0.13.3 → aru_code-0.14.0}/tests/test_ast_tools.py +0 -0
  40. {aru_code-0.13.3 → aru_code-0.14.0}/tests/test_cli.py +0 -0
  41. {aru_code-0.13.3 → aru_code-0.14.0}/tests/test_cli_advanced.py +0 -0
  42. {aru_code-0.13.3 → aru_code-0.14.0}/tests/test_cli_base.py +0 -0
  43. {aru_code-0.13.3 → aru_code-0.14.0}/tests/test_cli_completers.py +0 -0
  44. {aru_code-0.13.3 → aru_code-0.14.0}/tests/test_cli_new.py +0 -0
  45. {aru_code-0.13.3 → aru_code-0.14.0}/tests/test_cli_run_cli.py +0 -0
  46. {aru_code-0.13.3 → aru_code-0.14.0}/tests/test_cli_session.py +0 -0
  47. {aru_code-0.13.3 → aru_code-0.14.0}/tests/test_cli_shell.py +0 -0
  48. {aru_code-0.13.3 → aru_code-0.14.0}/tests/test_config.py +0 -0
  49. {aru_code-0.13.3 → aru_code-0.14.0}/tests/test_context.py +0 -0
  50. {aru_code-0.13.3 → aru_code-0.14.0}/tests/test_executor.py +0 -0
  51. {aru_code-0.13.3 → aru_code-0.14.0}/tests/test_gitignore.py +0 -0
  52. {aru_code-0.13.3 → aru_code-0.14.0}/tests/test_main.py +0 -0
  53. {aru_code-0.13.3 → aru_code-0.14.0}/tests/test_mcp_client.py +0 -0
  54. {aru_code-0.13.3 → aru_code-0.14.0}/tests/test_permissions.py +0 -0
  55. {aru_code-0.13.3 → aru_code-0.14.0}/tests/test_planner.py +0 -0
  56. {aru_code-0.13.3 → aru_code-0.14.0}/tests/test_providers.py +0 -0
  57. {aru_code-0.13.3 → 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.3
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. \
@@ -18,12 +18,17 @@ PRUNED_PLACEHOLDER = "[previous output cleared to save context]"
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
27
  TRUNCATE_MAX_LINES = 500
24
28
  TRUNCATE_MAX_BYTES = 20 * 1024 # 20 KB
25
29
  TRUNCATE_KEEP_START = 350 # lines to keep from the start
26
30
  TRUNCATE_KEEP_END = 100 # 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
34
  COMPACTION_THRESHOLD_RATIO = 0.85
@@ -70,15 +75,26 @@ 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 ──────────────────────────────────────────────
@@ -104,9 +120,14 @@ def prune_history(
104
120
  Walks backward through history, protecting the most recent content
105
121
  (scaled to the model's context size). Older messages beyond that
106
122
  budget are pruned:
107
- - Assistant messages: replaced entirely with placeholder
123
+ - Assistant messages: replaced entirely with placeholder (unless protected)
108
124
  - User messages over PRUNE_USER_MSG_THRESHOLD: truncated to first N chars
109
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
+
110
131
  Returns a new list (does not mutate the input).
111
132
  """
112
133
  if len(history) <= 2:
@@ -121,6 +142,18 @@ def prune_history(
121
142
  if total_chars < protect_chars + PRUNE_MINIMUM_CHARS:
122
143
  return list(history)
123
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
+
124
157
  # Walk backward, protecting recent content
125
158
  result = list(history)
126
159
  protected = 0
@@ -129,10 +162,20 @@ def prune_history(
129
162
  msg = result[i]
130
163
  msg_len = len(msg["content"])
131
164
 
165
+ # Turn-based protection: never prune last N user turns
166
+ if i in turn_protected:
167
+ protected += msg_len
168
+ continue
169
+
132
170
  if protected + msg_len <= protect_chars:
133
171
  # Still within protection window
134
172
  protected += msg_len
135
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
+
136
179
  # Beyond protection window — prune
137
180
  if msg["role"] == "assistant":
138
181
  if msg["content"] != PRUNED_PLACEHOLDER:
@@ -147,11 +190,36 @@ def prune_history(
147
190
 
148
191
  # ── Layer 2: Truncation ───────────────────────────────────────────
149
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
+
150
217
  def truncate_output(text: str) -> str:
151
218
  """Universal truncation for tool outputs.
152
219
 
153
220
  Caps output at TRUNCATE_MAX_BYTES / TRUNCATE_MAX_LINES, keeping the
154
221
  start and end with a middle marker showing what was cut.
222
+ Also truncates individual lines exceeding TRUNCATE_MAX_LINE_LENGTH.
155
223
  """
156
224
  if not text:
157
225
  return text
@@ -161,8 +229,11 @@ def truncate_output(text: str) -> str:
161
229
  lines = text.splitlines(keepends=True)
162
230
  line_count = len(lines)
163
231
 
232
+ # Truncate individual long lines first
233
+ lines = _truncate_long_lines(lines)
234
+
164
235
  if byte_len <= TRUNCATE_MAX_BYTES and line_count <= TRUNCATE_MAX_LINES:
165
- return text
236
+ return "".join(lines)
166
237
 
167
238
  # Truncate by lines
168
239
  if line_count > TRUNCATE_MAX_LINES:
@@ -171,8 +242,8 @@ def truncate_output(text: str) -> str:
171
242
  omitted = line_count - TRUNCATE_KEEP_START - TRUNCATE_KEEP_END
172
243
  return (
173
244
  "".join(head)
174
- + f"\n\n[... {omitted:,} lines omitted ({line_count:,} total)"
175
- 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"
176
247
  + "".join(tail)
177
248
  )
178
249
 
@@ -190,7 +261,8 @@ def truncate_output(text: str) -> str:
190
261
  return (
191
262
  "".join(kept_lines)
192
263
  + f"\n\n[... truncated at ~{TRUNCATE_MAX_BYTES // 1024}KB — "
193
- f"{remaining:,} more lines — use offset/limit to read further ...]\n"
264
+ f"{remaining:,} more lines]"
265
+ + _TRUNCATION_HINT + "\n"
194
266
  )
195
267
 
196
268
 
@@ -287,7 +359,7 @@ def apply_compaction(
287
359
 
288
360
  Uses the same protection window as pruning: recent messages within
289
361
  the window are preserved as-is, older messages are replaced by a
290
- compaction summary. This preserves the natural conversation flow.
362
+ compaction summary. Replays the last user message to maintain continuity.
291
363
  """
292
364
  _, recent = _split_history(history, model_id)
293
365
 
@@ -296,6 +368,21 @@ def apply_compaction(
296
368
  ]
297
369
  compacted.extend(recent)
298
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."})
385
+
299
386
  return compacted
300
387
 
301
388
 
@@ -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.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.3"
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.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
File without changes