aru-code 0.19.2__tar.gz → 0.20.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.
- {aru_code-0.19.2/aru_code.egg-info → aru_code-0.20.1}/PKG-INFO +42 -3
- {aru_code-0.19.2 → aru_code-0.20.1}/README.md +41 -2
- aru_code-0.20.1/aru/__init__.py +1 -0
- {aru_code-0.19.2 → aru_code-0.20.1}/aru/agents/base.py +6 -0
- {aru_code-0.19.2 → aru_code-0.20.1}/aru/cli.py +32 -0
- {aru_code-0.19.2 → aru_code-0.20.1}/aru/config.py +80 -38
- {aru_code-0.19.2 → aru_code-0.20.1}/aru/context.py +42 -13
- {aru_code-0.19.2 → aru_code-0.20.1}/aru/permissions.py +49 -1
- aru_code-0.20.1/aru/plugins/__init__.py +12 -0
- aru_code-0.20.1/aru/plugins/custom_tools.py +350 -0
- aru_code-0.20.1/aru/plugins/hooks.py +134 -0
- aru_code-0.20.1/aru/plugins/manager.py +330 -0
- aru_code-0.20.1/aru/plugins/tool_api.py +54 -0
- {aru_code-0.19.2 → aru_code-0.20.1}/aru/runner.py +5 -0
- {aru_code-0.19.2 → aru_code-0.20.1}/aru/runtime.py +3 -0
- {aru_code-0.19.2 → aru_code-0.20.1}/aru/tools/codebase.py +35 -7
- {aru_code-0.19.2 → aru_code-0.20.1/aru_code.egg-info}/PKG-INFO +42 -3
- {aru_code-0.19.2 → aru_code-0.20.1}/aru_code.egg-info/SOURCES.txt +6 -0
- {aru_code-0.19.2 → aru_code-0.20.1}/pyproject.toml +1 -1
- {aru_code-0.19.2 → aru_code-0.20.1}/tests/test_cli_advanced.py +5 -3
- {aru_code-0.19.2 → aru_code-0.20.1}/tests/test_context.py +20 -17
- aru_code-0.20.1/tests/test_plugins.py +447 -0
- aru_code-0.19.2/aru/__init__.py +0 -1
- {aru_code-0.19.2 → aru_code-0.20.1}/LICENSE +0 -0
- {aru_code-0.19.2 → aru_code-0.20.1}/aru/agent_factory.py +0 -0
- {aru_code-0.19.2 → aru_code-0.20.1}/aru/agents/__init__.py +0 -0
- {aru_code-0.19.2 → aru_code-0.20.1}/aru/agents/executor.py +0 -0
- {aru_code-0.19.2 → aru_code-0.20.1}/aru/agents/planner.py +0 -0
- {aru_code-0.19.2 → aru_code-0.20.1}/aru/cache_patch.py +0 -0
- {aru_code-0.19.2 → aru_code-0.20.1}/aru/commands.py +0 -0
- {aru_code-0.19.2 → aru_code-0.20.1}/aru/completers.py +0 -0
- {aru_code-0.19.2 → aru_code-0.20.1}/aru/display.py +0 -0
- {aru_code-0.19.2 → aru_code-0.20.1}/aru/history_blocks.py +0 -0
- {aru_code-0.19.2 → aru_code-0.20.1}/aru/providers.py +0 -0
- {aru_code-0.19.2 → aru_code-0.20.1}/aru/session.py +0 -0
- {aru_code-0.19.2 → aru_code-0.20.1}/aru/tools/__init__.py +0 -0
- {aru_code-0.19.2 → aru_code-0.20.1}/aru/tools/ast_tools.py +0 -0
- {aru_code-0.19.2 → aru_code-0.20.1}/aru/tools/gitignore.py +0 -0
- {aru_code-0.19.2 → aru_code-0.20.1}/aru/tools/mcp_client.py +0 -0
- {aru_code-0.19.2 → aru_code-0.20.1}/aru/tools/ranker.py +0 -0
- {aru_code-0.19.2 → aru_code-0.20.1}/aru/tools/tasklist.py +0 -0
- {aru_code-0.19.2 → aru_code-0.20.1}/aru_code.egg-info/dependency_links.txt +0 -0
- {aru_code-0.19.2 → aru_code-0.20.1}/aru_code.egg-info/entry_points.txt +0 -0
- {aru_code-0.19.2 → aru_code-0.20.1}/aru_code.egg-info/requires.txt +0 -0
- {aru_code-0.19.2 → aru_code-0.20.1}/aru_code.egg-info/top_level.txt +0 -0
- {aru_code-0.19.2 → aru_code-0.20.1}/setup.cfg +0 -0
- {aru_code-0.19.2 → aru_code-0.20.1}/tests/test_agents_base.py +0 -0
- {aru_code-0.19.2 → aru_code-0.20.1}/tests/test_cli.py +0 -0
- {aru_code-0.19.2 → aru_code-0.20.1}/tests/test_cli_base.py +0 -0
- {aru_code-0.19.2 → aru_code-0.20.1}/tests/test_cli_completers.py +0 -0
- {aru_code-0.19.2 → aru_code-0.20.1}/tests/test_cli_new.py +0 -0
- {aru_code-0.19.2 → aru_code-0.20.1}/tests/test_cli_run_cli.py +0 -0
- {aru_code-0.19.2 → aru_code-0.20.1}/tests/test_cli_session.py +0 -0
- {aru_code-0.19.2 → aru_code-0.20.1}/tests/test_cli_shell.py +0 -0
- {aru_code-0.19.2 → aru_code-0.20.1}/tests/test_codebase.py +0 -0
- {aru_code-0.19.2 → aru_code-0.20.1}/tests/test_confabulation_regression.py +0 -0
- {aru_code-0.19.2 → aru_code-0.20.1}/tests/test_config.py +0 -0
- {aru_code-0.19.2 → aru_code-0.20.1}/tests/test_executor.py +0 -0
- {aru_code-0.19.2 → aru_code-0.20.1}/tests/test_gitignore.py +0 -0
- {aru_code-0.19.2 → aru_code-0.20.1}/tests/test_main.py +0 -0
- {aru_code-0.19.2 → aru_code-0.20.1}/tests/test_mcp_client.py +0 -0
- {aru_code-0.19.2 → aru_code-0.20.1}/tests/test_permissions.py +0 -0
- {aru_code-0.19.2 → aru_code-0.20.1}/tests/test_planner.py +0 -0
- {aru_code-0.19.2 → aru_code-0.20.1}/tests/test_providers.py +0 -0
- {aru_code-0.19.2 → aru_code-0.20.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.
|
|
3
|
+
Version: 0.20.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
|
|
@@ -312,8 +312,47 @@ Without any `aru.json` config, aru applies safe defaults:
|
|
|
312
312
|
- Bash → ~40 safe command prefixes auto-allowed (`ls`, `git status`, `grep`, etc.), rest → `ask`
|
|
313
313
|
- Sensitive files (`*.env`, `*.env.*`) → `deny` for read/edit/write (except `*.env.example`)
|
|
314
314
|
|
|
315
|
-
|
|
316
|
-
|
|
315
|
+
#### Config file locations
|
|
316
|
+
|
|
317
|
+
Aru loads configuration from two levels, with project settings overriding global ones:
|
|
318
|
+
|
|
319
|
+
| Level | Path | Purpose |
|
|
320
|
+
|-------|------|---------|
|
|
321
|
+
| **Global (user)** | `~/.aru/config.json` | Defaults that apply to all projects (model, aliases, permissions, providers) |
|
|
322
|
+
| **Project** | `aru.json` or `.aru/config.json` | Project-specific overrides |
|
|
323
|
+
|
|
324
|
+
Global config is loaded first, then the project config is **deep-merged** on top — scalar values and lists are replaced, nested objects (like `permission`, `providers`, `model_aliases`) are merged recursively. This means you can set your preferred model and aliases globally and only override what's different per project.
|
|
325
|
+
|
|
326
|
+
**Example `~/.aru/config.json`:**
|
|
327
|
+
|
|
328
|
+
```json
|
|
329
|
+
{
|
|
330
|
+
"default_model": "anthropic/claude-sonnet-4-6",
|
|
331
|
+
"model_aliases": {
|
|
332
|
+
"sonnet": "anthropic/claude-sonnet-4-6",
|
|
333
|
+
"opus": "anthropic/claude-opus-4-6"
|
|
334
|
+
},
|
|
335
|
+
"permission": {
|
|
336
|
+
"read": "allow",
|
|
337
|
+
"glob": "allow",
|
|
338
|
+
"grep": "allow"
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
Then a project `aru.json` only needs project-specific settings:
|
|
344
|
+
|
|
345
|
+
```json
|
|
346
|
+
{
|
|
347
|
+
"default_model": "ollama/codellama",
|
|
348
|
+
"permission": {
|
|
349
|
+
"bash": { "pytest *": "allow" }
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
The result: `default_model` becomes `ollama/codellama`, `model_aliases` come from global, and `permission` merges both levels (`read`, `glob`, `grep` from global + `bash` from project).
|
|
355
|
+
|
|
317
356
|
> A full `aru.json` config reference here: [`aru.json`](./aru.json)
|
|
318
357
|
|
|
319
358
|
### AGENTS.md
|
|
@@ -265,8 +265,47 @@ Without any `aru.json` config, aru applies safe defaults:
|
|
|
265
265
|
- Bash → ~40 safe command prefixes auto-allowed (`ls`, `git status`, `grep`, etc.), rest → `ask`
|
|
266
266
|
- Sensitive files (`*.env`, `*.env.*`) → `deny` for read/edit/write (except `*.env.example`)
|
|
267
267
|
|
|
268
|
-
|
|
269
|
-
|
|
268
|
+
#### Config file locations
|
|
269
|
+
|
|
270
|
+
Aru loads configuration from two levels, with project settings overriding global ones:
|
|
271
|
+
|
|
272
|
+
| Level | Path | Purpose |
|
|
273
|
+
|-------|------|---------|
|
|
274
|
+
| **Global (user)** | `~/.aru/config.json` | Defaults that apply to all projects (model, aliases, permissions, providers) |
|
|
275
|
+
| **Project** | `aru.json` or `.aru/config.json` | Project-specific overrides |
|
|
276
|
+
|
|
277
|
+
Global config is loaded first, then the project config is **deep-merged** on top — scalar values and lists are replaced, nested objects (like `permission`, `providers`, `model_aliases`) are merged recursively. This means you can set your preferred model and aliases globally and only override what's different per project.
|
|
278
|
+
|
|
279
|
+
**Example `~/.aru/config.json`:**
|
|
280
|
+
|
|
281
|
+
```json
|
|
282
|
+
{
|
|
283
|
+
"default_model": "anthropic/claude-sonnet-4-6",
|
|
284
|
+
"model_aliases": {
|
|
285
|
+
"sonnet": "anthropic/claude-sonnet-4-6",
|
|
286
|
+
"opus": "anthropic/claude-opus-4-6"
|
|
287
|
+
},
|
|
288
|
+
"permission": {
|
|
289
|
+
"read": "allow",
|
|
290
|
+
"glob": "allow",
|
|
291
|
+
"grep": "allow"
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
Then a project `aru.json` only needs project-specific settings:
|
|
297
|
+
|
|
298
|
+
```json
|
|
299
|
+
{
|
|
300
|
+
"default_model": "ollama/codellama",
|
|
301
|
+
"permission": {
|
|
302
|
+
"bash": { "pytest *": "allow" }
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
The result: `default_model` becomes `ollama/codellama`, `model_aliases` come from global, and `permission` merges both levels (`read`, `glob`, `grep` from global + `bash` from project).
|
|
308
|
+
|
|
270
309
|
> A full `aru.json` config reference here: [`aru.json`](./aru.json)
|
|
271
310
|
|
|
272
311
|
### AGENTS.md
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.20.1"
|
|
@@ -21,6 +21,12 @@ Examples of ideal responses:
|
|
|
21
21
|
- user: "what command lists files?" → assistant: "ls"
|
|
22
22
|
- user: "fix the typo in line 5" → [call edit_file immediately, no narration]
|
|
23
23
|
|
|
24
|
+
## Permission denials — CRITICAL
|
|
25
|
+
|
|
26
|
+
When a tool returns "PERMISSION DENIED", the user intentionally refused the action. \
|
|
27
|
+
NEVER retry the same operation. Do NOT try alternative approaches to achieve the same edit. \
|
|
28
|
+
Instead, stop immediately and ask the user what they would like you to do instead.
|
|
29
|
+
|
|
24
30
|
## Scope rules
|
|
25
31
|
|
|
26
32
|
NEVER create documentation files (*.md) unless the user explicitly asks for them.
|
|
@@ -208,6 +208,38 @@ async def run_cli(skip_permissions: bool = False, resume_id: str | None = None):
|
|
|
208
208
|
paste_state = PasteState()
|
|
209
209
|
prompt_session = _create_prompt_session(paste_state, config)
|
|
210
210
|
|
|
211
|
+
# Load custom tools (synchronous — fast, no network)
|
|
212
|
+
from aru.plugins.custom_tools import discover_custom_tools, register_custom_tools
|
|
213
|
+
_disabled_tools = config.disabled_tools if hasattr(config, "disabled_tools") else []
|
|
214
|
+
_custom_tool_descs = discover_custom_tools(disabled=_disabled_tools)
|
|
215
|
+
if _custom_tool_descs:
|
|
216
|
+
_ct_count = register_custom_tools(_custom_tool_descs)
|
|
217
|
+
console.print(f"[dim]Loaded {_ct_count} custom tool(s): {', '.join(d['name'] for d in _custom_tool_descs)}[/dim]")
|
|
218
|
+
|
|
219
|
+
# Load plugins (local imports only, no network)
|
|
220
|
+
from aru.plugins.manager import PluginManager
|
|
221
|
+
from aru.plugins.hooks import PluginInput
|
|
222
|
+
_plugin_mgr = PluginManager()
|
|
223
|
+
ctx.plugin_manager = _plugin_mgr
|
|
224
|
+
|
|
225
|
+
try:
|
|
226
|
+
_p_input = PluginInput(
|
|
227
|
+
directory=os.getcwd(),
|
|
228
|
+
config_path="aru.json" if os.path.isfile("aru.json") else "",
|
|
229
|
+
model_ref=session.model_ref,
|
|
230
|
+
)
|
|
231
|
+
_plugin_specs = config.plugin_specs if hasattr(config, "plugin_specs") else []
|
|
232
|
+
_plugin_count = await _plugin_mgr.load_all(_p_input, plugin_specs=_plugin_specs)
|
|
233
|
+
if _plugin_count:
|
|
234
|
+
plugin_tools = _plugin_mgr.get_plugin_tools()
|
|
235
|
+
if plugin_tools:
|
|
236
|
+
_pt_count = register_custom_tools(plugin_tools)
|
|
237
|
+
console.print(f"[dim]Loaded {_plugin_count} plugin(s): {', '.join(_plugin_mgr.plugin_names)} ({_pt_count} tool(s))[/dim]")
|
|
238
|
+
else:
|
|
239
|
+
console.print(f"[dim]Loaded {_plugin_count} plugin(s): {', '.join(_plugin_mgr.plugin_names)}[/dim]")
|
|
240
|
+
except Exception as exc:
|
|
241
|
+
console.print(f"[dim yellow]Warning: plugin loading failed: {exc}[/dim yellow]")
|
|
242
|
+
|
|
211
243
|
# Startup: load MCP tools in background (don't block REPL)
|
|
212
244
|
async def _load_mcp_background():
|
|
213
245
|
from aru.tools.codebase import load_mcp_tools
|
|
@@ -161,6 +161,8 @@ class AgentConfig:
|
|
|
161
161
|
custom_agents: dict[str, CustomAgent] = field(default_factory=dict)
|
|
162
162
|
plan_reviewer: bool = True
|
|
163
163
|
tree_depth: int = 2 # max depth for directory tree in system prompt
|
|
164
|
+
disabled_tools: list[str] = field(default_factory=list) # tools to skip loading
|
|
165
|
+
plugin_specs: list = field(default_factory=list) # plugin specs from aru.json
|
|
164
166
|
|
|
165
167
|
@property
|
|
166
168
|
def has_instructions(self) -> bool:
|
|
@@ -434,6 +436,63 @@ def _discover_agents(search_roots: list[Path]) -> dict[str, CustomAgent]:
|
|
|
434
436
|
return agents
|
|
435
437
|
|
|
436
438
|
|
|
439
|
+
def _load_json_file(path: Path) -> dict | None:
|
|
440
|
+
"""Read and parse a JSON file, returning None on any error."""
|
|
441
|
+
if not path.is_file():
|
|
442
|
+
return None
|
|
443
|
+
try:
|
|
444
|
+
content = path.read_text(encoding="utf-8")
|
|
445
|
+
data = json.loads(content)
|
|
446
|
+
return data if isinstance(data, dict) else None
|
|
447
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
|
448
|
+
return None
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _deep_merge(base: dict, override: dict) -> dict:
|
|
452
|
+
"""Recursively merge override into base. Override values win for scalars;
|
|
453
|
+
dicts are merged recursively; lists are replaced (not concatenated)."""
|
|
454
|
+
result = base.copy()
|
|
455
|
+
for key, value in override.items():
|
|
456
|
+
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
|
|
457
|
+
result[key] = _deep_merge(result[key], value)
|
|
458
|
+
else:
|
|
459
|
+
result[key] = value
|
|
460
|
+
return result
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _apply_config_data(config: AgentConfig, data: dict, root: Path) -> None:
|
|
464
|
+
"""Apply a merged config dict to an AgentConfig object."""
|
|
465
|
+
if "permission" in data:
|
|
466
|
+
config.permissions = data["permission"]
|
|
467
|
+
if "providers" in data:
|
|
468
|
+
from aru.providers import load_providers_from_config
|
|
469
|
+
load_providers_from_config(data)
|
|
470
|
+
if "default_model" in data:
|
|
471
|
+
config.default_model = data["default_model"]
|
|
472
|
+
if "model_aliases" in data and isinstance(data["model_aliases"], dict):
|
|
473
|
+
config.model_aliases = data["model_aliases"]
|
|
474
|
+
if "plan_reviewer" in data:
|
|
475
|
+
config.plan_reviewer = bool(data["plan_reviewer"])
|
|
476
|
+
if "tree_depth" in data:
|
|
477
|
+
td = data["tree_depth"]
|
|
478
|
+
if isinstance(td, int) and 0 <= td <= 5:
|
|
479
|
+
config.tree_depth = td
|
|
480
|
+
if "plugins" in data and isinstance(data["plugins"], list):
|
|
481
|
+
config.plugin_specs = data["plugins"]
|
|
482
|
+
if "tools" in data and isinstance(data["tools"], dict):
|
|
483
|
+
tools_cfg = data["tools"]
|
|
484
|
+
if "disabled" in tools_cfg and isinstance(tools_cfg["disabled"], list):
|
|
485
|
+
config.disabled_tools = [str(t) for t in tools_cfg["disabled"]]
|
|
486
|
+
if "instructions" in data and isinstance(data["instructions"], list):
|
|
487
|
+
entries = [str(e) for e in data["instructions"] if isinstance(e, str)]
|
|
488
|
+
config.rules_instructions = _resolve_instructions(entries, root)
|
|
489
|
+
if "agent" in data and isinstance(data["agent"], dict):
|
|
490
|
+
for agent_name, agent_data in data["agent"].items():
|
|
491
|
+
if agent_name in config.custom_agents and isinstance(agent_data, dict):
|
|
492
|
+
if "permission" in agent_data:
|
|
493
|
+
config.custom_agents[agent_name].permission = agent_data["permission"]
|
|
494
|
+
|
|
495
|
+
|
|
437
496
|
def load_config(cwd: str | None = None) -> AgentConfig:
|
|
438
497
|
"""Load agent configuration from AGENTS.md and .agents/ directory.
|
|
439
498
|
|
|
@@ -490,44 +549,27 @@ def load_config(cwd: str | None = None) -> AgentConfig:
|
|
|
490
549
|
config.skills = _discover_skills(skill_roots)
|
|
491
550
|
config.custom_agents = _discover_agents(skill_roots)
|
|
492
551
|
|
|
493
|
-
# Load
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
if "tree_depth" in data:
|
|
515
|
-
td = data["tree_depth"]
|
|
516
|
-
if isinstance(td, int) and 0 <= td <= 5:
|
|
517
|
-
config.tree_depth = td
|
|
518
|
-
# Resolve instructions (local files, globs, URLs)
|
|
519
|
-
if "instructions" in data and isinstance(data["instructions"], list):
|
|
520
|
-
entries = [str(e) for e in data["instructions"] if isinstance(e, str)]
|
|
521
|
-
config.rules_instructions = _resolve_instructions(entries, root)
|
|
522
|
-
# Agent-level permission overrides from aru.json
|
|
523
|
-
if "agent" in data and isinstance(data["agent"], dict):
|
|
524
|
-
for agent_name, agent_data in data["agent"].items():
|
|
525
|
-
if agent_name in config.custom_agents and isinstance(agent_data, dict):
|
|
526
|
-
if "permission" in agent_data:
|
|
527
|
-
config.custom_agents[agent_name].permission = agent_data["permission"]
|
|
528
|
-
break
|
|
529
|
-
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
|
530
|
-
pass
|
|
552
|
+
# Load config: global (~/.aru/config.json) first, then project-level on top.
|
|
553
|
+
# Project values override global values via deep merge.
|
|
554
|
+
home = Path.home()
|
|
555
|
+
global_config_paths = [home / ".aru" / "config.json"]
|
|
556
|
+
project_config_paths = [root / "aru.json", root / ".aru" / "config.json"]
|
|
557
|
+
|
|
558
|
+
merged_data: dict = {}
|
|
559
|
+
for config_path in global_config_paths:
|
|
560
|
+
data = _load_json_file(config_path)
|
|
561
|
+
if data is not None:
|
|
562
|
+
merged_data = data
|
|
563
|
+
break
|
|
564
|
+
|
|
565
|
+
for config_path in project_config_paths:
|
|
566
|
+
data = _load_json_file(config_path)
|
|
567
|
+
if data is not None:
|
|
568
|
+
merged_data = _deep_merge(merged_data, data)
|
|
569
|
+
break
|
|
570
|
+
|
|
571
|
+
if merged_data:
|
|
572
|
+
_apply_config_data(config, merged_data, root)
|
|
531
573
|
|
|
532
574
|
return config
|
|
533
575
|
|
|
@@ -420,34 +420,52 @@ def truncate_output(
|
|
|
420
420
|
# Save full output to disk before truncating (like OpenCode)
|
|
421
421
|
saved_path = _save_truncated_output(text)
|
|
422
422
|
|
|
423
|
-
# Truncate by lines
|
|
423
|
+
# Truncate by lines — keep head + tail so summaries at the end are visible
|
|
424
424
|
if line_count > TRUNCATE_MAX_LINES:
|
|
425
425
|
head = lines[:TRUNCATE_KEEP_START]
|
|
426
|
-
|
|
426
|
+
tail = lines[-TRUNCATE_KEEP_END:]
|
|
427
|
+
omitted = line_count - TRUNCATE_KEEP_START - TRUNCATE_KEEP_END
|
|
427
428
|
hint = _build_truncation_hint(source_file, source_tool, TRUNCATE_KEEP_START, saved_path)
|
|
428
429
|
return (
|
|
429
430
|
"".join(head)
|
|
430
431
|
+ f"\n\n[... {omitted:,} lines omitted ({line_count:,} total)]\n"
|
|
431
|
-
+ hint + "\n"
|
|
432
|
+
+ hint + "\n\n"
|
|
433
|
+
+ "".join(tail)
|
|
432
434
|
)
|
|
433
435
|
|
|
434
436
|
# Truncate by bytes (lines fit but total bytes too large)
|
|
435
|
-
|
|
436
|
-
|
|
437
|
+
# Reserve ~20% of budget for tail lines
|
|
438
|
+
head_budget = int(TRUNCATE_MAX_BYTES * 0.75)
|
|
439
|
+
tail_budget = TRUNCATE_MAX_BYTES - head_budget
|
|
440
|
+
|
|
441
|
+
head_lines: list[str] = []
|
|
442
|
+
head_bytes = 0
|
|
437
443
|
for line in lines:
|
|
438
444
|
line_bytes = len(line.encode("utf-8", errors="replace"))
|
|
439
|
-
if
|
|
445
|
+
if head_bytes + line_bytes > head_budget:
|
|
440
446
|
break
|
|
441
|
-
|
|
442
|
-
|
|
447
|
+
head_lines.append(line)
|
|
448
|
+
head_bytes += line_bytes
|
|
443
449
|
|
|
444
|
-
|
|
445
|
-
|
|
450
|
+
# Collect tail lines within tail budget
|
|
451
|
+
tail_lines: list[str] = []
|
|
452
|
+
tail_bytes = 0
|
|
453
|
+
for line in reversed(lines[len(head_lines):]):
|
|
454
|
+
line_bytes = len(line.encode("utf-8", errors="replace"))
|
|
455
|
+
if tail_bytes + line_bytes > tail_budget:
|
|
456
|
+
break
|
|
457
|
+
tail_lines.append(line)
|
|
458
|
+
tail_bytes += line_bytes
|
|
459
|
+
tail_lines.reverse()
|
|
460
|
+
|
|
461
|
+
omitted = line_count - len(head_lines) - len(tail_lines)
|
|
462
|
+
hint = _build_truncation_hint(source_file, source_tool, len(head_lines), saved_path)
|
|
446
463
|
return (
|
|
447
|
-
"".join(
|
|
464
|
+
"".join(head_lines)
|
|
448
465
|
+ f"\n\n[... truncated at ~{TRUNCATE_MAX_BYTES // 1024}KB — "
|
|
449
|
-
f"{
|
|
450
|
-
+ hint + "\n"
|
|
466
|
+
f"{omitted:,} lines omitted]\n"
|
|
467
|
+
+ hint + "\n\n"
|
|
468
|
+
+ "".join(tail_lines)
|
|
451
469
|
)
|
|
452
470
|
|
|
453
471
|
|
|
@@ -660,6 +678,17 @@ async def compact_conversation(
|
|
|
660
678
|
"""
|
|
661
679
|
from aru.providers import create_model
|
|
662
680
|
|
|
681
|
+
# Fire session.compact hook — plugins can pre-process history.
|
|
682
|
+
# Import is lazy here to avoid circular dependency (context ← runtime).
|
|
683
|
+
try:
|
|
684
|
+
from aru.runtime import get_ctx # noqa: lazy to avoid circular dep
|
|
685
|
+
mgr = get_ctx().plugin_manager
|
|
686
|
+
if mgr is not None and mgr.loaded:
|
|
687
|
+
event = await mgr.fire("session.compact", {"history": history})
|
|
688
|
+
history = event.data.get("history", history)
|
|
689
|
+
except (LookupError, AttributeError, ImportError):
|
|
690
|
+
pass # no plugin manager available — proceed without hooks
|
|
691
|
+
|
|
663
692
|
prompt = build_compaction_prompt(history, plan_task, model_id=model_id)
|
|
664
693
|
|
|
665
694
|
try:
|
|
@@ -413,6 +413,42 @@ def resolve_permission(
|
|
|
413
413
|
# Permission gate (user-facing prompt)
|
|
414
414
|
# ---------------------------------------------------------------------------
|
|
415
415
|
|
|
416
|
+
def _fire_permission_hook(mgr, category: str, subject: str) -> bool | None:
|
|
417
|
+
"""Fire permission.ask hook through all plugin handlers.
|
|
418
|
+
|
|
419
|
+
Supports both sync and async handlers. Returns True/False if a handler
|
|
420
|
+
sets event.data["allow"], or None if no handler overrode the decision.
|
|
421
|
+
"""
|
|
422
|
+
import asyncio
|
|
423
|
+
from aru.plugins.hooks import HookEvent
|
|
424
|
+
|
|
425
|
+
evt = HookEvent(hook="permission.ask", data={"category": category, "subject": subject})
|
|
426
|
+
|
|
427
|
+
for hooks_obj in mgr._hooks:
|
|
428
|
+
for handler in hooks_obj.get_handlers("permission.ask"):
|
|
429
|
+
try:
|
|
430
|
+
if asyncio.iscoroutinefunction(handler):
|
|
431
|
+
# Async handler — run via the event loop
|
|
432
|
+
loop = asyncio.get_event_loop()
|
|
433
|
+
if loop.is_running():
|
|
434
|
+
# Schedule as a task and wait with run_until_complete
|
|
435
|
+
# won't work, so use a new loop in a thread
|
|
436
|
+
import concurrent.futures
|
|
437
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
|
438
|
+
pool.submit(asyncio.run, handler(evt)).result(timeout=5)
|
|
439
|
+
else:
|
|
440
|
+
loop.run_until_complete(handler(evt))
|
|
441
|
+
else:
|
|
442
|
+
handler(evt)
|
|
443
|
+
except Exception:
|
|
444
|
+
continue # skip broken handlers
|
|
445
|
+
|
|
446
|
+
if "allow" in evt.data:
|
|
447
|
+
return bool(evt.data["allow"])
|
|
448
|
+
|
|
449
|
+
return None # no handler overrode
|
|
450
|
+
|
|
451
|
+
|
|
416
452
|
def check_permission(
|
|
417
453
|
category: str,
|
|
418
454
|
subject: str,
|
|
@@ -429,8 +465,20 @@ def check_permission(
|
|
|
429
465
|
if action == "deny":
|
|
430
466
|
return False
|
|
431
467
|
|
|
432
|
-
#
|
|
468
|
+
# Fire permission.ask hook — plugins can override the decision.
|
|
469
|
+
# check_permission runs in a sync context (called from tool threads),
|
|
470
|
+
# so we fire sync handlers directly and async handlers via the event loop.
|
|
433
471
|
ctx = get_ctx()
|
|
472
|
+
mgr = getattr(ctx, "plugin_manager", None)
|
|
473
|
+
if mgr is not None and getattr(mgr, "loaded", False):
|
|
474
|
+
try:
|
|
475
|
+
override = _fire_permission_hook(mgr, category, subject)
|
|
476
|
+
if override is not None:
|
|
477
|
+
return override
|
|
478
|
+
except Exception:
|
|
479
|
+
pass # never let plugin errors block permissions
|
|
480
|
+
|
|
481
|
+
# action == "ask" -> prompt user
|
|
434
482
|
with ctx.permission_lock:
|
|
435
483
|
# Re-check after acquiring lock (another thread may have resolved it)
|
|
436
484
|
action2, pattern2 = resolve_permission(category, subject)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Aru plugin system — custom tools, hooks, and OpenCode TS bridge.
|
|
2
|
+
|
|
3
|
+
Public API for plugin authors:
|
|
4
|
+
|
|
5
|
+
from aru.plugins import tool # @tool decorator for custom tools
|
|
6
|
+
from aru.plugins import PluginInput, Hooks # Full plugin API (Phase 2)
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from aru.plugins.tool_api import tool
|
|
10
|
+
from aru.plugins.hooks import Hooks, HookEvent, PluginInput
|
|
11
|
+
|
|
12
|
+
__all__ = ["tool", "Hooks", "HookEvent", "PluginInput"]
|