aru-code 0.22.1__tar.gz → 0.24.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 (70) hide show
  1. {aru_code-0.22.1/aru_code.egg-info → aru_code-0.24.0}/PKG-INFO +1 -1
  2. aru_code-0.24.0/aru/__init__.py +1 -0
  3. aru_code-0.24.0/aru/agent_factory.py +220 -0
  4. {aru_code-0.22.1 → aru_code-0.24.0}/aru/agents/base.py +32 -2
  5. aru_code-0.24.0/aru/agents/explorer.py +91 -0
  6. {aru_code-0.22.1 → aru_code-0.24.0}/aru/cache_patch.py +3 -3
  7. {aru_code-0.22.1 → aru_code-0.24.0}/aru/cli.py +49 -0
  8. {aru_code-0.22.1 → aru_code-0.24.0}/aru/config.py +1 -1
  9. {aru_code-0.22.1 → aru_code-0.24.0}/aru/context.py +17 -25
  10. {aru_code-0.22.1 → aru_code-0.24.0}/aru/display.py +18 -0
  11. {aru_code-0.22.1 → aru_code-0.24.0}/aru/plugins/__init__.py +2 -1
  12. {aru_code-0.22.1 → aru_code-0.24.0}/aru/plugins/hooks.py +73 -3
  13. {aru_code-0.22.1 → aru_code-0.24.0}/aru/plugins/manager.py +63 -1
  14. {aru_code-0.22.1 → aru_code-0.24.0}/aru/runner.py +75 -0
  15. {aru_code-0.22.1 → aru_code-0.24.0}/aru/tools/codebase.py +39 -32
  16. {aru_code-0.22.1 → aru_code-0.24.0/aru_code.egg-info}/PKG-INFO +1 -1
  17. {aru_code-0.22.1 → aru_code-0.24.0}/aru_code.egg-info/SOURCES.txt +2 -0
  18. {aru_code-0.22.1 → aru_code-0.24.0}/pyproject.toml +1 -1
  19. {aru_code-0.22.1 → aru_code-0.24.0}/tests/test_codebase.py +1 -1
  20. {aru_code-0.22.1 → aru_code-0.24.0}/tests/test_context.py +5 -5
  21. aru_code-0.24.0/tests/test_guardrails_scenarios.py +199 -0
  22. aru_code-0.22.1/aru/__init__.py +0 -1
  23. aru_code-0.22.1/aru/agent_factory.py +0 -69
  24. {aru_code-0.22.1 → aru_code-0.24.0}/LICENSE +0 -0
  25. {aru_code-0.22.1 → aru_code-0.24.0}/README.md +0 -0
  26. {aru_code-0.22.1 → aru_code-0.24.0}/aru/agents/__init__.py +0 -0
  27. {aru_code-0.22.1 → aru_code-0.24.0}/aru/agents/executor.py +0 -0
  28. {aru_code-0.22.1 → aru_code-0.24.0}/aru/agents/planner.py +0 -0
  29. {aru_code-0.22.1 → aru_code-0.24.0}/aru/checkpoints.py +0 -0
  30. {aru_code-0.22.1 → aru_code-0.24.0}/aru/commands.py +0 -0
  31. {aru_code-0.22.1 → aru_code-0.24.0}/aru/completers.py +0 -0
  32. {aru_code-0.22.1 → aru_code-0.24.0}/aru/history_blocks.py +0 -0
  33. {aru_code-0.22.1 → aru_code-0.24.0}/aru/permissions.py +0 -0
  34. {aru_code-0.22.1 → aru_code-0.24.0}/aru/plugins/custom_tools.py +0 -0
  35. {aru_code-0.22.1 → aru_code-0.24.0}/aru/plugins/tool_api.py +0 -0
  36. {aru_code-0.22.1 → aru_code-0.24.0}/aru/providers.py +0 -0
  37. {aru_code-0.22.1 → aru_code-0.24.0}/aru/runtime.py +0 -0
  38. {aru_code-0.22.1 → aru_code-0.24.0}/aru/session.py +0 -0
  39. {aru_code-0.22.1 → aru_code-0.24.0}/aru/tools/__init__.py +0 -0
  40. {aru_code-0.22.1 → aru_code-0.24.0}/aru/tools/ast_tools.py +0 -0
  41. {aru_code-0.22.1 → aru_code-0.24.0}/aru/tools/gitignore.py +0 -0
  42. {aru_code-0.22.1 → aru_code-0.24.0}/aru/tools/mcp_client.py +0 -0
  43. {aru_code-0.22.1 → aru_code-0.24.0}/aru/tools/ranker.py +0 -0
  44. {aru_code-0.22.1 → aru_code-0.24.0}/aru/tools/tasklist.py +0 -0
  45. {aru_code-0.22.1 → aru_code-0.24.0}/aru_code.egg-info/dependency_links.txt +0 -0
  46. {aru_code-0.22.1 → aru_code-0.24.0}/aru_code.egg-info/entry_points.txt +0 -0
  47. {aru_code-0.22.1 → aru_code-0.24.0}/aru_code.egg-info/requires.txt +0 -0
  48. {aru_code-0.22.1 → aru_code-0.24.0}/aru_code.egg-info/top_level.txt +0 -0
  49. {aru_code-0.22.1 → aru_code-0.24.0}/setup.cfg +0 -0
  50. {aru_code-0.22.1 → aru_code-0.24.0}/tests/test_agents_base.py +0 -0
  51. {aru_code-0.22.1 → aru_code-0.24.0}/tests/test_checkpoints.py +0 -0
  52. {aru_code-0.22.1 → aru_code-0.24.0}/tests/test_cli.py +0 -0
  53. {aru_code-0.22.1 → aru_code-0.24.0}/tests/test_cli_advanced.py +0 -0
  54. {aru_code-0.22.1 → aru_code-0.24.0}/tests/test_cli_base.py +0 -0
  55. {aru_code-0.22.1 → aru_code-0.24.0}/tests/test_cli_completers.py +0 -0
  56. {aru_code-0.22.1 → aru_code-0.24.0}/tests/test_cli_new.py +0 -0
  57. {aru_code-0.22.1 → aru_code-0.24.0}/tests/test_cli_run_cli.py +0 -0
  58. {aru_code-0.22.1 → aru_code-0.24.0}/tests/test_cli_session.py +0 -0
  59. {aru_code-0.22.1 → aru_code-0.24.0}/tests/test_cli_shell.py +0 -0
  60. {aru_code-0.22.1 → aru_code-0.24.0}/tests/test_confabulation_regression.py +0 -0
  61. {aru_code-0.22.1 → aru_code-0.24.0}/tests/test_config.py +0 -0
  62. {aru_code-0.22.1 → aru_code-0.24.0}/tests/test_executor.py +0 -0
  63. {aru_code-0.22.1 → aru_code-0.24.0}/tests/test_gitignore.py +0 -0
  64. {aru_code-0.22.1 → aru_code-0.24.0}/tests/test_main.py +0 -0
  65. {aru_code-0.22.1 → aru_code-0.24.0}/tests/test_mcp_client.py +0 -0
  66. {aru_code-0.22.1 → aru_code-0.24.0}/tests/test_permissions.py +0 -0
  67. {aru_code-0.22.1 → aru_code-0.24.0}/tests/test_planner.py +0 -0
  68. {aru_code-0.22.1 → aru_code-0.24.0}/tests/test_plugins.py +0 -0
  69. {aru_code-0.22.1 → aru_code-0.24.0}/tests/test_providers.py +0 -0
  70. {aru_code-0.22.1 → aru_code-0.24.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.1
3
+ Version: 0.24.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.24.0"
@@ -0,0 +1,220 @@
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
+ async def _fire_tool_definition(tool_name: str, description: str, parameters: dict) -> dict:
37
+ """Fire tool.definition hook — plugins can modify tool desc/params."""
38
+ try:
39
+ ctx = get_ctx()
40
+ mgr = ctx.plugin_manager
41
+ if mgr is not None and mgr.loaded:
42
+ event = await mgr.fire("tool.definition", {
43
+ "tool_name": tool_name,
44
+ "description": description,
45
+ "parameters": parameters,
46
+ })
47
+ return event.data
48
+ except (LookupError, AttributeError):
49
+ pass
50
+ return {"tool_name": tool_name, "description": description, "parameters": parameters}
51
+
52
+ def _wrap_one(fn):
53
+ if not callable(fn) or getattr(fn, "_hook_wrapped", False):
54
+ return fn
55
+
56
+ @functools.wraps(fn)
57
+ async def wrapper(**kwargs):
58
+ tool_name = fn.__name__
59
+ # Before hook — plugins can mutate args or raise PermissionError to block
60
+ try:
61
+ before_data = await _fire("tool.execute.before", {
62
+ "tool_name": tool_name,
63
+ "args": kwargs,
64
+ })
65
+ kwargs = before_data.get("args", kwargs)
66
+ except PermissionError as e:
67
+ return f"BLOCKED by plugin: {e}. Do NOT retry this operation."
68
+
69
+ # Execute the tool
70
+ if inspect.iscoroutinefunction(fn):
71
+ result = await fn(**kwargs)
72
+ else:
73
+ result = fn(**kwargs)
74
+
75
+ # After hook — plugins can mutate the result
76
+ after_data = await _fire("tool.execute.after", {
77
+ "tool_name": tool_name,
78
+ "args": kwargs,
79
+ "result": result,
80
+ })
81
+ return after_data.get("result", result)
82
+
83
+ wrapper._hook_wrapped = True
84
+ return wrapper
85
+
86
+ return [_wrap_one(t) for t in tools]
87
+
88
+
89
+ def _fire_sync_hook(event_name: str, data: dict) -> dict:
90
+ """Fire a plugin hook synchronously (for agent creation context).
91
+
92
+ Agent creation happens in sync code, so we need a sync path.
93
+ """
94
+ try:
95
+ from aru.runtime import get_ctx
96
+ ctx = get_ctx()
97
+ mgr = ctx.plugin_manager
98
+ if mgr is not None and mgr.loaded:
99
+ import asyncio
100
+ from aru.plugins.hooks import HookEvent
101
+ event = HookEvent(hook=event_name, data=data or {})
102
+ for hooks in mgr._hooks:
103
+ for handler in hooks.get_handlers(event_name):
104
+ try:
105
+ if asyncio.iscoroutinefunction(handler):
106
+ # Best-effort: try to run async handler
107
+ try:
108
+ loop = asyncio.get_running_loop()
109
+ except RuntimeError:
110
+ loop = None
111
+ if loop and loop.is_running():
112
+ # Can't await in sync context with running loop — skip
113
+ continue
114
+ else:
115
+ asyncio.run(handler(event))
116
+ else:
117
+ handler(event)
118
+ except Exception as e:
119
+ logger.warning("Hook handler error (%s): %s", event_name, e)
120
+ return event.data
121
+ except (LookupError, AttributeError):
122
+ pass
123
+ return data
124
+
125
+
126
+ def _apply_chat_hooks(instructions: str, model_ref: str, agent_name: str,
127
+ max_tokens: int = 8192) -> tuple[str, str, int]:
128
+ """Apply chat.system.transform and chat.params hooks to agent creation params.
129
+
130
+ Returns (instructions, model_ref, max_tokens) — possibly modified by plugins.
131
+ """
132
+ # chat.system.transform — plugins can modify the system prompt
133
+ data = _fire_sync_hook("chat.system.transform", {
134
+ "system_prompt": instructions,
135
+ "agent": agent_name,
136
+ })
137
+ instructions = data.get("system_prompt", instructions)
138
+
139
+ # chat.params — plugins can modify LLM parameters
140
+ data = _fire_sync_hook("chat.params", {
141
+ "model": model_ref,
142
+ "max_tokens": max_tokens,
143
+ "temperature": None, # let plugin set if desired
144
+ })
145
+ model_ref = data.get("model", model_ref)
146
+ max_tokens = data.get("max_tokens", max_tokens)
147
+
148
+ return instructions, model_ref, max_tokens
149
+
150
+
151
+ def create_general_agent(
152
+ session: Session,
153
+ config: AgentConfig | None = None,
154
+ model_override: str | None = None,
155
+ env_context: str = "",
156
+ ):
157
+ """Create the general-purpose agent.
158
+
159
+ Args:
160
+ env_context: Environment context (cwd, tree, git status) to include
161
+ in the system prompt. Placed in instructions so it's cacheable.
162
+ """
163
+ from agno.agent import Agent
164
+
165
+ from aru.tools.codebase import GENERAL_TOOLS
166
+ tools = _wrap_tools_with_hooks(GENERAL_TOOLS)
167
+
168
+ extra = config.get_extra_instructions() if config else ""
169
+ if env_context:
170
+ extra = f"{extra}\n\n{env_context}" if extra else env_context
171
+ model_ref = model_override or session.model_ref
172
+ instructions = _build_instructions("general", extra)
173
+
174
+ # Apply chat hooks (system.transform + params)
175
+ instructions, model_ref, max_tokens = _apply_chat_hooks(
176
+ instructions, model_ref, "Aru", max_tokens=8192,
177
+ )
178
+
179
+ return Agent(
180
+ name="Aru",
181
+ model=create_model(model_ref, max_tokens=max_tokens),
182
+ tools=tools,
183
+ instructions=instructions,
184
+ markdown=True,
185
+ tool_call_limit=20,
186
+ )
187
+
188
+
189
+ def create_custom_agent_instance(agent_def: CustomAgent, session: Session,
190
+ config: AgentConfig | None = None,
191
+ env_context: str = ""):
192
+ """Create an Agno Agent from a CustomAgent definition."""
193
+ from agno.agent import Agent
194
+ from aru.agents.base import BASE_INSTRUCTIONS
195
+ from aru.tools.codebase import resolve_tools
196
+
197
+ model_ref = agent_def.model or session.model_ref
198
+ tools = _wrap_tools_with_hooks(resolve_tools(agent_def.tools))
199
+
200
+ extra = config.get_extra_instructions() if config else ""
201
+ if env_context:
202
+ extra = f"{extra}\n\n{env_context}" if extra else env_context
203
+ parts = [agent_def.system_prompt, BASE_INSTRUCTIONS]
204
+ if extra:
205
+ parts.append(extra)
206
+ instructions = "\n\n".join(parts)
207
+
208
+ # Apply chat hooks (system.transform + params)
209
+ instructions, model_ref, max_tokens = _apply_chat_hooks(
210
+ instructions, model_ref, agent_def.name, max_tokens=8192,
211
+ )
212
+
213
+ return Agent(
214
+ name=agent_def.name,
215
+ model=create_model(model_ref, max_tokens=max_tokens),
216
+ tools=tools,
217
+ instructions=instructions,
218
+ markdown=True,
219
+ tool_call_limit=agent_def.max_turns or 20,
220
+ )
@@ -161,7 +161,10 @@ Use `context_lines=30` for full function bodies.
161
161
 
162
162
  **Batch independent tool calls**: emit ALL independent tool calls in a single response.
163
163
 
164
- Use delegate_task to split work into independent subtasks for parallel execution.
164
+ Use delegate_task to split work into independent subtasks for parallel execution. \
165
+ For broad codebase exploration (searching many files, finding patterns, understanding code), \
166
+ break the research into focused questions and spawn multiple \
167
+ `delegate_task(task="<specific search>", agent_name="explorer")` calls in parallel.
165
168
 
166
169
  When given a plan, execute it step by step. When given a direct task, figure out what needs to be done and do it.
167
170
  **ZERO narration between tool calls.** No "Now I have enough context...", \
@@ -208,7 +211,34 @@ Batch what you need upfront, then execute.
208
211
 
209
212
  **When adding or modifying unit tests, ALWAYS run them to verify they pass before finishing.**
210
213
 
211
- Use delegate_task to split work into independent subtasks for parallel execution.\
214
+ ## Delegation strategy CRITICAL for context efficiency
215
+
216
+ For simple, directed lookups (one known file, one specific symbol) use \
217
+ `grep_search` / `glob_search` / `read_file` directly.
218
+
219
+ For **anything broader** — understanding a system, researching before implementing, \
220
+ analyzing multiple files, writing specs or documentation — **always use explorer agents**. \
221
+ Every `read_file` / `read_file_smart` / `grep_search` result you call directly accumulates \
222
+ in YOUR context window and stays there forever. Explorer agents read files in their own \
223
+ isolated context and return only a concise summary. This is critical: \
224
+ **3 explorer summaries < 8 raw file reads** in context cost.
225
+
226
+ **Rule of thumb**: If you'd need to read or search more than 2-3 files, use explorers instead.
227
+
228
+ **Decompose, don't dump.** Never throw one vague task at one explorer. \
229
+ Break the work into **focused, independent search questions** and spawn one explorer \
230
+ per question — all in a single response so they run in parallel. Each explorer prompt \
231
+ should be specific enough that it can search and answer on its own.
232
+
233
+ Example — user asks "explain the authentication system":
234
+ ```
235
+ delegate_task(task="Find auth middleware: search for login/logout handlers, session management, token validation", agent_name="explorer")
236
+ delegate_task(task="Find auth configuration: search for auth-related config files, env vars, secrets setup", agent_name="explorer")
237
+ delegate_task(task="Find auth tests: search for test files covering authentication flows", agent_name="explorer")
238
+ ```
239
+
240
+ After all explorers return, **synthesize their findings yourself** — the user sees \
241
+ your summary, not the raw explorer output.\
212
242
  """
213
243
 
214
244
 
@@ -0,0 +1,91 @@
1
+ """Explorer agent — fast, read-only codebase exploration specialist."""
2
+
3
+ import os
4
+
5
+ from agno.agent import Agent
6
+
7
+ from aru.providers import create_model
8
+ from aru.runtime import get_ctx
9
+ from aru.tools.codebase import (
10
+ bash,
11
+ glob_search,
12
+ grep_search,
13
+ list_directory,
14
+ rank_files,
15
+ read_file,
16
+ read_file_smart,
17
+ )
18
+
19
+ # Read-only tools only — no write/edit/delegate (prevents recursion and mutations)
20
+ EXPLORER_TOOLS = [
21
+ read_file,
22
+ read_file_smart,
23
+ glob_search,
24
+ grep_search,
25
+ list_directory,
26
+ bash,
27
+ rank_files,
28
+ ]
29
+
30
+ EXPLORER_ROLE = """\
31
+ You are a file search specialist. You excel at thoroughly navigating and exploring codebases.
32
+
33
+ === CRITICAL: READ-ONLY MODE — NO FILE MODIFICATIONS ===
34
+ This is a READ-ONLY exploration task. You are STRICTLY PROHIBITED from:
35
+ - Creating new files (no write_file, touch, or file creation of any kind)
36
+ - Modifying existing files (no edit_file operations)
37
+ - Deleting files (no rm or deletion)
38
+ - Moving or copying files (no mv or cp)
39
+ - Creating temporary files anywhere, including /tmp
40
+ - Using redirect operators (>, >>, |) or heredocs to write to files
41
+ - Running ANY commands that change system state
42
+
43
+ Your role is EXCLUSIVELY to search and analyze existing code. \
44
+ You do NOT have access to file editing tools — attempting to edit files will fail.
45
+
46
+ Your strengths:
47
+ - Rapidly finding files using glob patterns
48
+ - Searching code and text with powerful regex patterns
49
+ - Reading and analyzing file contents
50
+
51
+ Guidelines:
52
+ - Use glob_search for broad file pattern matching
53
+ - Use grep_search for searching file contents with regex
54
+ - Use read_file when you know the specific file path you need to read
55
+ - Use read_file_smart when you know the file and have a specific question about it
56
+ - Use bash ONLY for read-only operations (ls, git status, git log, git diff, find, cat, head, tail)
57
+ - NEVER use bash for: mkdir, touch, rm, cp, mv, git add, git commit, npm install, pip install, \
58
+ or any file creation/modification
59
+ - Adapt your search approach based on the thoroughness level specified by the caller
60
+
61
+ NOTE: You are meant to be a FAST agent that returns output as quickly as possible. To achieve this:
62
+ - Make efficient use of tools: be smart about how you search for files and implementations
63
+ - Wherever possible, spawn MULTIPLE PARALLEL tool calls for grepping and reading files
64
+ - Do not read files you don't need — stop as soon as you have enough information
65
+
66
+ Complete the search request efficiently and report your findings clearly.\
67
+ """
68
+
69
+
70
+ def create_explorer(task: str, context: str = "") -> Agent:
71
+ """Create and return an explorer agent for a specific task.
72
+
73
+ Args:
74
+ task: The exploration task description.
75
+ context: Optional extra context (file paths, constraints).
76
+ """
77
+ cwd = os.getcwd()
78
+ small_model_ref = get_ctx().small_model_ref
79
+
80
+ instructions = EXPLORER_ROLE + f"\nThe current working directory is: {cwd}\n"
81
+ if context:
82
+ instructions += f"\nAdditional context:\n{context}\n"
83
+
84
+ return Agent(
85
+ name="Explorer",
86
+ model=create_model(small_model_ref, max_tokens=4096),
87
+ tools=EXPLORER_TOOLS,
88
+ instructions=instructions,
89
+ markdown=True,
90
+ tool_call_limit=15,
91
+ )
@@ -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
- # 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
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
28
28
  _PRUNED_PLACEHOLDER = "[Old tool result cleared]"
29
29
 
30
30
  # Last API call metrics (updated on every internal API call)
@@ -228,10 +228,20 @@ async def run_cli(skip_permissions: bool = False, resume_id: str | None = None):
228
228
  ctx.plugin_manager = _plugin_mgr
229
229
 
230
230
  try:
231
+ _config_dict = {
232
+ "default_model": config.default_model,
233
+ "model_aliases": config.model_aliases,
234
+ "permissions": config.permissions,
235
+ "plugin_specs": config.plugin_specs,
236
+ "disabled_tools": config.disabled_tools,
237
+ "plan_reviewer": config.plan_reviewer,
238
+ }
231
239
  _p_input = PluginInput(
232
240
  directory=os.getcwd(),
233
241
  config_path="aru.json" if os.path.isfile("aru.json") else "",
234
242
  model_ref=session.model_ref,
243
+ config=_config_dict,
244
+ session=session,
235
245
  )
236
246
  _plugin_specs = config.plugin_specs if hasattr(config, "plugin_specs") else []
237
247
  _plugin_count = await _plugin_mgr.load_all(_p_input, plugin_specs=_plugin_specs)
@@ -252,6 +262,17 @@ async def run_cli(skip_permissions: bool = False, resume_id: str | None = None):
252
262
 
253
263
  asyncio.create_task(_load_mcp_background())
254
264
 
265
+ # Event: session.start
266
+ if _plugin_mgr.loaded:
267
+ try:
268
+ await _plugin_mgr.publish("session.start", {
269
+ "session_id": getattr(session, "id", None),
270
+ "model_ref": session.model_ref,
271
+ "directory": os.getcwd(),
272
+ })
273
+ except Exception:
274
+ pass
275
+
255
276
  while True:
256
277
  try:
257
278
  paste_state.clear()
@@ -393,6 +414,14 @@ async def run_cli(skip_permissions: bool = False, resume_id: str | None = None):
393
414
  continue
394
415
 
395
416
  if user_input.lower() in ("/quit", "/exit", "quit", "exit"):
417
+ # Event: session.end
418
+ if _plugin_mgr.loaded:
419
+ try:
420
+ await _plugin_mgr.publish("session.end", {
421
+ "session_id": getattr(session, "id", None),
422
+ })
423
+ except Exception:
424
+ pass
396
425
  store.save(session)
397
426
  console.print(f"\n[dim]Session saved: {session.session_id}[/dim]")
398
427
  console.print(f"[dim]Resume with:[/dim] [bold cyan]aru --resume {session.session_id}[/bold cyan]")
@@ -579,6 +608,26 @@ async def run_cli(skip_permissions: bool = False, resume_id: str | None = None):
579
608
  cmd_name = parts[0].lower()
580
609
  cmd_args = parts[1] if len(parts) > 1 else ""
581
610
 
611
+ # Hook: command.execute.before — plugins can block or modify
612
+ _cmd_blocked = False
613
+ try:
614
+ _mgr = ctx.plugin_manager
615
+ if _mgr is not None and _mgr.loaded:
616
+ _cmd_event = await _mgr.fire("command.execute.before", {
617
+ "command": cmd_name,
618
+ "command_args": cmd_args,
619
+ "blocked": False,
620
+ })
621
+ if _cmd_event.data.get("blocked"):
622
+ console.print(f"[yellow]Command /{cmd_name} blocked by plugin.[/yellow]")
623
+ _cmd_blocked = True
624
+ else:
625
+ cmd_args = _cmd_event.data.get("command_args", cmd_args)
626
+ except Exception:
627
+ pass
628
+ if _cmd_blocked:
629
+ continue
630
+
582
631
  if cmd_name in config.commands:
583
632
  cmd_def = config.commands[cmd_name]
584
633
  prompt = render_command_template(cmd_def.template, cmd_args)
@@ -552,7 +552,7 @@ def load_config(cwd: str | None = None) -> AgentConfig:
552
552
  # Load config: global (~/.aru/config.json) first, then project-level on top.
553
553
  # Project values override global values via deep merge.
554
554
  home = Path.home()
555
- global_config_paths = [home / ".aru" / "config.json"]
555
+ global_config_paths = [home / ".aru" / "aru.json", home / ".aru" / "config.json"]
556
556
  project_config_paths = [root / "aru.json", root / ".aru" / "config.json"]
557
557
 
558
558
  merged_data: dict = {}
@@ -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
- # Lower than opencode's 20K tokens to fire early and keep context ~30K tokens.
28
- PRUNE_MINIMUM_CHARS = 20_000 # ~5K tokens
27
+ # Matches opencode's PRUNE_MINIMUM = 20_000 tokens (~80K chars @ 4 chars/token).
28
+ PRUNE_MINIMUM_CHARS = 80_000 # ~20K 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.
@@ -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
@@ -177,20 +165,24 @@ def _tool_result_content_len(msg: dict) -> int:
177
165
 
178
166
 
179
167
  def _get_prune_protect_chars(model_id: str = "default") -> int:
180
- """Chars of recent tool-result content that must NEVER be pruned.
168
+ """Chars of recent history that must NEVER be pruned.
169
+
170
+ Flat value across all models, mirroring opencode's fixed
171
+ `PRUNE_PROTECT = 40_000` tokens (compaction.ts:36). At ~4 chars/token
172
+ that's 160K chars of tool-result content kept intact in the recent
173
+ window. Older tool_result blocks beyond this budget are eligible for
174
+ the lossy clear pass in `prune_history`.
181
175
 
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.
176
+ Why flat (not scaled by model): opencode validated this in production
177
+ on contexts from 128K to 1M scaling by ratio adds complexity without
178
+ improving behavior, and protecting too much in 1M-context models can
179
+ actually hurt prompt caching by keeping rarely-touched tail content warm.
188
180
 
189
181
  The `model_id` parameter is retained for signature compatibility with
190
182
  older call sites; it has no effect on the returned value.
191
183
  """
192
184
  del model_id # unused — kept for signature compatibility
193
- return 55_000
185
+ return 160_000
194
186
 
195
187
 
196
188
  def prune_history(
@@ -204,6 +204,7 @@ TOOL_DISPLAY_NAMES = {
204
204
  "list_directory": "List",
205
205
  "bash": "Bash",
206
206
  "rank_files": "Rank",
207
+ "delegate_task": "Agent",
207
208
  }
208
209
 
209
210
  TOOL_PRIMARY_ARG = {
@@ -216,6 +217,12 @@ TOOL_PRIMARY_ARG = {
216
217
  "list_directory": "directory",
217
218
  "bash": "command",
218
219
  "rank_files": "task",
220
+ "delegate_task": "task",
221
+ }
222
+
223
+ # Agent type display names for delegate_task
224
+ _AGENT_TYPE_LABELS = {
225
+ "explorer": "Explorer",
219
226
  }
220
227
 
221
228
 
@@ -225,6 +232,17 @@ def _format_tool_label(tool_name: str, tool_args: dict | None) -> str:
225
232
  if not tool_args:
226
233
  return display
227
234
 
235
+ # Special handling for delegate_task — show agent type and task summary
236
+ if tool_name == "delegate_task":
237
+ agent = str(tool_args.get("agent_name", "") or tool_args.get("agent", ""))
238
+ agent_label = _AGENT_TYPE_LABELS.get(agent, agent.title() if agent else "SubAgent")
239
+ task = str(tool_args.get("task", ""))
240
+ # Extract first meaningful line/sentence as summary
241
+ summary = task.split("\n")[0].strip()
242
+ if len(summary) > 60:
243
+ summary = summary[:57] + "..."
244
+ return f"{agent_label}({summary})" if summary else agent_label
245
+
228
246
  primary_key = TOOL_PRIMARY_ARG.get(tool_name)
229
247
  if primary_key and primary_key in tool_args:
230
248
  value = str(tool_args[primary_key])
@@ -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"]