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.
- {aru_code-0.22.0/aru_code.egg-info → aru_code-0.23.0}/PKG-INFO +1 -1
- aru_code-0.23.0/aru/__init__.py +1 -0
- aru_code-0.23.0/aru/agent_factory.py +131 -0
- {aru_code-0.22.0 → aru_code-0.23.0}/aru/checkpoints.py +189 -189
- {aru_code-0.22.0 → aru_code-0.23.0}/aru/context.py +3 -15
- {aru_code-0.22.0 → aru_code-0.23.0}/aru/plugins/__init__.py +2 -1
- {aru_code-0.22.0 → aru_code-0.23.0}/aru/plugins/manager.py +2 -0
- {aru_code-0.22.0 → aru_code-0.23.0}/aru/tools/codebase.py +13 -9
- {aru_code-0.22.0 → aru_code-0.23.0/aru_code.egg-info}/PKG-INFO +1 -1
- {aru_code-0.22.0 → aru_code-0.23.0}/aru_code.egg-info/SOURCES.txt +1 -0
- {aru_code-0.22.0 → aru_code-0.23.0}/pyproject.toml +1 -1
- {aru_code-0.22.0 → aru_code-0.23.0}/tests/test_checkpoints.py +190 -190
- {aru_code-0.22.0 → aru_code-0.23.0}/tests/test_context.py +6 -5
- aru_code-0.23.0/tests/test_guardrails_scenarios.py +199 -0
- aru_code-0.22.0/aru/__init__.py +0 -1
- aru_code-0.22.0/aru/agent_factory.py +0 -69
- {aru_code-0.22.0 → aru_code-0.23.0}/LICENSE +0 -0
- {aru_code-0.22.0 → aru_code-0.23.0}/README.md +0 -0
- {aru_code-0.22.0 → aru_code-0.23.0}/aru/agents/__init__.py +0 -0
- {aru_code-0.22.0 → aru_code-0.23.0}/aru/agents/base.py +0 -0
- {aru_code-0.22.0 → aru_code-0.23.0}/aru/agents/executor.py +0 -0
- {aru_code-0.22.0 → aru_code-0.23.0}/aru/agents/planner.py +0 -0
- {aru_code-0.22.0 → aru_code-0.23.0}/aru/cache_patch.py +0 -0
- {aru_code-0.22.0 → aru_code-0.23.0}/aru/cli.py +0 -0
- {aru_code-0.22.0 → aru_code-0.23.0}/aru/commands.py +0 -0
- {aru_code-0.22.0 → aru_code-0.23.0}/aru/completers.py +0 -0
- {aru_code-0.22.0 → aru_code-0.23.0}/aru/config.py +0 -0
- {aru_code-0.22.0 → aru_code-0.23.0}/aru/display.py +0 -0
- {aru_code-0.22.0 → aru_code-0.23.0}/aru/history_blocks.py +0 -0
- {aru_code-0.22.0 → aru_code-0.23.0}/aru/permissions.py +0 -0
- {aru_code-0.22.0 → aru_code-0.23.0}/aru/plugins/custom_tools.py +0 -0
- {aru_code-0.22.0 → aru_code-0.23.0}/aru/plugins/hooks.py +0 -0
- {aru_code-0.22.0 → aru_code-0.23.0}/aru/plugins/tool_api.py +0 -0
- {aru_code-0.22.0 → aru_code-0.23.0}/aru/providers.py +0 -0
- {aru_code-0.22.0 → aru_code-0.23.0}/aru/runner.py +0 -0
- {aru_code-0.22.0 → aru_code-0.23.0}/aru/runtime.py +0 -0
- {aru_code-0.22.0 → aru_code-0.23.0}/aru/session.py +0 -0
- {aru_code-0.22.0 → aru_code-0.23.0}/aru/tools/__init__.py +0 -0
- {aru_code-0.22.0 → aru_code-0.23.0}/aru/tools/ast_tools.py +0 -0
- {aru_code-0.22.0 → aru_code-0.23.0}/aru/tools/gitignore.py +0 -0
- {aru_code-0.22.0 → aru_code-0.23.0}/aru/tools/mcp_client.py +0 -0
- {aru_code-0.22.0 → aru_code-0.23.0}/aru/tools/ranker.py +0 -0
- {aru_code-0.22.0 → aru_code-0.23.0}/aru/tools/tasklist.py +0 -0
- {aru_code-0.22.0 → aru_code-0.23.0}/aru_code.egg-info/dependency_links.txt +0 -0
- {aru_code-0.22.0 → aru_code-0.23.0}/aru_code.egg-info/entry_points.txt +0 -0
- {aru_code-0.22.0 → aru_code-0.23.0}/aru_code.egg-info/requires.txt +0 -0
- {aru_code-0.22.0 → aru_code-0.23.0}/aru_code.egg-info/top_level.txt +0 -0
- {aru_code-0.22.0 → aru_code-0.23.0}/setup.cfg +0 -0
- {aru_code-0.22.0 → aru_code-0.23.0}/tests/test_agents_base.py +0 -0
- {aru_code-0.22.0 → aru_code-0.23.0}/tests/test_cli.py +0 -0
- {aru_code-0.22.0 → aru_code-0.23.0}/tests/test_cli_advanced.py +0 -0
- {aru_code-0.22.0 → aru_code-0.23.0}/tests/test_cli_base.py +0 -0
- {aru_code-0.22.0 → aru_code-0.23.0}/tests/test_cli_completers.py +0 -0
- {aru_code-0.22.0 → aru_code-0.23.0}/tests/test_cli_new.py +0 -0
- {aru_code-0.22.0 → aru_code-0.23.0}/tests/test_cli_run_cli.py +0 -0
- {aru_code-0.22.0 → aru_code-0.23.0}/tests/test_cli_session.py +0 -0
- {aru_code-0.22.0 → aru_code-0.23.0}/tests/test_cli_shell.py +0 -0
- {aru_code-0.22.0 → aru_code-0.23.0}/tests/test_codebase.py +0 -0
- {aru_code-0.22.0 → aru_code-0.23.0}/tests/test_confabulation_regression.py +0 -0
- {aru_code-0.22.0 → aru_code-0.23.0}/tests/test_config.py +0 -0
- {aru_code-0.22.0 → aru_code-0.23.0}/tests/test_executor.py +0 -0
- {aru_code-0.22.0 → aru_code-0.23.0}/tests/test_gitignore.py +0 -0
- {aru_code-0.22.0 → aru_code-0.23.0}/tests/test_main.py +0 -0
- {aru_code-0.22.0 → aru_code-0.23.0}/tests/test_mcp_client.py +0 -0
- {aru_code-0.22.0 → aru_code-0.23.0}/tests/test_permissions.py +0 -0
- {aru_code-0.22.0 → aru_code-0.23.0}/tests/test_planner.py +0 -0
- {aru_code-0.22.0 → aru_code-0.23.0}/tests/test_plugins.py +0 -0
- {aru_code-0.22.0 → aru_code-0.23.0}/tests/test_providers.py +0 -0
- {aru_code-0.22.0 → aru_code-0.23.0}/tests/test_ranker.py +0 -0
|
@@ -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
|
-
#
|
|
53
|
-
|
|
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"]
|
|
@@ -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 =
|
|
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
|
|
83
|
-
Set to 0 to read the full file in chunks — each chunk up to ~
|
|
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 =
|
|
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
|
|
530
|
-
Use 0 for file-level matches only. Use
|
|
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
|
|
922
|
-
|
|
923
|
-
|
|
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
|