aru-code 0.22.0__tar.gz → 0.22.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 (67) hide show
  1. {aru_code-0.22.0/aru_code.egg-info → aru_code-0.22.1}/PKG-INFO +1 -1
  2. aru_code-0.22.1/aru/__init__.py +1 -0
  3. {aru_code-0.22.0 → aru_code-0.22.1}/aru/cache_patch.py +3 -3
  4. {aru_code-0.22.0 → aru_code-0.22.1}/aru/checkpoints.py +189 -189
  5. {aru_code-0.22.0 → aru_code-0.22.1}/aru/context.py +10 -14
  6. {aru_code-0.22.0 → aru_code-0.22.1/aru_code.egg-info}/PKG-INFO +1 -1
  7. {aru_code-0.22.0 → aru_code-0.22.1}/pyproject.toml +1 -1
  8. {aru_code-0.22.0 → aru_code-0.22.1}/tests/test_checkpoints.py +190 -190
  9. {aru_code-0.22.0 → aru_code-0.22.1}/tests/test_context.py +7 -6
  10. aru_code-0.22.0/aru/__init__.py +0 -1
  11. {aru_code-0.22.0 → aru_code-0.22.1}/LICENSE +0 -0
  12. {aru_code-0.22.0 → aru_code-0.22.1}/README.md +0 -0
  13. {aru_code-0.22.0 → aru_code-0.22.1}/aru/agent_factory.py +0 -0
  14. {aru_code-0.22.0 → aru_code-0.22.1}/aru/agents/__init__.py +0 -0
  15. {aru_code-0.22.0 → aru_code-0.22.1}/aru/agents/base.py +0 -0
  16. {aru_code-0.22.0 → aru_code-0.22.1}/aru/agents/executor.py +0 -0
  17. {aru_code-0.22.0 → aru_code-0.22.1}/aru/agents/planner.py +0 -0
  18. {aru_code-0.22.0 → aru_code-0.22.1}/aru/cli.py +0 -0
  19. {aru_code-0.22.0 → aru_code-0.22.1}/aru/commands.py +0 -0
  20. {aru_code-0.22.0 → aru_code-0.22.1}/aru/completers.py +0 -0
  21. {aru_code-0.22.0 → aru_code-0.22.1}/aru/config.py +0 -0
  22. {aru_code-0.22.0 → aru_code-0.22.1}/aru/display.py +0 -0
  23. {aru_code-0.22.0 → aru_code-0.22.1}/aru/history_blocks.py +0 -0
  24. {aru_code-0.22.0 → aru_code-0.22.1}/aru/permissions.py +0 -0
  25. {aru_code-0.22.0 → aru_code-0.22.1}/aru/plugins/__init__.py +0 -0
  26. {aru_code-0.22.0 → aru_code-0.22.1}/aru/plugins/custom_tools.py +0 -0
  27. {aru_code-0.22.0 → aru_code-0.22.1}/aru/plugins/hooks.py +0 -0
  28. {aru_code-0.22.0 → aru_code-0.22.1}/aru/plugins/manager.py +0 -0
  29. {aru_code-0.22.0 → aru_code-0.22.1}/aru/plugins/tool_api.py +0 -0
  30. {aru_code-0.22.0 → aru_code-0.22.1}/aru/providers.py +0 -0
  31. {aru_code-0.22.0 → aru_code-0.22.1}/aru/runner.py +0 -0
  32. {aru_code-0.22.0 → aru_code-0.22.1}/aru/runtime.py +0 -0
  33. {aru_code-0.22.0 → aru_code-0.22.1}/aru/session.py +0 -0
  34. {aru_code-0.22.0 → aru_code-0.22.1}/aru/tools/__init__.py +0 -0
  35. {aru_code-0.22.0 → aru_code-0.22.1}/aru/tools/ast_tools.py +0 -0
  36. {aru_code-0.22.0 → aru_code-0.22.1}/aru/tools/codebase.py +0 -0
  37. {aru_code-0.22.0 → aru_code-0.22.1}/aru/tools/gitignore.py +0 -0
  38. {aru_code-0.22.0 → aru_code-0.22.1}/aru/tools/mcp_client.py +0 -0
  39. {aru_code-0.22.0 → aru_code-0.22.1}/aru/tools/ranker.py +0 -0
  40. {aru_code-0.22.0 → aru_code-0.22.1}/aru/tools/tasklist.py +0 -0
  41. {aru_code-0.22.0 → aru_code-0.22.1}/aru_code.egg-info/SOURCES.txt +0 -0
  42. {aru_code-0.22.0 → aru_code-0.22.1}/aru_code.egg-info/dependency_links.txt +0 -0
  43. {aru_code-0.22.0 → aru_code-0.22.1}/aru_code.egg-info/entry_points.txt +0 -0
  44. {aru_code-0.22.0 → aru_code-0.22.1}/aru_code.egg-info/requires.txt +0 -0
  45. {aru_code-0.22.0 → aru_code-0.22.1}/aru_code.egg-info/top_level.txt +0 -0
  46. {aru_code-0.22.0 → aru_code-0.22.1}/setup.cfg +0 -0
  47. {aru_code-0.22.0 → aru_code-0.22.1}/tests/test_agents_base.py +0 -0
  48. {aru_code-0.22.0 → aru_code-0.22.1}/tests/test_cli.py +0 -0
  49. {aru_code-0.22.0 → aru_code-0.22.1}/tests/test_cli_advanced.py +0 -0
  50. {aru_code-0.22.0 → aru_code-0.22.1}/tests/test_cli_base.py +0 -0
  51. {aru_code-0.22.0 → aru_code-0.22.1}/tests/test_cli_completers.py +0 -0
  52. {aru_code-0.22.0 → aru_code-0.22.1}/tests/test_cli_new.py +0 -0
  53. {aru_code-0.22.0 → aru_code-0.22.1}/tests/test_cli_run_cli.py +0 -0
  54. {aru_code-0.22.0 → aru_code-0.22.1}/tests/test_cli_session.py +0 -0
  55. {aru_code-0.22.0 → aru_code-0.22.1}/tests/test_cli_shell.py +0 -0
  56. {aru_code-0.22.0 → aru_code-0.22.1}/tests/test_codebase.py +0 -0
  57. {aru_code-0.22.0 → aru_code-0.22.1}/tests/test_confabulation_regression.py +0 -0
  58. {aru_code-0.22.0 → aru_code-0.22.1}/tests/test_config.py +0 -0
  59. {aru_code-0.22.0 → aru_code-0.22.1}/tests/test_executor.py +0 -0
  60. {aru_code-0.22.0 → aru_code-0.22.1}/tests/test_gitignore.py +0 -0
  61. {aru_code-0.22.0 → aru_code-0.22.1}/tests/test_main.py +0 -0
  62. {aru_code-0.22.0 → aru_code-0.22.1}/tests/test_mcp_client.py +0 -0
  63. {aru_code-0.22.0 → aru_code-0.22.1}/tests/test_permissions.py +0 -0
  64. {aru_code-0.22.0 → aru_code-0.22.1}/tests/test_planner.py +0 -0
  65. {aru_code-0.22.0 → aru_code-0.22.1}/tests/test_plugins.py +0 -0
  66. {aru_code-0.22.0 → aru_code-0.22.1}/tests/test_providers.py +0 -0
  67. {aru_code-0.22.0 → aru_code-0.22.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.22.0
3
+ Version: 0.22.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.22.1"
@@ -22,9 +22,9 @@ from __future__ import annotations
22
22
  # - Protect recent tool results within a token budget
23
23
  # - Only prune if there's enough to free (avoid churn)
24
24
  # - Walk backwards, protecting recent content first
25
- # OpenCode uses 40K protect / 20K minimum; we use chars (~4 chars/token)
26
- _PRUNE_PROTECT_CHARS = 160_000 # ~40K tokens — recent content always kept
27
- _PRUNE_MINIMUM_CHARS = 80_000 # ~20K tokens — only prune if this much is freeable
25
+ # Aligned with context.py thresholds to keep context ~30K tokens.
26
+ _PRUNE_PROTECT_CHARS = 55_000 # ~14K tokens — recent content always kept
27
+ _PRUNE_MINIMUM_CHARS = 20_000 # ~5K tokens — only prune if this much is freeable
28
28
  _PRUNED_PLACEHOLDER = "[Old tool result cleared]"
29
29
 
30
30
  # Last API call metrics (updated on every internal API call)
@@ -1,189 +1,189 @@
1
- """File checkpoint system for undo/rewind support.
2
-
3
- Tracks file state before tool mutations so changes can be reverted.
4
- Inspired by Claude Code's fileHistory system.
5
-
6
- Architecture:
7
- - Each user message creates a "snapshot" identified by a turn index.
8
- - Before any file mutation (write_file, edit_file, bash), the pre-edit
9
- content is saved as a versioned backup in .aru/file-history/{session_id}/.
10
- - On /undo, the most recent snapshot is applied: files are restored to
11
- their pre-turn state and the conversation is rewound.
12
-
13
- Backup naming: {sha256(path)[:16]}@v{version}
14
- Snapshot: {turn_index: {file_path: BackupEntry}}
15
- """
16
-
17
- from __future__ import annotations
18
-
19
- import hashlib
20
- import os
21
- import shutil
22
- import threading
23
- from dataclasses import dataclass, field
24
-
25
-
26
- @dataclass
27
- class BackupEntry:
28
- """A single file backup."""
29
- backup_path: str | None # None = file didn't exist before this turn
30
- version: int
31
- original_path: str
32
-
33
-
34
- @dataclass
35
- class Snapshot:
36
- """Checkpoint at a specific conversation turn."""
37
- turn_index: int
38
- backups: dict[str, BackupEntry] = field(default_factory=dict) # abs_path → BackupEntry
39
-
40
-
41
- MAX_SNAPSHOTS = 100
42
-
43
-
44
- class CheckpointManager:
45
- """Manages file checkpoints for a session.
46
-
47
- Thread-safe: multiple tools may run in parallel within a turn.
48
- """
49
-
50
- def __init__(self, session_id: str, base_dir: str | None = None):
51
- self._session_id = session_id
52
- self._base_dir = base_dir or os.path.join(os.getcwd(), ".aru", "file-history", session_id)
53
- self._lock = threading.Lock()
54
- self._snapshots: list[Snapshot] = []
55
- self._current_turn: int = 0
56
- self._tracked_files: set[str] = set()
57
- # Per-file version counter (monotonic)
58
- self._file_versions: dict[str, int] = {}
59
- self._dir_created = False
60
-
61
- def _ensure_dir(self):
62
- if not self._dir_created:
63
- os.makedirs(self._base_dir, exist_ok=True)
64
- self._dir_created = True
65
-
66
- def _backup_filename(self, file_path: str, version: int) -> str:
67
- path_hash = hashlib.sha256(file_path.encode("utf-8")).hexdigest()[:16]
68
- return f"{path_hash}@v{version}"
69
-
70
- def begin_turn(self, turn_index: int):
71
- """Start a new turn — creates a fresh snapshot for this turn."""
72
- with self._lock:
73
- self._current_turn = turn_index
74
- # Create snapshot for this turn (backups added lazily as files are edited)
75
- snapshot = Snapshot(turn_index=turn_index)
76
- self._snapshots.append(snapshot)
77
- # Enforce cap
78
- if len(self._snapshots) > MAX_SNAPSHOTS:
79
- evicted = self._snapshots.pop(0)
80
- self._cleanup_snapshot_backups(evicted)
81
-
82
- def track_edit(self, file_path: str):
83
- """Capture pre-edit state of a file before mutation.
84
-
85
- Call this BEFORE writing/editing a file. If the file was already
86
- captured in the current turn's snapshot, this is a no-op.
87
- """
88
- abs_path = os.path.abspath(file_path)
89
-
90
- with self._lock:
91
- if not self._snapshots:
92
- return
93
-
94
- current_snapshot = self._snapshots[-1]
95
-
96
- # Already tracked in this turn
97
- if abs_path in current_snapshot.backups:
98
- return
99
-
100
- # Increment version
101
- version = self._file_versions.get(abs_path, 0) + 1
102
- self._file_versions[abs_path] = version
103
- self._tracked_files.add(abs_path)
104
-
105
- # Read file outside lock (IO)
106
- backup_path = None
107
- if os.path.isfile(abs_path):
108
- self._ensure_dir()
109
- backup_name = self._backup_filename(abs_path, version)
110
- backup_path = os.path.join(self._base_dir, backup_name)
111
- try:
112
- shutil.copy2(abs_path, backup_path)
113
- except OSError:
114
- backup_path = None
115
-
116
- # Commit to snapshot
117
- with self._lock:
118
- if not self._snapshots:
119
- return
120
- entry = BackupEntry(
121
- backup_path=backup_path,
122
- version=version,
123
- original_path=abs_path,
124
- )
125
- self._snapshots[-1].backups[abs_path] = entry
126
-
127
- def undo_last_turn(self) -> tuple[list[str], int]:
128
- """Revert files changed in the most recent snapshot.
129
-
130
- Returns:
131
- (list of restored file paths, turn_index that was undone)
132
- """
133
- with self._lock:
134
- if not self._snapshots:
135
- return [], 0
136
- snapshot = self._snapshots.pop()
137
-
138
- restored = []
139
- for abs_path, entry in snapshot.backups.items():
140
- try:
141
- if entry.backup_path is None:
142
- # File didn't exist before — delete it
143
- if os.path.isfile(abs_path):
144
- os.unlink(abs_path)
145
- restored.append(abs_path)
146
- elif os.path.isfile(entry.backup_path):
147
- # Restore from backup
148
- shutil.copy2(entry.backup_path, abs_path)
149
- restored.append(abs_path)
150
- except OSError:
151
- pass # best effort
152
-
153
- return restored, snapshot.turn_index
154
-
155
- def get_snapshot_count(self) -> int:
156
- with self._lock:
157
- return len(self._snapshots)
158
-
159
- def get_last_snapshot_files(self) -> list[str]:
160
- """Return files that would be affected by undo."""
161
- with self._lock:
162
- if not self._snapshots:
163
- return []
164
- return list(self._snapshots[-1].backups.keys())
165
-
166
- def _cleanup_snapshot_backups(self, snapshot: Snapshot):
167
- """Remove backup files for an evicted snapshot (if not referenced by others)."""
168
- # Collect all backup paths still referenced
169
- referenced = set()
170
- for s in self._snapshots:
171
- for entry in s.backups.values():
172
- if entry.backup_path:
173
- referenced.add(entry.backup_path)
174
-
175
- # Delete unreferenced backups
176
- for entry in snapshot.backups.values():
177
- if entry.backup_path and entry.backup_path not in referenced:
178
- try:
179
- os.unlink(entry.backup_path)
180
- except OSError:
181
- pass
182
-
183
- def cleanup(self):
184
- """Remove all backup files for this session."""
185
- try:
186
- if os.path.isdir(self._base_dir):
187
- shutil.rmtree(self._base_dir, ignore_errors=True)
188
- except OSError:
189
- pass
1
+ """File checkpoint system for undo/rewind support.
2
+
3
+ Tracks file state before tool mutations so changes can be reverted.
4
+ Inspired by Claude Code's fileHistory system.
5
+
6
+ Architecture:
7
+ - Each user message creates a "snapshot" identified by a turn index.
8
+ - Before any file mutation (write_file, edit_file, bash), the pre-edit
9
+ content is saved as a versioned backup in .aru/file-history/{session_id}/.
10
+ - On /undo, the most recent snapshot is applied: files are restored to
11
+ their pre-turn state and the conversation is rewound.
12
+
13
+ Backup naming: {sha256(path)[:16]}@v{version}
14
+ Snapshot: {turn_index: {file_path: BackupEntry}}
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import hashlib
20
+ import os
21
+ import shutil
22
+ import threading
23
+ from dataclasses import dataclass, field
24
+
25
+
26
+ @dataclass
27
+ class BackupEntry:
28
+ """A single file backup."""
29
+ backup_path: str | None # None = file didn't exist before this turn
30
+ version: int
31
+ original_path: str
32
+
33
+
34
+ @dataclass
35
+ class Snapshot:
36
+ """Checkpoint at a specific conversation turn."""
37
+ turn_index: int
38
+ backups: dict[str, BackupEntry] = field(default_factory=dict) # abs_path → BackupEntry
39
+
40
+
41
+ MAX_SNAPSHOTS = 100
42
+
43
+
44
+ class CheckpointManager:
45
+ """Manages file checkpoints for a session.
46
+
47
+ Thread-safe: multiple tools may run in parallel within a turn.
48
+ """
49
+
50
+ def __init__(self, session_id: str, base_dir: str | None = None):
51
+ self._session_id = session_id
52
+ self._base_dir = base_dir or os.path.join(os.getcwd(), ".aru", "file-history", session_id)
53
+ self._lock = threading.Lock()
54
+ self._snapshots: list[Snapshot] = []
55
+ self._current_turn: int = 0
56
+ self._tracked_files: set[str] = set()
57
+ # Per-file version counter (monotonic)
58
+ self._file_versions: dict[str, int] = {}
59
+ self._dir_created = False
60
+
61
+ def _ensure_dir(self):
62
+ if not self._dir_created:
63
+ os.makedirs(self._base_dir, exist_ok=True)
64
+ self._dir_created = True
65
+
66
+ def _backup_filename(self, file_path: str, version: int) -> str:
67
+ path_hash = hashlib.sha256(file_path.encode("utf-8")).hexdigest()[:16]
68
+ return f"{path_hash}@v{version}"
69
+
70
+ def begin_turn(self, turn_index: int):
71
+ """Start a new turn — creates a fresh snapshot for this turn."""
72
+ with self._lock:
73
+ self._current_turn = turn_index
74
+ # Create snapshot for this turn (backups added lazily as files are edited)
75
+ snapshot = Snapshot(turn_index=turn_index)
76
+ self._snapshots.append(snapshot)
77
+ # Enforce cap
78
+ if len(self._snapshots) > MAX_SNAPSHOTS:
79
+ evicted = self._snapshots.pop(0)
80
+ self._cleanup_snapshot_backups(evicted)
81
+
82
+ def track_edit(self, file_path: str):
83
+ """Capture pre-edit state of a file before mutation.
84
+
85
+ Call this BEFORE writing/editing a file. If the file was already
86
+ captured in the current turn's snapshot, this is a no-op.
87
+ """
88
+ abs_path = os.path.abspath(file_path)
89
+
90
+ with self._lock:
91
+ if not self._snapshots:
92
+ return
93
+
94
+ current_snapshot = self._snapshots[-1]
95
+
96
+ # Already tracked in this turn
97
+ if abs_path in current_snapshot.backups:
98
+ return
99
+
100
+ # Increment version
101
+ version = self._file_versions.get(abs_path, 0) + 1
102
+ self._file_versions[abs_path] = version
103
+ self._tracked_files.add(abs_path)
104
+
105
+ # Read file outside lock (IO)
106
+ backup_path = None
107
+ if os.path.isfile(abs_path):
108
+ self._ensure_dir()
109
+ backup_name = self._backup_filename(abs_path, version)
110
+ backup_path = os.path.join(self._base_dir, backup_name)
111
+ try:
112
+ shutil.copy2(abs_path, backup_path)
113
+ except OSError:
114
+ backup_path = None
115
+
116
+ # Commit to snapshot
117
+ with self._lock:
118
+ if not self._snapshots:
119
+ return
120
+ entry = BackupEntry(
121
+ backup_path=backup_path,
122
+ version=version,
123
+ original_path=abs_path,
124
+ )
125
+ self._snapshots[-1].backups[abs_path] = entry
126
+
127
+ def undo_last_turn(self) -> tuple[list[str], int]:
128
+ """Revert files changed in the most recent snapshot.
129
+
130
+ Returns:
131
+ (list of restored file paths, turn_index that was undone)
132
+ """
133
+ with self._lock:
134
+ if not self._snapshots:
135
+ return [], 0
136
+ snapshot = self._snapshots.pop()
137
+
138
+ restored = []
139
+ for abs_path, entry in snapshot.backups.items():
140
+ try:
141
+ if entry.backup_path is None:
142
+ # File didn't exist before — delete it
143
+ if os.path.isfile(abs_path):
144
+ os.unlink(abs_path)
145
+ restored.append(abs_path)
146
+ elif os.path.isfile(entry.backup_path):
147
+ # Restore from backup
148
+ shutil.copy2(entry.backup_path, abs_path)
149
+ restored.append(abs_path)
150
+ except OSError:
151
+ pass # best effort
152
+
153
+ return restored, snapshot.turn_index
154
+
155
+ def get_snapshot_count(self) -> int:
156
+ with self._lock:
157
+ return len(self._snapshots)
158
+
159
+ def get_last_snapshot_files(self) -> list[str]:
160
+ """Return files that would be affected by undo."""
161
+ with self._lock:
162
+ if not self._snapshots:
163
+ return []
164
+ return list(self._snapshots[-1].backups.keys())
165
+
166
+ def _cleanup_snapshot_backups(self, snapshot: Snapshot):
167
+ """Remove backup files for an evicted snapshot (if not referenced by others)."""
168
+ # Collect all backup paths still referenced
169
+ referenced = set()
170
+ for s in self._snapshots:
171
+ for entry in s.backups.values():
172
+ if entry.backup_path:
173
+ referenced.add(entry.backup_path)
174
+
175
+ # Delete unreferenced backups
176
+ for entry in snapshot.backups.values():
177
+ if entry.backup_path and entry.backup_path not in referenced:
178
+ try:
179
+ os.unlink(entry.backup_path)
180
+ except OSError:
181
+ pass
182
+
183
+ def cleanup(self):
184
+ """Remove all backup files for this session."""
185
+ try:
186
+ if os.path.isdir(self._base_dir):
187
+ shutil.rmtree(self._base_dir, ignore_errors=True)
188
+ except OSError:
189
+ pass
@@ -24,8 +24,8 @@ from __future__ import annotations
24
24
  # ── Constants ──────────────────────────────────────────────────────
25
25
 
26
26
  # Pruning: minimum chars that must be freeable to justify a prune pass.
27
- # Matches opencode's PRUNE_MINIMUM = 20_000 tokens (~80K chars @ 4 chars/token).
28
- PRUNE_MINIMUM_CHARS = 80_000 # ~20K tokens
27
+ # Lower than opencode's 20K tokens to fire early and keep context ~30K tokens.
28
+ PRUNE_MINIMUM_CHARS = 20_000 # ~5K tokens
29
29
  # Placeholder that replaces cleared tool_result content. Matches
30
30
  # cache_patch.py's _PRUNED_PLACEHOLDER so both layers produce identical
31
31
  # text when a tool output is cleared.
@@ -177,24 +177,20 @@ def _tool_result_content_len(msg: dict) -> int:
177
177
 
178
178
 
179
179
  def _get_prune_protect_chars(model_id: str = "default") -> int:
180
- """Chars of recent history that must NEVER be pruned.
180
+ """Chars of recent tool-result content that must NEVER be pruned.
181
181
 
182
- Flat value across all models, mirroring opencode's fixed
183
- `PRUNE_PROTECT = 40_000` tokens (compaction.ts:36). At ~4 chars/token
184
- that's 160K chars of tool-result content kept intact in the recent
185
- window. Older tool_result blocks beyond this budget are eligible for
186
- the lossy clear pass in `prune_history`.
187
-
188
- Why flat (not scaled by model): opencode validated this in production
189
- on contexts from 128K to 1M — scaling by ratio adds complexity without
190
- improving behavior, and protecting too much in 1M-context models can
191
- actually hurt prompt caching by keeping rarely-touched tail content warm.
182
+ Targets a ~30K token total context window. With ~5K tokens of
183
+ system prompt + tool definitions and ~7K of user/assistant text,
184
+ the tool output budget is ~18K tokens 65K chars. We protect
185
+ 55K chars (~14K tokens) of recent tool output so pruning fires
186
+ at protect + PRUNE_MINIMUM = 55K + 20K = 75K chars (~19K tokens
187
+ of tool output), keeping the steady-state around 30K total.
192
188
 
193
189
  The `model_id` parameter is retained for signature compatibility with
194
190
  older call sites; it has no effect on the returned value.
195
191
  """
196
192
  del model_id # unused — kept for signature compatibility
197
- return 160_000
193
+ return 55_000
198
194
 
199
195
 
200
196
  def prune_history(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aru-code
3
- Version: 0.22.0
3
+ Version: 0.22.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.22.0"
7
+ version = "0.22.1"
8
8
  description = "A Claude Code clone built with Agno agents"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1,190 +1,190 @@
1
- """Tests for the checkpoint/undo system."""
2
-
3
- import os
4
- import tempfile
5
-
6
- import pytest
7
-
8
- from aru.checkpoints import CheckpointManager
9
-
10
-
11
- @pytest.fixture
12
- def tmp_workspace(tmp_path):
13
- """Create a temporary workspace with some files."""
14
- (tmp_path / "hello.py").write_text("print('hello')\n")
15
- (tmp_path / "config.json").write_text('{"key": "value"}\n')
16
- return tmp_path
17
-
18
-
19
- @pytest.fixture
20
- def manager(tmp_path):
21
- """Create a CheckpointManager with temp backup dir."""
22
- backup_dir = str(tmp_path / "backups")
23
- return CheckpointManager("test-session", base_dir=backup_dir)
24
-
25
-
26
- class TestCheckpointManager:
27
- def test_begin_turn_creates_snapshot(self, manager):
28
- manager.begin_turn(1)
29
- assert manager.get_snapshot_count() == 1
30
-
31
- def test_track_edit_captures_file_state(self, manager, tmp_workspace):
32
- manager.begin_turn(1)
33
- file_path = str(tmp_workspace / "hello.py")
34
- manager.track_edit(file_path)
35
-
36
- affected = manager.get_last_snapshot_files()
37
- assert os.path.abspath(file_path) in affected
38
-
39
- def test_track_edit_idempotent_within_turn(self, manager, tmp_workspace):
40
- manager.begin_turn(1)
41
- file_path = str(tmp_workspace / "hello.py")
42
- manager.track_edit(file_path)
43
- manager.track_edit(file_path) # should be no-op
44
-
45
- affected = manager.get_last_snapshot_files()
46
- assert len(affected) == 1
47
-
48
- def test_undo_restores_edited_file(self, manager, tmp_workspace):
49
- file_path = str(tmp_workspace / "hello.py")
50
- original_content = "print('hello')\n"
51
-
52
- manager.begin_turn(1)
53
- manager.track_edit(file_path)
54
-
55
- # Simulate edit
56
- with open(file_path, "w") as f:
57
- f.write("print('CHANGED')\n")
58
- assert open(file_path).read() == "print('CHANGED')\n"
59
-
60
- # Undo
61
- restored, turn = manager.undo_last_turn()
62
- assert turn == 1
63
- assert os.path.abspath(file_path) in restored
64
- assert open(file_path).read() == original_content
65
-
66
- def test_undo_deletes_newly_created_file(self, manager, tmp_workspace):
67
- new_file = str(tmp_workspace / "new_file.py")
68
-
69
- manager.begin_turn(1)
70
- manager.track_edit(new_file) # file doesn't exist yet
71
-
72
- # Simulate creation
73
- with open(new_file, "w") as f:
74
- f.write("new content\n")
75
- assert os.path.isfile(new_file)
76
-
77
- # Undo should delete the file
78
- restored, turn = manager.undo_last_turn()
79
- assert os.path.abspath(new_file) in restored
80
- assert not os.path.isfile(new_file)
81
-
82
- def test_undo_multiple_files(self, manager, tmp_workspace):
83
- file1 = str(tmp_workspace / "hello.py")
84
- file2 = str(tmp_workspace / "config.json")
85
-
86
- manager.begin_turn(1)
87
- manager.track_edit(file1)
88
- manager.track_edit(file2)
89
-
90
- # Edit both
91
- with open(file1, "w") as f:
92
- f.write("changed1\n")
93
- with open(file2, "w") as f:
94
- f.write("changed2\n")
95
-
96
- # Undo
97
- restored, _ = manager.undo_last_turn()
98
- assert len(restored) == 2
99
- assert open(file1).read() == "print('hello')\n"
100
- assert open(file2).read() == '{"key": "value"}\n'
101
-
102
- def test_undo_only_affects_last_turn(self, manager, tmp_workspace):
103
- file_path = str(tmp_workspace / "hello.py")
104
-
105
- # Turn 1: edit file
106
- manager.begin_turn(1)
107
- manager.track_edit(file_path)
108
- with open(file_path, "w") as f:
109
- f.write("turn1\n")
110
-
111
- # Turn 2: edit file again
112
- manager.begin_turn(2)
113
- manager.track_edit(file_path)
114
- with open(file_path, "w") as f:
115
- f.write("turn2\n")
116
-
117
- # Undo turn 2 → should restore to turn1 state
118
- restored, turn = manager.undo_last_turn()
119
- assert turn == 2
120
- assert open(file_path).read() == "turn1\n"
121
-
122
- # Undo turn 1 → should restore to original
123
- restored, turn = manager.undo_last_turn()
124
- assert turn == 1
125
- assert open(file_path).read() == "print('hello')\n"
126
-
127
- def test_undo_empty_returns_empty(self, manager):
128
- restored, turn = manager.undo_last_turn()
129
- assert restored == []
130
- assert turn == 0
131
-
132
- def test_get_last_snapshot_files_empty(self, manager):
133
- assert manager.get_last_snapshot_files() == []
134
-
135
- def test_max_snapshots_enforced(self, manager, tmp_workspace):
136
- file_path = str(tmp_workspace / "hello.py")
137
- for i in range(105):
138
- manager.begin_turn(i)
139
- manager.track_edit(file_path)
140
- with open(file_path, "w") as f:
141
- f.write(f"v{i}\n")
142
-
143
- assert manager.get_snapshot_count() == 100
144
-
145
- def test_cleanup_removes_backup_dir(self, manager, tmp_workspace):
146
- file_path = str(tmp_workspace / "hello.py")
147
- manager.begin_turn(1)
148
- manager.track_edit(file_path)
149
-
150
- assert os.path.isdir(manager._base_dir)
151
- manager.cleanup()
152
- assert not os.path.isdir(manager._base_dir)
153
-
154
-
155
- class TestSessionUndoLastTurn:
156
- """Tests for Session.undo_last_turn (conversation history only)."""
157
-
158
- def test_undo_removes_last_turn(self):
159
- from aru.session import Session
160
- session = Session()
161
- session.add_message("user", "hello")
162
- session.add_message("assistant", "hi there")
163
- session.add_message("user", "how are you")
164
- session.add_message("assistant", "good")
165
-
166
- removed = session.undo_last_turn()
167
- assert removed == 2 # user + assistant
168
- assert len(session.history) == 2
169
- assert session.history[-1]["role"] == "assistant"
170
-
171
- def test_undo_removes_tool_messages(self):
172
- from aru.session import Session
173
- session = Session()
174
- session.add_message("user", "fix the bug")
175
- session.add_message("assistant", "reading file")
176
- session.add_message("tool", "file contents here")
177
- session.add_message("assistant", "done")
178
-
179
- removed = session.undo_last_turn()
180
- # Should remove: user + assistant + tool + assistant = 4 if they go back to last user
181
- # Actually: pops from end until user is found
182
- # done (assistant) → tool → reading file (assistant) → fix the bug (user) = 4
183
- assert removed == 4
184
- assert len(session.history) == 0
185
-
186
- def test_undo_empty_history(self):
187
- from aru.session import Session
188
- session = Session()
189
- removed = session.undo_last_turn()
190
- assert removed == 0
1
+ """Tests for the checkpoint/undo system."""
2
+
3
+ import os
4
+ import tempfile
5
+
6
+ import pytest
7
+
8
+ from aru.checkpoints import CheckpointManager
9
+
10
+
11
+ @pytest.fixture
12
+ def tmp_workspace(tmp_path):
13
+ """Create a temporary workspace with some files."""
14
+ (tmp_path / "hello.py").write_text("print('hello')\n")
15
+ (tmp_path / "config.json").write_text('{"key": "value"}\n')
16
+ return tmp_path
17
+
18
+
19
+ @pytest.fixture
20
+ def manager(tmp_path):
21
+ """Create a CheckpointManager with temp backup dir."""
22
+ backup_dir = str(tmp_path / "backups")
23
+ return CheckpointManager("test-session", base_dir=backup_dir)
24
+
25
+
26
+ class TestCheckpointManager:
27
+ def test_begin_turn_creates_snapshot(self, manager):
28
+ manager.begin_turn(1)
29
+ assert manager.get_snapshot_count() == 1
30
+
31
+ def test_track_edit_captures_file_state(self, manager, tmp_workspace):
32
+ manager.begin_turn(1)
33
+ file_path = str(tmp_workspace / "hello.py")
34
+ manager.track_edit(file_path)
35
+
36
+ affected = manager.get_last_snapshot_files()
37
+ assert os.path.abspath(file_path) in affected
38
+
39
+ def test_track_edit_idempotent_within_turn(self, manager, tmp_workspace):
40
+ manager.begin_turn(1)
41
+ file_path = str(tmp_workspace / "hello.py")
42
+ manager.track_edit(file_path)
43
+ manager.track_edit(file_path) # should be no-op
44
+
45
+ affected = manager.get_last_snapshot_files()
46
+ assert len(affected) == 1
47
+
48
+ def test_undo_restores_edited_file(self, manager, tmp_workspace):
49
+ file_path = str(tmp_workspace / "hello.py")
50
+ original_content = "print('hello')\n"
51
+
52
+ manager.begin_turn(1)
53
+ manager.track_edit(file_path)
54
+
55
+ # Simulate edit
56
+ with open(file_path, "w") as f:
57
+ f.write("print('CHANGED')\n")
58
+ assert open(file_path).read() == "print('CHANGED')\n"
59
+
60
+ # Undo
61
+ restored, turn = manager.undo_last_turn()
62
+ assert turn == 1
63
+ assert os.path.abspath(file_path) in restored
64
+ assert open(file_path).read() == original_content
65
+
66
+ def test_undo_deletes_newly_created_file(self, manager, tmp_workspace):
67
+ new_file = str(tmp_workspace / "new_file.py")
68
+
69
+ manager.begin_turn(1)
70
+ manager.track_edit(new_file) # file doesn't exist yet
71
+
72
+ # Simulate creation
73
+ with open(new_file, "w") as f:
74
+ f.write("new content\n")
75
+ assert os.path.isfile(new_file)
76
+
77
+ # Undo should delete the file
78
+ restored, turn = manager.undo_last_turn()
79
+ assert os.path.abspath(new_file) in restored
80
+ assert not os.path.isfile(new_file)
81
+
82
+ def test_undo_multiple_files(self, manager, tmp_workspace):
83
+ file1 = str(tmp_workspace / "hello.py")
84
+ file2 = str(tmp_workspace / "config.json")
85
+
86
+ manager.begin_turn(1)
87
+ manager.track_edit(file1)
88
+ manager.track_edit(file2)
89
+
90
+ # Edit both
91
+ with open(file1, "w") as f:
92
+ f.write("changed1\n")
93
+ with open(file2, "w") as f:
94
+ f.write("changed2\n")
95
+
96
+ # Undo
97
+ restored, _ = manager.undo_last_turn()
98
+ assert len(restored) == 2
99
+ assert open(file1).read() == "print('hello')\n"
100
+ assert open(file2).read() == '{"key": "value"}\n'
101
+
102
+ def test_undo_only_affects_last_turn(self, manager, tmp_workspace):
103
+ file_path = str(tmp_workspace / "hello.py")
104
+
105
+ # Turn 1: edit file
106
+ manager.begin_turn(1)
107
+ manager.track_edit(file_path)
108
+ with open(file_path, "w") as f:
109
+ f.write("turn1\n")
110
+
111
+ # Turn 2: edit file again
112
+ manager.begin_turn(2)
113
+ manager.track_edit(file_path)
114
+ with open(file_path, "w") as f:
115
+ f.write("turn2\n")
116
+
117
+ # Undo turn 2 → should restore to turn1 state
118
+ restored, turn = manager.undo_last_turn()
119
+ assert turn == 2
120
+ assert open(file_path).read() == "turn1\n"
121
+
122
+ # Undo turn 1 → should restore to original
123
+ restored, turn = manager.undo_last_turn()
124
+ assert turn == 1
125
+ assert open(file_path).read() == "print('hello')\n"
126
+
127
+ def test_undo_empty_returns_empty(self, manager):
128
+ restored, turn = manager.undo_last_turn()
129
+ assert restored == []
130
+ assert turn == 0
131
+
132
+ def test_get_last_snapshot_files_empty(self, manager):
133
+ assert manager.get_last_snapshot_files() == []
134
+
135
+ def test_max_snapshots_enforced(self, manager, tmp_workspace):
136
+ file_path = str(tmp_workspace / "hello.py")
137
+ for i in range(105):
138
+ manager.begin_turn(i)
139
+ manager.track_edit(file_path)
140
+ with open(file_path, "w") as f:
141
+ f.write(f"v{i}\n")
142
+
143
+ assert manager.get_snapshot_count() == 100
144
+
145
+ def test_cleanup_removes_backup_dir(self, manager, tmp_workspace):
146
+ file_path = str(tmp_workspace / "hello.py")
147
+ manager.begin_turn(1)
148
+ manager.track_edit(file_path)
149
+
150
+ assert os.path.isdir(manager._base_dir)
151
+ manager.cleanup()
152
+ assert not os.path.isdir(manager._base_dir)
153
+
154
+
155
+ class TestSessionUndoLastTurn:
156
+ """Tests for Session.undo_last_turn (conversation history only)."""
157
+
158
+ def test_undo_removes_last_turn(self):
159
+ from aru.session import Session
160
+ session = Session()
161
+ session.add_message("user", "hello")
162
+ session.add_message("assistant", "hi there")
163
+ session.add_message("user", "how are you")
164
+ session.add_message("assistant", "good")
165
+
166
+ removed = session.undo_last_turn()
167
+ assert removed == 2 # user + assistant
168
+ assert len(session.history) == 2
169
+ assert session.history[-1]["role"] == "assistant"
170
+
171
+ def test_undo_removes_tool_messages(self):
172
+ from aru.session import Session
173
+ session = Session()
174
+ session.add_message("user", "fix the bug")
175
+ session.add_message("assistant", "reading file")
176
+ session.add_message("tool", "file contents here")
177
+ session.add_message("assistant", "done")
178
+
179
+ removed = session.undo_last_turn()
180
+ # Should remove: user + assistant + tool + assistant = 4 if they go back to last user
181
+ # Actually: pops from end until user is found
182
+ # done (assistant) → tool → reading file (assistant) → fix the bug (user) = 4
183
+ assert removed == 4
184
+ assert len(session.history) == 0
185
+
186
+ def test_undo_empty_history(self):
187
+ from aru.session import Session
188
+ session = Session()
189
+ removed = session.undo_last_turn()
190
+ assert removed == 0
@@ -37,16 +37,17 @@ class TestPruneHistory:
37
37
 
38
38
  def test_prunes_old_tool_results_when_over_threshold(self):
39
39
  """Should clear old tool_result content when total tool output
40
- exceeds protect + minimum (opencode-aligned budget semantics).
40
+ exceeds protect + minimum (budget semantics).
41
41
 
42
42
  The budget walks backward over tool_result content chars only.
43
43
  Text and tool_use args don't count, so this test uses large
44
44
  tool_result payloads to actually trip the prune path.
45
45
  """
46
- # Three rounds of read_file-sized outputs. Total ~300K chars
47
- # of tool_result content clears the 240K entry gate, and
48
- # the 160K protect budget will cover only the most recent one.
49
- big_output = "line of code\n" * 8_000 # ~100K chars
46
+ # Three rounds of tool outputs. Each ~30K chars, total ~90K chars.
47
+ # Entry gate: protect (55K) + minimum (20K) = 75K → 90K exceeds it.
48
+ # Protection budget (55K) covers the most recent block (30K) plus
49
+ # part of the middle, so at least tu_old gets cleared.
50
+ big_output = "line of code\n" * 2_300 # ~30K chars each
50
51
  messages = [
51
52
  {"role": "user", "content": "round 1"},
52
53
  {
@@ -97,7 +98,7 @@ class TestPruneHistory:
97
98
 
98
99
  # The older tool_result must have been cleared — at least one
99
100
  # of tu_old/tu_mid should now hold the placeholder, since only
100
- # 160K chars worth fits inside the protect window.
101
+ # 55K chars worth fits inside the protect window.
101
102
  cleared_count = sum(
102
103
  1 for tu_id in ("tu_old", "tu_mid")
103
104
  if by_id[tu_id]["content"] == CLEARED_TOOL_RESULT
@@ -1 +0,0 @@
1
- __version__ = "0.22.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
File without changes
File without changes
File without changes
File without changes
File without changes