aru-code 0.22.0__tar.gz → 0.23.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 (69) hide show
  1. {aru_code-0.22.0/aru_code.egg-info → aru_code-0.23.0}/PKG-INFO +1 -1
  2. aru_code-0.23.0/aru/__init__.py +1 -0
  3. aru_code-0.23.0/aru/agent_factory.py +131 -0
  4. {aru_code-0.22.0 → aru_code-0.23.0}/aru/checkpoints.py +189 -189
  5. {aru_code-0.22.0 → aru_code-0.23.0}/aru/context.py +3 -15
  6. {aru_code-0.22.0 → aru_code-0.23.0}/aru/plugins/__init__.py +2 -1
  7. {aru_code-0.22.0 → aru_code-0.23.0}/aru/plugins/manager.py +2 -0
  8. {aru_code-0.22.0 → aru_code-0.23.0}/aru/tools/codebase.py +13 -9
  9. {aru_code-0.22.0 → aru_code-0.23.0/aru_code.egg-info}/PKG-INFO +1 -1
  10. {aru_code-0.22.0 → aru_code-0.23.0}/aru_code.egg-info/SOURCES.txt +1 -0
  11. {aru_code-0.22.0 → aru_code-0.23.0}/pyproject.toml +1 -1
  12. {aru_code-0.22.0 → aru_code-0.23.0}/tests/test_checkpoints.py +190 -190
  13. {aru_code-0.22.0 → aru_code-0.23.0}/tests/test_context.py +6 -5
  14. aru_code-0.23.0/tests/test_guardrails_scenarios.py +199 -0
  15. aru_code-0.22.0/aru/__init__.py +0 -1
  16. aru_code-0.22.0/aru/agent_factory.py +0 -69
  17. {aru_code-0.22.0 → aru_code-0.23.0}/LICENSE +0 -0
  18. {aru_code-0.22.0 → aru_code-0.23.0}/README.md +0 -0
  19. {aru_code-0.22.0 → aru_code-0.23.0}/aru/agents/__init__.py +0 -0
  20. {aru_code-0.22.0 → aru_code-0.23.0}/aru/agents/base.py +0 -0
  21. {aru_code-0.22.0 → aru_code-0.23.0}/aru/agents/executor.py +0 -0
  22. {aru_code-0.22.0 → aru_code-0.23.0}/aru/agents/planner.py +0 -0
  23. {aru_code-0.22.0 → aru_code-0.23.0}/aru/cache_patch.py +0 -0
  24. {aru_code-0.22.0 → aru_code-0.23.0}/aru/cli.py +0 -0
  25. {aru_code-0.22.0 → aru_code-0.23.0}/aru/commands.py +0 -0
  26. {aru_code-0.22.0 → aru_code-0.23.0}/aru/completers.py +0 -0
  27. {aru_code-0.22.0 → aru_code-0.23.0}/aru/config.py +0 -0
  28. {aru_code-0.22.0 → aru_code-0.23.0}/aru/display.py +0 -0
  29. {aru_code-0.22.0 → aru_code-0.23.0}/aru/history_blocks.py +0 -0
  30. {aru_code-0.22.0 → aru_code-0.23.0}/aru/permissions.py +0 -0
  31. {aru_code-0.22.0 → aru_code-0.23.0}/aru/plugins/custom_tools.py +0 -0
  32. {aru_code-0.22.0 → aru_code-0.23.0}/aru/plugins/hooks.py +0 -0
  33. {aru_code-0.22.0 → aru_code-0.23.0}/aru/plugins/tool_api.py +0 -0
  34. {aru_code-0.22.0 → aru_code-0.23.0}/aru/providers.py +0 -0
  35. {aru_code-0.22.0 → aru_code-0.23.0}/aru/runner.py +0 -0
  36. {aru_code-0.22.0 → aru_code-0.23.0}/aru/runtime.py +0 -0
  37. {aru_code-0.22.0 → aru_code-0.23.0}/aru/session.py +0 -0
  38. {aru_code-0.22.0 → aru_code-0.23.0}/aru/tools/__init__.py +0 -0
  39. {aru_code-0.22.0 → aru_code-0.23.0}/aru/tools/ast_tools.py +0 -0
  40. {aru_code-0.22.0 → aru_code-0.23.0}/aru/tools/gitignore.py +0 -0
  41. {aru_code-0.22.0 → aru_code-0.23.0}/aru/tools/mcp_client.py +0 -0
  42. {aru_code-0.22.0 → aru_code-0.23.0}/aru/tools/ranker.py +0 -0
  43. {aru_code-0.22.0 → aru_code-0.23.0}/aru/tools/tasklist.py +0 -0
  44. {aru_code-0.22.0 → aru_code-0.23.0}/aru_code.egg-info/dependency_links.txt +0 -0
  45. {aru_code-0.22.0 → aru_code-0.23.0}/aru_code.egg-info/entry_points.txt +0 -0
  46. {aru_code-0.22.0 → aru_code-0.23.0}/aru_code.egg-info/requires.txt +0 -0
  47. {aru_code-0.22.0 → aru_code-0.23.0}/aru_code.egg-info/top_level.txt +0 -0
  48. {aru_code-0.22.0 → aru_code-0.23.0}/setup.cfg +0 -0
  49. {aru_code-0.22.0 → aru_code-0.23.0}/tests/test_agents_base.py +0 -0
  50. {aru_code-0.22.0 → aru_code-0.23.0}/tests/test_cli.py +0 -0
  51. {aru_code-0.22.0 → aru_code-0.23.0}/tests/test_cli_advanced.py +0 -0
  52. {aru_code-0.22.0 → aru_code-0.23.0}/tests/test_cli_base.py +0 -0
  53. {aru_code-0.22.0 → aru_code-0.23.0}/tests/test_cli_completers.py +0 -0
  54. {aru_code-0.22.0 → aru_code-0.23.0}/tests/test_cli_new.py +0 -0
  55. {aru_code-0.22.0 → aru_code-0.23.0}/tests/test_cli_run_cli.py +0 -0
  56. {aru_code-0.22.0 → aru_code-0.23.0}/tests/test_cli_session.py +0 -0
  57. {aru_code-0.22.0 → aru_code-0.23.0}/tests/test_cli_shell.py +0 -0
  58. {aru_code-0.22.0 → aru_code-0.23.0}/tests/test_codebase.py +0 -0
  59. {aru_code-0.22.0 → aru_code-0.23.0}/tests/test_confabulation_regression.py +0 -0
  60. {aru_code-0.22.0 → aru_code-0.23.0}/tests/test_config.py +0 -0
  61. {aru_code-0.22.0 → aru_code-0.23.0}/tests/test_executor.py +0 -0
  62. {aru_code-0.22.0 → aru_code-0.23.0}/tests/test_gitignore.py +0 -0
  63. {aru_code-0.22.0 → aru_code-0.23.0}/tests/test_main.py +0 -0
  64. {aru_code-0.22.0 → aru_code-0.23.0}/tests/test_mcp_client.py +0 -0
  65. {aru_code-0.22.0 → aru_code-0.23.0}/tests/test_permissions.py +0 -0
  66. {aru_code-0.22.0 → aru_code-0.23.0}/tests/test_planner.py +0 -0
  67. {aru_code-0.22.0 → aru_code-0.23.0}/tests/test_plugins.py +0 -0
  68. {aru_code-0.22.0 → aru_code-0.23.0}/tests/test_providers.py +0 -0
  69. {aru_code-0.22.0 → aru_code-0.23.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.22.0
3
+ Version: 0.23.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.23.0"
@@ -0,0 +1,131 @@
1
+ """Agent creation: general-purpose and custom agent instantiation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import functools
6
+ import inspect
7
+ import logging
8
+
9
+ from aru.agents.base import build_instructions as _build_instructions
10
+ from aru.config import AgentConfig, CustomAgent
11
+ from aru.providers import create_model
12
+ from aru.session import Session
13
+
14
+ logger = logging.getLogger("aru.agent_factory")
15
+
16
+
17
+ def _wrap_tools_with_hooks(tools: list) -> list:
18
+ """Wrap tool functions to fire tool.execute.before/after plugin hooks.
19
+
20
+ Before hook can mutate args; after hook can mutate the result.
21
+ If a before hook raises, the tool is not executed and the error is returned.
22
+ """
23
+ from aru.runtime import get_ctx
24
+
25
+ async def _fire(event_name: str, data: dict) -> dict:
26
+ try:
27
+ ctx = get_ctx()
28
+ mgr = ctx.plugin_manager
29
+ if mgr is not None and mgr.loaded:
30
+ event = await mgr.fire(event_name, data)
31
+ return event.data
32
+ except (LookupError, AttributeError):
33
+ pass
34
+ return data
35
+
36
+ def _wrap_one(fn):
37
+ if not callable(fn) or getattr(fn, "_hook_wrapped", False):
38
+ return fn
39
+
40
+ @functools.wraps(fn)
41
+ async def wrapper(**kwargs):
42
+ tool_name = fn.__name__
43
+ # Before hook — plugins can mutate args or raise PermissionError to block
44
+ try:
45
+ before_data = await _fire("tool.execute.before", {
46
+ "tool_name": tool_name,
47
+ "args": kwargs,
48
+ })
49
+ kwargs = before_data.get("args", kwargs)
50
+ except PermissionError as e:
51
+ return f"BLOCKED by plugin: {e}. Do NOT retry this operation."
52
+
53
+ # Execute the tool
54
+ if inspect.iscoroutinefunction(fn):
55
+ result = await fn(**kwargs)
56
+ else:
57
+ result = fn(**kwargs)
58
+
59
+ # After hook — plugins can mutate the result
60
+ after_data = await _fire("tool.execute.after", {
61
+ "tool_name": tool_name,
62
+ "args": kwargs,
63
+ "result": result,
64
+ })
65
+ return after_data.get("result", result)
66
+
67
+ wrapper._hook_wrapped = True
68
+ return wrapper
69
+
70
+ return [_wrap_one(t) for t in tools]
71
+
72
+
73
+ def create_general_agent(
74
+ session: Session,
75
+ config: AgentConfig | None = None,
76
+ model_override: str | None = None,
77
+ env_context: str = "",
78
+ ):
79
+ """Create the general-purpose agent.
80
+
81
+ Args:
82
+ env_context: Environment context (cwd, tree, git status) to include
83
+ in the system prompt. Placed in instructions so it's cacheable.
84
+ """
85
+ from agno.agent import Agent
86
+
87
+ from aru.tools.codebase import GENERAL_TOOLS
88
+ tools = _wrap_tools_with_hooks(GENERAL_TOOLS)
89
+
90
+ extra = config.get_extra_instructions() if config else ""
91
+ if env_context:
92
+ extra = f"{extra}\n\n{env_context}" if extra else env_context
93
+ model_ref = model_override or session.model_ref
94
+
95
+ return Agent(
96
+ name="Aru",
97
+ model=create_model(model_ref, max_tokens=8192),
98
+ tools=tools,
99
+ instructions=_build_instructions("general", extra),
100
+ markdown=True,
101
+ tool_call_limit=20,
102
+ )
103
+
104
+
105
+ def create_custom_agent_instance(agent_def: CustomAgent, session: Session,
106
+ config: AgentConfig | None = None,
107
+ env_context: str = ""):
108
+ """Create an Agno Agent from a CustomAgent definition."""
109
+ from agno.agent import Agent
110
+ from aru.agents.base import BASE_INSTRUCTIONS
111
+ from aru.tools.codebase import resolve_tools
112
+
113
+ model_ref = agent_def.model or session.model_ref
114
+ tools = _wrap_tools_with_hooks(resolve_tools(agent_def.tools))
115
+
116
+ extra = config.get_extra_instructions() if config else ""
117
+ if env_context:
118
+ extra = f"{extra}\n\n{env_context}" if extra else env_context
119
+ parts = [agent_def.system_prompt, BASE_INSTRUCTIONS]
120
+ if extra:
121
+ parts.append(extra)
122
+ instructions = "\n\n".join(parts)
123
+
124
+ return Agent(
125
+ name=agent_def.name,
126
+ model=create_model(model_ref, max_tokens=8192),
127
+ tools=tools,
128
+ instructions=instructions,
129
+ markdown=True,
130
+ tool_call_limit=agent_def.max_turns or 20,
131
+ )
@@ -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
@@ -48,21 +48,9 @@ TRUNCATE_MAX_LINE_LENGTH = 1500 # chars per individual line (prevents minified
48
48
  TRUNCATE_SAVE_DIR = ".aru/truncated"
49
49
 
50
50
  # Compaction: chars of recent conversation preserved verbatim post-compact.
51
- #
52
- # Separate from the prune protect window (160K) because they measure
53
- # different things:
54
- # - Prune protect: "how much tool_result content stays intact"
55
- # - Compact recent: "how much full-message history stays verbatim after
56
- # the summary replaces the older portion"
57
- #
58
- # Set to 80K chars (~20K tokens) — half the prune window. Rationale:
59
- # with the compactor now running on the main model (not a small one),
60
- # summaries are faithful enough that we don't need 40K of recent overlap
61
- # as a safety net. 20K still covers 3-6 recent turns verbatim, which
62
- # mirrors the "last few exchanges" a human would re-read to resume work.
63
- # Going to zero would match opencode exactly but requires the reactive
64
- # overflow replay flow we haven't implemented yet.
65
- COMPACT_RECENT_CHARS = 80_000
51
+ # Uses the same budget as prune protect (160K chars ≈ 40K tokens) to match
52
+ # opencode's approach where the split point mirrors the prune window.
53
+ COMPACT_RECENT_CHARS = 160_000
66
54
 
67
55
  # Compaction: trigger when per-call input tokens approach real overflow.
68
56
  # Matches opencode's philosophy: only fire near the model's actual context
@@ -8,5 +8,6 @@ Public API for plugin authors:
8
8
 
9
9
  from aru.plugins.tool_api import tool
10
10
  from aru.plugins.hooks import Hooks, HookEvent, PluginInput
11
+ from aru.plugins.manager import PluginManager
11
12
 
12
- __all__ = ["tool", "Hooks", "HookEvent", "PluginInput"]
13
+ __all__ = ["tool", "Hooks", "HookEvent", "PluginInput", "PluginManager"]
@@ -145,6 +145,8 @@ class PluginManager:
145
145
  await handler(event)
146
146
  else:
147
147
  handler(event)
148
+ except PermissionError:
149
+ raise # let blocking signals propagate
148
150
  except Exception as e:
149
151
  logger.error("Hook handler error (%s): %s", event_name, e)
150
152
 
@@ -72,15 +72,15 @@ def clear_read_cache():
72
72
  get_ctx().read_cache.clear()
73
73
 
74
74
 
75
- def read_file(file_path: str, start_line: int = 0, end_line: int = 0, max_size: int = 8_000) -> str:
75
+ def read_file(file_path: str, start_line: int = 0, end_line: int = 0, max_size: int = 12_000) -> str:
76
76
  """Read file contents. Returns chunked output for large files.
77
77
 
78
78
  Args:
79
79
  file_path: Path to the file (absolute or relative).
80
80
  start_line: First line (1-indexed, inclusive). 0 = beginning.
81
81
  end_line: Last line (1-indexed, inclusive). 0 = end.
82
- max_size: Max bytes before truncation. Default 8KB.
83
- Set to 0 to read the full file in chunks — each chunk up to ~25KB.
82
+ max_size: Max bytes before truncation. Default 12KB.
83
+ Set to 0 to read the full file in chunks — each chunk up to ~40KB.
84
84
  The first chunk includes a continuation hint so you can call again
85
85
  with start_line to get the next chunk.
86
86
  """
@@ -519,15 +519,15 @@ def glob_search(pattern: str, directory: str = ".") -> str:
519
519
  return "\n".join(matches)
520
520
 
521
521
 
522
- def grep_search(pattern: str, directory: str = ".", file_glob: str = "", context_lines: int = 5) -> str:
522
+ def grep_search(pattern: str, directory: str = ".", file_glob: str = "", context_lines: int = 10) -> str:
523
523
  """Search for a regex pattern in file contents.
524
524
 
525
525
  Args:
526
526
  pattern: Regular expression pattern to search for.
527
527
  directory: Directory to search in. Defaults to current directory.
528
528
  file_glob: Optional glob to filter which files to search (e.g. '*.py').
529
- context_lines: Lines of context before and after each match (like grep -C). Default 5.
530
- Use 0 for file-level matches only. Use 20+ for full function bodies.
529
+ context_lines: Lines of context before and after each match (like grep -C). Default 10.
530
+ Use 0 for file-level matches only. Use 30+ for full function bodies.
531
531
  """
532
532
  import re
533
533
 
@@ -918,9 +918,13 @@ async def bash(command: str, timeout: int = 60, working_directory: str = "") ->
918
918
  if not check_permission("bash", command, cmd_display):
919
919
  return f"PERMISSION DENIED by user: {command}. Do NOT retry this operation. Stop and ask the user for new instructions."
920
920
 
921
- # Fire shell.env hook — plugins can inject environment variables
922
- extra_env = await _fire_plugin_hook("shell.env", {"cwd": cwd, "command": command, "env": {}})
923
- shell_env = extra_env.get("env") if isinstance(extra_env, dict) else None
921
+ # Fire shell.env hook — plugins can inject env vars or rewrite/block the command
922
+ hook_data = await _fire_plugin_hook("shell.env", {"cwd": cwd, "command": command, "env": {}})
923
+ if isinstance(hook_data, dict):
924
+ command = hook_data.get("command", command)
925
+ shell_env = hook_data.get("env") or None
926
+ else:
927
+ shell_env = None
924
928
 
925
929
  result = await run_command(command, timeout=timeout, working_directory=working_directory, extra_env=shell_env)
926
930
  # Bash can modify files, so always invalidate cache
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aru-code
3
- Version: 0.22.0
3
+ Version: 0.23.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
@@ -55,6 +55,7 @@ tests/test_config.py
55
55
  tests/test_context.py
56
56
  tests/test_executor.py
57
57
  tests/test_gitignore.py
58
+ tests/test_guardrails_scenarios.py
58
59
  tests/test_main.py
59
60
  tests/test_mcp_client.py
60
61
  tests/test_permissions.py
@@ -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.23.0"
8
8
  description = "A Claude Code clone built with Agno agents"
9
9
  readme = "README.md"
10
10
  license = "MIT"