aru-code 0.1.0__py3-none-any.whl
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/__init__.py +1 -0
- aru/agents/__init__.py +0 -0
- aru/agents/base.py +188 -0
- aru/agents/executor.py +32 -0
- aru/agents/planner.py +85 -0
- aru/cli.py +1993 -0
- aru/config.py +237 -0
- aru/context.py +287 -0
- aru/providers.py +433 -0
- aru/tools/__init__.py +0 -0
- aru/tools/ast_tools.py +422 -0
- aru/tools/codebase.py +1328 -0
- aru/tools/gitignore.py +109 -0
- aru/tools/mcp_client.py +156 -0
- aru/tools/ranker.py +220 -0
- aru/tools/tasklist.py +183 -0
- aru_code-0.1.0.dist-info/METADATA +385 -0
- aru_code-0.1.0.dist-info/RECORD +22 -0
- aru_code-0.1.0.dist-info/WHEEL +5 -0
- aru_code-0.1.0.dist-info/entry_points.txt +2 -0
- aru_code-0.1.0.dist-info/licenses/LICENSE +21 -0
- aru_code-0.1.0.dist-info/top_level.txt +1 -0
aru/cli.py
ADDED
|
@@ -0,0 +1,1993 @@
|
|
|
1
|
+
"""Interactive CLI for aru - a Claude Code clone."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import random
|
|
8
|
+
import re
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
|
|
15
|
+
from prompt_toolkit import PromptSession
|
|
16
|
+
from prompt_toolkit.completion import Completer, Completion
|
|
17
|
+
from prompt_toolkit.document import Document
|
|
18
|
+
from prompt_toolkit.formatted_text import HTML
|
|
19
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
20
|
+
from prompt_toolkit.keys import Keys
|
|
21
|
+
from rich.console import Console, ConsoleOptions, RenderResult
|
|
22
|
+
from rich.live import Live
|
|
23
|
+
from rich.markdown import Markdown
|
|
24
|
+
from rich.measure import Measurement
|
|
25
|
+
from rich.panel import Panel
|
|
26
|
+
from rich.rule import Rule
|
|
27
|
+
from rich.spinner import Spinner
|
|
28
|
+
from rich.syntax import Syntax
|
|
29
|
+
from rich.text import Text
|
|
30
|
+
|
|
31
|
+
from aru.agents.executor import create_executor
|
|
32
|
+
from aru.agents.planner import create_planner, review_plan
|
|
33
|
+
from aru.config import AgentConfig, load_config, render_command_template
|
|
34
|
+
from aru.tools.codebase import get_skip_permissions
|
|
35
|
+
from aru.providers import (
|
|
36
|
+
MODEL_ALIASES,
|
|
37
|
+
create_model,
|
|
38
|
+
get_model_display,
|
|
39
|
+
list_providers,
|
|
40
|
+
resolve_model_ref,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
import io as _io
|
|
44
|
+
import logging as _logging
|
|
45
|
+
|
|
46
|
+
if sys.platform == "win32" and not hasattr(sys, "_called_from_test"):
|
|
47
|
+
sys.stdout = _io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")
|
|
48
|
+
sys.stderr = _io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8")
|
|
49
|
+
|
|
50
|
+
# Suppress Agno INFO logs (e.g. "Tool count limit hit") — only show warnings/errors
|
|
51
|
+
_logging.getLogger("agno").setLevel(_logging.WARNING)
|
|
52
|
+
|
|
53
|
+
console = Console()
|
|
54
|
+
|
|
55
|
+
# Arte ASCII original mantida
|
|
56
|
+
aru_logo = """
|
|
57
|
+
██████▖ ██▗████ ██ ██
|
|
58
|
+
██ ██ ██ ██
|
|
59
|
+
▗███████ ██ ██ ██
|
|
60
|
+
██ ██ ██ ██ ██
|
|
61
|
+
▝████▘██████ ▝████▘██
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
neon_green = "#39ff14" # Um verde bem "fósforo brilhante"
|
|
65
|
+
shadow_green = "#042800" # Verde bem escuro para sombra
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _build_logo_with_shadow(logo_text: str) -> Text:
|
|
69
|
+
"""Build logo Text with a drop-shadow effect for depth."""
|
|
70
|
+
lines = logo_text.strip("\n").split("\n")
|
|
71
|
+
# Pad all lines to same width
|
|
72
|
+
max_width = max(len(l) for l in lines)
|
|
73
|
+
lines = [l.ljust(max_width) for l in lines]
|
|
74
|
+
|
|
75
|
+
rows = len(lines)
|
|
76
|
+
cols = max_width
|
|
77
|
+
|
|
78
|
+
# Shadow offset: 1 row down, 1 col right
|
|
79
|
+
shadow_dy, shadow_dx = 1, 1
|
|
80
|
+
out_rows = rows + shadow_dy
|
|
81
|
+
out_cols = cols + shadow_dx
|
|
82
|
+
|
|
83
|
+
# Build grid: 'main' cells and 'shadow' cells
|
|
84
|
+
grid = [[" "] * out_cols for _ in range(out_rows)]
|
|
85
|
+
cell_type = [["empty"] * out_cols for _ in range(out_rows)]
|
|
86
|
+
|
|
87
|
+
# First pass: mark shadow cells
|
|
88
|
+
for r, line in enumerate(lines):
|
|
89
|
+
for c, ch in enumerate(line):
|
|
90
|
+
if ch != " ":
|
|
91
|
+
sr, sc = r + shadow_dy, c + shadow_dx
|
|
92
|
+
if cell_type[sr][sc] == "empty":
|
|
93
|
+
grid[sr][sc] = ch
|
|
94
|
+
cell_type[sr][sc] = "shadow"
|
|
95
|
+
|
|
96
|
+
# Second pass: mark main cells (overwrite shadow)
|
|
97
|
+
for r, line in enumerate(lines):
|
|
98
|
+
for c, ch in enumerate(line):
|
|
99
|
+
if ch != " ":
|
|
100
|
+
grid[r][c] = ch
|
|
101
|
+
cell_type[r][c] = "main"
|
|
102
|
+
|
|
103
|
+
# Render as Rich Text
|
|
104
|
+
result = Text("\n")
|
|
105
|
+
for r in range(out_rows):
|
|
106
|
+
result.append(" ")
|
|
107
|
+
for c in range(out_cols):
|
|
108
|
+
ch = grid[r][c]
|
|
109
|
+
ct = cell_type[r][c]
|
|
110
|
+
if ct == "main":
|
|
111
|
+
result.append(ch, style=f"bold {neon_green}")
|
|
112
|
+
elif ct == "shadow":
|
|
113
|
+
result.append(ch, style=shadow_green)
|
|
114
|
+
else:
|
|
115
|
+
result.append(ch)
|
|
116
|
+
result.append("\n")
|
|
117
|
+
return result
|
|
118
|
+
|
|
119
|
+
# Default model reference (provider/model format)
|
|
120
|
+
DEFAULT_MODEL = "anthropic/claude-sonnet-4-5"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def format_duration(seconds: float) -> str:
|
|
124
|
+
"""Format a duration in seconds to a human-readable string.
|
|
125
|
+
|
|
126
|
+
Examples:
|
|
127
|
+
0.5 -> "500ms"
|
|
128
|
+
1.0 -> "1s"
|
|
129
|
+
90.0 -> "1m 30s"
|
|
130
|
+
3661 -> "1h 1m 1s"
|
|
131
|
+
"""
|
|
132
|
+
if seconds < 1:
|
|
133
|
+
return f"{int(seconds * 1000)}ms"
|
|
134
|
+
total = int(seconds)
|
|
135
|
+
hours, remainder = divmod(total, 3600)
|
|
136
|
+
minutes, secs = divmod(remainder, 60)
|
|
137
|
+
if hours:
|
|
138
|
+
return f"{hours}h {minutes}m {secs}s"
|
|
139
|
+
if minutes:
|
|
140
|
+
return f"{minutes}m {secs}s"
|
|
141
|
+
return f"{secs}s"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _sanitize_input(text: str) -> str:
|
|
145
|
+
"""Remove lone UTF-16 surrogates that Windows clipboard can introduce."""
|
|
146
|
+
return text.encode("utf-8", errors="replace").decode("utf-8")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
_MENTION_RE = re.compile(r'(?<!\S)@([a-zA-Z0-9_./\\-]+)')
|
|
150
|
+
_MENTION_MAX_SIZE = 30_000 # bytes, same limit as read_file
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _resolve_mentions(text: str, cwd: str) -> tuple[str, int]:
|
|
154
|
+
"""Resolve @file mentions by appending file contents to the message.
|
|
155
|
+
|
|
156
|
+
Returns (resolved_text, number_of_files_attached).
|
|
157
|
+
"""
|
|
158
|
+
matches = list(_MENTION_RE.finditer(text))
|
|
159
|
+
if not matches:
|
|
160
|
+
return text, 0
|
|
161
|
+
|
|
162
|
+
appendix_parts = []
|
|
163
|
+
seen = set()
|
|
164
|
+
for m in matches:
|
|
165
|
+
rel_path = m.group(1)
|
|
166
|
+
if rel_path in seen:
|
|
167
|
+
continue
|
|
168
|
+
seen.add(rel_path)
|
|
169
|
+
abs_path = os.path.join(cwd, rel_path)
|
|
170
|
+
if not os.path.isfile(abs_path):
|
|
171
|
+
continue
|
|
172
|
+
try:
|
|
173
|
+
size = os.path.getsize(abs_path)
|
|
174
|
+
with open(abs_path, "r", encoding="utf-8", errors="replace") as f:
|
|
175
|
+
content = f.read(_MENTION_MAX_SIZE)
|
|
176
|
+
if size > _MENTION_MAX_SIZE:
|
|
177
|
+
appendix_parts.append(
|
|
178
|
+
f"\n\n---\nContents of {rel_path} (truncated to {_MENTION_MAX_SIZE // 1000}KB):\n```\n{content}\n```"
|
|
179
|
+
)
|
|
180
|
+
else:
|
|
181
|
+
appendix_parts.append(
|
|
182
|
+
f"\n\n---\nContents of {rel_path}:\n```\n{content}\n```"
|
|
183
|
+
)
|
|
184
|
+
except OSError:
|
|
185
|
+
continue
|
|
186
|
+
|
|
187
|
+
if appendix_parts:
|
|
188
|
+
return text + "".join(appendix_parts), len(appendix_parts)
|
|
189
|
+
return text, 0
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
TIPS = [
|
|
193
|
+
"Type naturally — aru decides whether to plan or execute.",
|
|
194
|
+
"Use /plan to break down complex tasks before executing.",
|
|
195
|
+
"Place AGENTS.md in project root for custom instructions.",
|
|
196
|
+
"Use .agents/commands/ and .agents/skills/ for extensions.",
|
|
197
|
+
"Use ! <command> to run shell commands directly.",
|
|
198
|
+
"Use /model to switch providers (e.g., /model ollama/llama3.1).",
|
|
199
|
+
"Use /sessions to resume previous conversations.",
|
|
200
|
+
]
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _render_input_separator() -> None:
|
|
204
|
+
"""Print a green separator line above the input prompt."""
|
|
205
|
+
console.print(Rule(style=f"dim {neon_green}"))
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _render_home(session: "Session", skip_permissions: bool) -> None:
|
|
210
|
+
"""Render a clean home screen inspired by Claude Code."""
|
|
211
|
+
from rich.table import Table
|
|
212
|
+
|
|
213
|
+
from aru import __version__
|
|
214
|
+
|
|
215
|
+
logo = _build_logo_with_shadow(aru_logo)
|
|
216
|
+
console.print(logo)
|
|
217
|
+
console.print(
|
|
218
|
+
Text.from_markup(f" [dim]A coding agent powered by OpenSource[/dim] [bold {neon_green}]v{__version__}[/bold {neon_green}]"),
|
|
219
|
+
)
|
|
220
|
+
console.print()
|
|
221
|
+
|
|
222
|
+
# Compact command reference
|
|
223
|
+
cmds = Table(show_header=False, box=None, padding=(0, 2), expand=False)
|
|
224
|
+
cmds.add_column(style="bold cyan", min_width=12)
|
|
225
|
+
cmds.add_column(style="dim")
|
|
226
|
+
cmds.add_row("/help", "Show all commands")
|
|
227
|
+
console.print(cmds)
|
|
228
|
+
console.print()
|
|
229
|
+
|
|
230
|
+
# Status line
|
|
231
|
+
mode_label = "[red]skip permissions[/red]" if skip_permissions else "[green]safe mode[/green]"
|
|
232
|
+
console.print(
|
|
233
|
+
Text.from_markup(
|
|
234
|
+
f" [dim]model:[/dim] [bold]{session.model_display}[/bold] [dim]({session.model_id})[/dim]"
|
|
235
|
+
f" [dim]|[/dim] {mode_label}"
|
|
236
|
+
)
|
|
237
|
+
)
|
|
238
|
+
console.print(
|
|
239
|
+
Text.from_markup(f" [dim]cwd:[/dim] {os.getcwd()}")
|
|
240
|
+
)
|
|
241
|
+
console.print()
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
SLASH_COMMANDS = [
|
|
245
|
+
("/help", "Show help and available commands", "/help"),
|
|
246
|
+
("/plan", "Create an implementation plan", "/plan <task>"),
|
|
247
|
+
("/model", "Switch model/provider", "/model [provider/model]"),
|
|
248
|
+
("/sessions", "List recent sessions", "/sessions"),
|
|
249
|
+
("/commands", "List custom commands", "/commands"),
|
|
250
|
+
("/skills", "List available skills", "/skills"),
|
|
251
|
+
("/mcp", "List loaded MCP tools", "/mcp"),
|
|
252
|
+
("/quit", "Exit aru", "/quit"),
|
|
253
|
+
]
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
class SlashCommandCompleter(Completer):
|
|
257
|
+
"""Show slash commands only when '/' is typed as the first character."""
|
|
258
|
+
|
|
259
|
+
def __init__(self, custom_commands: dict | None = None):
|
|
260
|
+
self._custom_commands = custom_commands or {}
|
|
261
|
+
|
|
262
|
+
def get_completions(self, document: Document, complete_event):
|
|
263
|
+
text = document.text_before_cursor
|
|
264
|
+
# Only complete when '/' is the first character
|
|
265
|
+
if not text.startswith("/"):
|
|
266
|
+
return
|
|
267
|
+
# Built-in commands
|
|
268
|
+
for cmd, description, usage in SLASH_COMMANDS:
|
|
269
|
+
if cmd.startswith(text):
|
|
270
|
+
yield Completion(
|
|
271
|
+
cmd,
|
|
272
|
+
start_position=-len(text),
|
|
273
|
+
display=HTML(f"<b>{cmd}</b>"),
|
|
274
|
+
display_meta=description,
|
|
275
|
+
)
|
|
276
|
+
# Custom commands from .agents/commands/
|
|
277
|
+
for name, cmd_def in self._custom_commands.items():
|
|
278
|
+
slash_name = f"/{name}"
|
|
279
|
+
if slash_name.startswith(text):
|
|
280
|
+
yield Completion(
|
|
281
|
+
slash_name,
|
|
282
|
+
start_position=-len(text),
|
|
283
|
+
display=HTML(f"<b>{slash_name}</b>"),
|
|
284
|
+
display_meta=cmd_def.description,
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
class FileMentionCompleter(Completer):
|
|
289
|
+
"""Show file/directory suggestions when '@' is typed."""
|
|
290
|
+
|
|
291
|
+
def get_completions(self, document: Document, complete_event):
|
|
292
|
+
text = document.text_before_cursor
|
|
293
|
+
# Find the last '@' that is either at start or preceded by whitespace
|
|
294
|
+
idx = text.rfind("@")
|
|
295
|
+
if idx < 0:
|
|
296
|
+
return
|
|
297
|
+
if idx > 0 and not text[idx - 1].isspace():
|
|
298
|
+
return
|
|
299
|
+
|
|
300
|
+
partial = text[idx + 1:] # e.g. "arc/con" from "@arc/con"
|
|
301
|
+
# Split into directory part and name prefix
|
|
302
|
+
if "/" in partial or "\\" in partial:
|
|
303
|
+
# Normalize to forward slashes
|
|
304
|
+
normalized = partial.replace("\\", "/")
|
|
305
|
+
dir_part, name_prefix = normalized.rsplit("/", 1)
|
|
306
|
+
search_dir = os.path.join(os.getcwd(), dir_part)
|
|
307
|
+
rel_prefix = dir_part + "/"
|
|
308
|
+
else:
|
|
309
|
+
dir_part = ""
|
|
310
|
+
name_prefix = partial
|
|
311
|
+
search_dir = os.getcwd()
|
|
312
|
+
rel_prefix = ""
|
|
313
|
+
|
|
314
|
+
if not os.path.isdir(search_dir):
|
|
315
|
+
return
|
|
316
|
+
|
|
317
|
+
from aru.tools.gitignore import is_ignored
|
|
318
|
+
cwd = os.getcwd()
|
|
319
|
+
|
|
320
|
+
try:
|
|
321
|
+
entries = sorted(os.listdir(search_dir))
|
|
322
|
+
except OSError:
|
|
323
|
+
return
|
|
324
|
+
|
|
325
|
+
count = 0
|
|
326
|
+
for entry in entries:
|
|
327
|
+
if count >= 50: # limit suggestions
|
|
328
|
+
break
|
|
329
|
+
if not entry.lower().startswith(name_prefix.lower()):
|
|
330
|
+
continue
|
|
331
|
+
|
|
332
|
+
full_path = os.path.join(search_dir, entry)
|
|
333
|
+
rel_path = os.path.relpath(full_path, cwd).replace("\\", "/")
|
|
334
|
+
|
|
335
|
+
# Skip gitignored entries
|
|
336
|
+
if is_ignored(rel_path, cwd):
|
|
337
|
+
continue
|
|
338
|
+
# Skip hidden files/dirs
|
|
339
|
+
if entry.startswith("."):
|
|
340
|
+
continue
|
|
341
|
+
|
|
342
|
+
is_dir = os.path.isdir(full_path)
|
|
343
|
+
display_text = rel_prefix + entry + ("/" if is_dir else "")
|
|
344
|
+
meta = "dir" if is_dir else ""
|
|
345
|
+
|
|
346
|
+
yield Completion(
|
|
347
|
+
display_text,
|
|
348
|
+
start_position=-len(partial),
|
|
349
|
+
display=HTML(f"<b>@{display_text}</b>"),
|
|
350
|
+
display_meta=meta,
|
|
351
|
+
)
|
|
352
|
+
count += 1
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
class AruCompleter(Completer):
|
|
356
|
+
"""Merges slash-command and @file completions."""
|
|
357
|
+
|
|
358
|
+
def __init__(self, custom_commands: dict | None = None):
|
|
359
|
+
self._slash = SlashCommandCompleter(custom_commands)
|
|
360
|
+
self._mention = FileMentionCompleter()
|
|
361
|
+
|
|
362
|
+
def get_completions(self, document: Document, complete_event):
|
|
363
|
+
text = document.text_before_cursor
|
|
364
|
+
if text.startswith("/"):
|
|
365
|
+
yield from self._slash.get_completions(document, complete_event)
|
|
366
|
+
elif "@" in text:
|
|
367
|
+
yield from self._mention.get_completions(document, complete_event)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
class PasteState:
|
|
371
|
+
"""Tracks pasted content so the user can annotate it."""
|
|
372
|
+
|
|
373
|
+
def __init__(self):
|
|
374
|
+
self.pasted_content: str | None = None
|
|
375
|
+
self.line_count: int = 0
|
|
376
|
+
|
|
377
|
+
def set(self, content: str):
|
|
378
|
+
lines = content.splitlines()
|
|
379
|
+
self.pasted_content = content
|
|
380
|
+
self.line_count = len(lines)
|
|
381
|
+
|
|
382
|
+
def clear(self):
|
|
383
|
+
self.pasted_content = None
|
|
384
|
+
self.line_count = 0
|
|
385
|
+
|
|
386
|
+
def build_message(self, user_text: str) -> str:
|
|
387
|
+
"""Combine user annotation with pasted content."""
|
|
388
|
+
if self.pasted_content and user_text.strip():
|
|
389
|
+
return f"{user_text.strip()}\n\n```\n{self.pasted_content}\n```"
|
|
390
|
+
if self.pasted_content:
|
|
391
|
+
return self.pasted_content
|
|
392
|
+
return user_text
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _create_prompt_session(paste_state: PasteState, config: AgentConfig | None = None) -> PromptSession:
|
|
396
|
+
"""Create a prompt_toolkit session with smart paste detection."""
|
|
397
|
+
bindings = KeyBindings()
|
|
398
|
+
|
|
399
|
+
@bindings.add(Keys.Escape, Keys.Enter)
|
|
400
|
+
def _newline(event):
|
|
401
|
+
"""Escape+Enter inserts a newline for manual multi-line editing."""
|
|
402
|
+
event.current_buffer.insert_text("\n")
|
|
403
|
+
|
|
404
|
+
custom_cmds = config.commands if config else {}
|
|
405
|
+
session = PromptSession(
|
|
406
|
+
key_bindings=bindings,
|
|
407
|
+
multiline=False,
|
|
408
|
+
enable_open_in_editor=False,
|
|
409
|
+
completer=AruCompleter(custom_cmds),
|
|
410
|
+
complete_while_typing=True,
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
@bindings.add(Keys.BracketedPaste)
|
|
414
|
+
def _handle_paste(event):
|
|
415
|
+
"""Intercept multi-line pastes: store content and show line count."""
|
|
416
|
+
data = event.data
|
|
417
|
+
lines = data.splitlines()
|
|
418
|
+
if len(lines) > 1:
|
|
419
|
+
paste_state.set(data)
|
|
420
|
+
# Preserve text typed before the paste (e.g., "/plan ")
|
|
421
|
+
existing_text = event.current_buffer.text
|
|
422
|
+
event.current_buffer.reset()
|
|
423
|
+
if existing_text.strip():
|
|
424
|
+
event.current_buffer.insert_text(existing_text)
|
|
425
|
+
# Dynamically enable toolbar now that paste exists
|
|
426
|
+
session.bottom_toolbar = HTML(
|
|
427
|
+
f'<style fg="ansicyan">│</style> <b><style bg="ansiblue" fg="ansiwhite"> {paste_state.line_count} lines pasted </style></b>'
|
|
428
|
+
f' <i><style fg="ansigray">Type a message about this paste, or press Enter to send as-is</style></i>'
|
|
429
|
+
)
|
|
430
|
+
event.app.invalidate()
|
|
431
|
+
else:
|
|
432
|
+
event.current_buffer.insert_text(data)
|
|
433
|
+
|
|
434
|
+
return session
|
|
435
|
+
|
|
436
|
+
from aru.agents.base import build_instructions as _build_instructions
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
class PlanStep:
|
|
440
|
+
"""A single step in a structured plan."""
|
|
441
|
+
|
|
442
|
+
def __init__(self, index: int, description: str, subtasks: list[str] | None = None):
|
|
443
|
+
self.index = index
|
|
444
|
+
self.description = description
|
|
445
|
+
self.subtasks: list[str] = subtasks or []
|
|
446
|
+
self.status: str = "pending" # pending | in_progress | completed | failed
|
|
447
|
+
|
|
448
|
+
@property
|
|
449
|
+
def checkbox(self) -> str:
|
|
450
|
+
if self.status == "completed":
|
|
451
|
+
return "[bold green]\\[x][/bold green]"
|
|
452
|
+
elif self.status == "in_progress":
|
|
453
|
+
return "[bold yellow]\\[~][/bold yellow]"
|
|
454
|
+
elif self.status == "failed":
|
|
455
|
+
return "[bold red]\\[!][/bold red]"
|
|
456
|
+
return "[dim]\\[ ][/dim]"
|
|
457
|
+
|
|
458
|
+
@property
|
|
459
|
+
def full_description(self) -> str:
|
|
460
|
+
"""Description with subtask list for executor prompt."""
|
|
461
|
+
if not self.subtasks:
|
|
462
|
+
return self.description
|
|
463
|
+
subtask_lines = "\n".join(f" {i+1}. {s}" for i, s in enumerate(self.subtasks))
|
|
464
|
+
return f"{self.description}\n\nSubtasks:\n{subtask_lines}"
|
|
465
|
+
|
|
466
|
+
def __str__(self) -> str:
|
|
467
|
+
return f"Step {self.index}: {self.description}"
|
|
468
|
+
|
|
469
|
+
def to_dict(self) -> dict:
|
|
470
|
+
return {"index": self.index, "description": self.description, "subtasks": self.subtasks, "status": self.status}
|
|
471
|
+
|
|
472
|
+
@classmethod
|
|
473
|
+
def from_dict(cls, data: dict) -> "PlanStep":
|
|
474
|
+
step = cls(data["index"], data["description"], data.get("subtasks", []))
|
|
475
|
+
step.status = data.get("status", "pending")
|
|
476
|
+
return step
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def parse_plan_steps(plan_text: str) -> list[PlanStep]:
|
|
480
|
+
"""Extract structured steps from a plan markdown output.
|
|
481
|
+
|
|
482
|
+
Matches step lines like:
|
|
483
|
+
- [ ] Step 1: Do something
|
|
484
|
+
- [ ] 1. Do something
|
|
485
|
+
|
|
486
|
+
And subtask lines indented below each step:
|
|
487
|
+
1. Write backend/models.py
|
|
488
|
+
2. Edit backend/main.py — add router
|
|
489
|
+
"""
|
|
490
|
+
steps = []
|
|
491
|
+
lines = plan_text.split("\n")
|
|
492
|
+
|
|
493
|
+
# Patterns
|
|
494
|
+
checkbox_pattern = re.compile(r"^\s*-\s*\[[ x]\]\s*(.+)$")
|
|
495
|
+
subtask_pattern = re.compile(r"^\s+\d+[.:]\s*(.+)$")
|
|
496
|
+
|
|
497
|
+
current_step_desc = None
|
|
498
|
+
current_subtasks: list[str] = []
|
|
499
|
+
step_index = 0
|
|
500
|
+
|
|
501
|
+
def _flush_step():
|
|
502
|
+
nonlocal current_step_desc, current_subtasks, step_index
|
|
503
|
+
if current_step_desc is not None:
|
|
504
|
+
step_index += 1
|
|
505
|
+
cleaned = re.sub(r"^(?:step\s*)?\d+[.:]\s*", "", current_step_desc, flags=re.IGNORECASE).strip()
|
|
506
|
+
steps.append(PlanStep(step_index, cleaned or current_step_desc.strip(), current_subtasks))
|
|
507
|
+
current_subtasks = []
|
|
508
|
+
current_step_desc = None
|
|
509
|
+
|
|
510
|
+
for line in lines:
|
|
511
|
+
checkbox_match = checkbox_pattern.match(line)
|
|
512
|
+
subtask_match = subtask_pattern.match(line)
|
|
513
|
+
|
|
514
|
+
if checkbox_match:
|
|
515
|
+
_flush_step()
|
|
516
|
+
current_step_desc = checkbox_match.group(1)
|
|
517
|
+
elif subtask_match and current_step_desc is not None:
|
|
518
|
+
current_subtasks.append(subtask_match.group(1).strip())
|
|
519
|
+
|
|
520
|
+
_flush_step()
|
|
521
|
+
|
|
522
|
+
if steps:
|
|
523
|
+
return steps
|
|
524
|
+
|
|
525
|
+
# Fallback: numbered items without checkboxes
|
|
526
|
+
numbered_pattern = re.compile(r"^\s*(?:step\s*)?\d+[.:]\s*(.+)$", re.IGNORECASE)
|
|
527
|
+
for line in lines:
|
|
528
|
+
match = numbered_pattern.match(line)
|
|
529
|
+
if match:
|
|
530
|
+
desc = match.group(1)
|
|
531
|
+
cleaned = re.sub(r"^(?:step\s*)?\d+[.:]\s*", "", desc, flags=re.IGNORECASE).strip()
|
|
532
|
+
steps.append(PlanStep(len(steps) + 1, cleaned or desc.strip()))
|
|
533
|
+
|
|
534
|
+
return steps if len(steps) >= 2 else []
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
class Session:
|
|
538
|
+
"""Holds shared state across the conversation."""
|
|
539
|
+
|
|
540
|
+
# Approximate chars-per-token ratio for estimation (conservative)
|
|
541
|
+
_CHARS_PER_TOKEN = 3.5
|
|
542
|
+
# History summarization threshold: summarize oldest messages when history exceeds this
|
|
543
|
+
_HISTORY_SUMMARIZE_THRESHOLD = 20
|
|
544
|
+
_HISTORY_SUMMARIZE_COUNT = 6 # number of oldest messages to condense
|
|
545
|
+
|
|
546
|
+
def __init__(self, session_id: str | None = None):
|
|
547
|
+
self.session_id: str = session_id or _generate_session_id()
|
|
548
|
+
self.history: list[dict[str, str]] = []
|
|
549
|
+
self.current_plan: str | None = None
|
|
550
|
+
self.plan_task: str | None = None
|
|
551
|
+
self.plan_steps: list[PlanStep] = []
|
|
552
|
+
self.model_ref: str = DEFAULT_MODEL # provider/model format
|
|
553
|
+
self.cwd: str = os.getcwd()
|
|
554
|
+
self.created_at: str = datetime.now().isoformat(timespec="milliseconds")
|
|
555
|
+
self.updated_at: str = self.created_at
|
|
556
|
+
self.total_input_tokens: int = 0
|
|
557
|
+
self.total_output_tokens: int = 0
|
|
558
|
+
self.total_cache_read_tokens: int = 0
|
|
559
|
+
self.total_cache_write_tokens: int = 0
|
|
560
|
+
self.api_calls: int = 0
|
|
561
|
+
# Context cache — invalidated on file mutations
|
|
562
|
+
self._cached_tree: str | None = None
|
|
563
|
+
self._cached_git_status: str | None = None
|
|
564
|
+
self._context_dirty: bool = True
|
|
565
|
+
# Token budget (0 = unlimited)
|
|
566
|
+
self.token_budget: int = 0
|
|
567
|
+
|
|
568
|
+
@property
|
|
569
|
+
def model_id(self) -> str:
|
|
570
|
+
"""Resolve to the actual model ID for the API."""
|
|
571
|
+
from aru.providers import _get_actual_model_id, get_provider
|
|
572
|
+
provider_key, model_name = resolve_model_ref(self.model_ref)
|
|
573
|
+
provider = get_provider(provider_key)
|
|
574
|
+
if provider:
|
|
575
|
+
return _get_actual_model_id(provider, model_name)
|
|
576
|
+
return model_name
|
|
577
|
+
|
|
578
|
+
@property
|
|
579
|
+
def model_display(self) -> str:
|
|
580
|
+
return get_model_display(self.model_ref)
|
|
581
|
+
|
|
582
|
+
@property
|
|
583
|
+
def title(self) -> str:
|
|
584
|
+
"""Generate a short title from the first user message or plan task."""
|
|
585
|
+
if self.plan_task:
|
|
586
|
+
return self.plan_task[:60]
|
|
587
|
+
for msg in self.history:
|
|
588
|
+
if msg["role"] == "user":
|
|
589
|
+
text = msg["content"][:60]
|
|
590
|
+
return text.split("\n")[0]
|
|
591
|
+
return "(empty session)"
|
|
592
|
+
|
|
593
|
+
def set_plan(self, task: str, plan_content: str):
|
|
594
|
+
"""Store a plan and parse its steps."""
|
|
595
|
+
self.current_plan = plan_content
|
|
596
|
+
self.plan_task = task
|
|
597
|
+
self.plan_steps = parse_plan_steps(plan_content)
|
|
598
|
+
|
|
599
|
+
def clear_plan(self):
|
|
600
|
+
"""Clear the active plan."""
|
|
601
|
+
self.current_plan = None
|
|
602
|
+
self.plan_task = None
|
|
603
|
+
self.plan_steps = []
|
|
604
|
+
|
|
605
|
+
def track_tokens(self, metrics):
|
|
606
|
+
"""Accumulate token usage from a RunCompletedEvent.metrics."""
|
|
607
|
+
if metrics is None:
|
|
608
|
+
return
|
|
609
|
+
self.total_input_tokens += getattr(metrics, "input_tokens", 0) or 0
|
|
610
|
+
self.total_output_tokens += getattr(metrics, "output_tokens", 0) or 0
|
|
611
|
+
self.total_cache_read_tokens += getattr(metrics, "cache_read_tokens", 0) or 0
|
|
612
|
+
self.total_cache_write_tokens += getattr(metrics, "cache_write_tokens", 0) or 0
|
|
613
|
+
self.api_calls += 1
|
|
614
|
+
|
|
615
|
+
@property
|
|
616
|
+
def token_summary(self) -> str:
|
|
617
|
+
total = self.total_input_tokens + self.total_output_tokens
|
|
618
|
+
if total == 0:
|
|
619
|
+
return ""
|
|
620
|
+
metrics_str = f"in: {self.total_input_tokens:,} / out: {self.total_output_tokens:,}"
|
|
621
|
+
if self.total_cache_read_tokens > 0:
|
|
622
|
+
metrics_str += f" / cached: {self.total_cache_read_tokens:,}"
|
|
623
|
+
summary = f"tokens: {total:,} ({metrics_str}) | calls: {self.api_calls}"
|
|
624
|
+
if self.token_budget > 0:
|
|
625
|
+
pct = int(total / self.token_budget * 100)
|
|
626
|
+
summary += f" | budget: {pct}%"
|
|
627
|
+
return summary
|
|
628
|
+
|
|
629
|
+
def invalidate_context_cache(self):
|
|
630
|
+
"""Mark cached tree/git status as stale. Call after file mutations."""
|
|
631
|
+
self._context_dirty = True
|
|
632
|
+
|
|
633
|
+
def get_cached_tree(self, cwd: str) -> str | None:
|
|
634
|
+
"""Return cached directory tree, regenerating if dirty."""
|
|
635
|
+
if self._context_dirty or self._cached_tree is None:
|
|
636
|
+
self._refresh_context_cache(cwd)
|
|
637
|
+
return self._cached_tree
|
|
638
|
+
|
|
639
|
+
def get_cached_git_status(self, cwd: str) -> str | None:
|
|
640
|
+
"""Return cached git status, regenerating if dirty."""
|
|
641
|
+
if self._context_dirty or self._cached_git_status is None:
|
|
642
|
+
self._refresh_context_cache(cwd)
|
|
643
|
+
return self._cached_git_status
|
|
644
|
+
|
|
645
|
+
def _refresh_context_cache(self, cwd: str):
|
|
646
|
+
"""Regenerate tree and git status caches."""
|
|
647
|
+
try:
|
|
648
|
+
from aru.tools.codebase import get_project_tree
|
|
649
|
+
self._cached_tree = get_project_tree(cwd, max_depth=3) or None
|
|
650
|
+
except Exception:
|
|
651
|
+
self._cached_tree = None
|
|
652
|
+
try:
|
|
653
|
+
self._cached_git_status = subprocess.run(
|
|
654
|
+
["git", "status", "-s"], capture_output=True, text=True, cwd=cwd, timeout=2
|
|
655
|
+
).stdout.strip() or None
|
|
656
|
+
except Exception:
|
|
657
|
+
self._cached_git_status = None
|
|
658
|
+
self._context_dirty = False
|
|
659
|
+
|
|
660
|
+
@staticmethod
|
|
661
|
+
def estimate_tokens(text: str) -> int:
|
|
662
|
+
"""Fast approximate token count based on character length."""
|
|
663
|
+
return int(len(text) / Session._CHARS_PER_TOKEN)
|
|
664
|
+
|
|
665
|
+
def check_budget_warning(self) -> str | None:
|
|
666
|
+
"""Return a warning string if token usage is approaching the budget."""
|
|
667
|
+
if self.token_budget <= 0:
|
|
668
|
+
return None
|
|
669
|
+
total = self.total_input_tokens + self.total_output_tokens
|
|
670
|
+
pct = total / self.token_budget * 100
|
|
671
|
+
if pct >= 95:
|
|
672
|
+
return f"[bold red]Token budget nearly exhausted ({pct:.0f}%)[/bold red]"
|
|
673
|
+
if pct >= 80:
|
|
674
|
+
return f"[yellow]Token budget at {pct:.0f}%[/yellow]"
|
|
675
|
+
return None
|
|
676
|
+
|
|
677
|
+
def add_message(self, role: str, content: str):
|
|
678
|
+
self.history.append({"role": role, "content": content})
|
|
679
|
+
# Summarize oldest messages instead of hard-truncating
|
|
680
|
+
if len(self.history) > self._HISTORY_SUMMARIZE_THRESHOLD:
|
|
681
|
+
self._summarize_old_messages()
|
|
682
|
+
# Hard cap as safety net
|
|
683
|
+
if len(self.history) > 30:
|
|
684
|
+
self.history = self.history[-30:]
|
|
685
|
+
|
|
686
|
+
def _summarize_old_messages(self):
|
|
687
|
+
"""Condense the oldest messages into a single summary message.
|
|
688
|
+
|
|
689
|
+
Preserves [Tools] and [Plan] sections so the model knows what actions
|
|
690
|
+
were taken even after summarization.
|
|
691
|
+
"""
|
|
692
|
+
n = self._HISTORY_SUMMARIZE_COUNT
|
|
693
|
+
old = self.history[:n]
|
|
694
|
+
rest = self.history[n:]
|
|
695
|
+
summary_parts = []
|
|
696
|
+
for msg in old:
|
|
697
|
+
role = msg["role"]
|
|
698
|
+
content = msg["content"]
|
|
699
|
+
# Extract [Tools] section before truncating
|
|
700
|
+
tools_section = ""
|
|
701
|
+
tools_idx = content.find("\n[Tools]\n")
|
|
702
|
+
if tools_idx != -1:
|
|
703
|
+
tools_section = content[tools_idx:]
|
|
704
|
+
# Truncate the main text but keep tools metadata
|
|
705
|
+
text = content[:300] if tools_idx == -1 else content[:tools_idx][:300]
|
|
706
|
+
if len(content) > 300:
|
|
707
|
+
text += "..."
|
|
708
|
+
if tools_section:
|
|
709
|
+
text += tools_section
|
|
710
|
+
summary_parts.append(f"[{role}]: {text}")
|
|
711
|
+
summary = "[Conversation summary of earlier messages]\n" + "\n".join(summary_parts)
|
|
712
|
+
self.history = [{"role": "user", "content": summary}] + rest
|
|
713
|
+
self.updated_at = datetime.now().isoformat(timespec="milliseconds")
|
|
714
|
+
|
|
715
|
+
def compact_history(self, max_tokens: int) -> int:
|
|
716
|
+
"""Remove oldest messages until the estimated token total is below max_tokens.
|
|
717
|
+
|
|
718
|
+
Token count is estimated from the total character length of all messages
|
|
719
|
+
using the class-level _CHARS_PER_TOKEN ratio (3.5 chars ≈ 1 token).
|
|
720
|
+
|
|
721
|
+
Messages are dropped from the front of the history (oldest first).
|
|
722
|
+
If a single message already exceeds max_tokens, the history is reduced
|
|
723
|
+
to that one message only.
|
|
724
|
+
|
|
725
|
+
Args:
|
|
726
|
+
max_tokens: Target token ceiling for the conversation history.
|
|
727
|
+
|
|
728
|
+
Returns:
|
|
729
|
+
Number of messages removed.
|
|
730
|
+
"""
|
|
731
|
+
def _total_tokens() -> int:
|
|
732
|
+
return sum(self.estimate_tokens(m["content"]) for m in self.history)
|
|
733
|
+
|
|
734
|
+
removed = 0
|
|
735
|
+
while self.history and _total_tokens() > max_tokens:
|
|
736
|
+
self.history.pop(0)
|
|
737
|
+
removed += 1
|
|
738
|
+
|
|
739
|
+
if removed:
|
|
740
|
+
self.updated_at = datetime.now().isoformat(timespec="milliseconds")
|
|
741
|
+
|
|
742
|
+
return removed
|
|
743
|
+
|
|
744
|
+
def to_dict(self) -> dict:
|
|
745
|
+
return {
|
|
746
|
+
"session_id": self.session_id,
|
|
747
|
+
"history": self.history,
|
|
748
|
+
"current_plan": self.current_plan,
|
|
749
|
+
"plan_task": self.plan_task,
|
|
750
|
+
"plan_steps": [s.to_dict() for s in self.plan_steps],
|
|
751
|
+
"model_ref": self.model_ref,
|
|
752
|
+
"cwd": self.cwd,
|
|
753
|
+
"created_at": self.created_at,
|
|
754
|
+
"updated_at": self.updated_at,
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
@classmethod
|
|
758
|
+
def from_dict(cls, data: dict) -> "Session":
|
|
759
|
+
session = cls(session_id=data["session_id"])
|
|
760
|
+
session.history = data.get("history", [])
|
|
761
|
+
session.current_plan = data.get("current_plan")
|
|
762
|
+
session.plan_task = data.get("plan_task")
|
|
763
|
+
session.plan_steps = [PlanStep.from_dict(s) for s in data.get("plan_steps", [])]
|
|
764
|
+
# Support both new "model_ref" and legacy "model_key" for backward compat
|
|
765
|
+
model_ref = data.get("model_ref")
|
|
766
|
+
if not model_ref:
|
|
767
|
+
legacy_key = data.get("model_key", "sonnet")
|
|
768
|
+
model_ref = MODEL_ALIASES.get(legacy_key, DEFAULT_MODEL)
|
|
769
|
+
session.model_ref = model_ref
|
|
770
|
+
session.cwd = data.get("cwd", os.getcwd())
|
|
771
|
+
session.created_at = data.get("created_at", "")
|
|
772
|
+
session.updated_at = data.get("updated_at", "")
|
|
773
|
+
return session
|
|
774
|
+
|
|
775
|
+
def get_context_summary(self) -> str:
|
|
776
|
+
"""Build compact context string from active plan status."""
|
|
777
|
+
parts = []
|
|
778
|
+
if self.current_plan:
|
|
779
|
+
# Send only plan progress (checkboxes), not the full plan text
|
|
780
|
+
parts.append(f"## Active Plan\nTask: {self.plan_task}\n\n{self.render_plan_progress()}")
|
|
781
|
+
return "\n\n".join(parts)
|
|
782
|
+
|
|
783
|
+
def render_plan_progress(self) -> str:
|
|
784
|
+
"""Render the plan steps with checkbox status for display."""
|
|
785
|
+
if not self.plan_steps:
|
|
786
|
+
return ""
|
|
787
|
+
lines = []
|
|
788
|
+
completed = sum(1 for s in self.plan_steps if s.status == "completed")
|
|
789
|
+
total = len(self.plan_steps)
|
|
790
|
+
lines.append(f"[bold]Plan Progress ({completed}/{total}):[/bold]")
|
|
791
|
+
for step in self.plan_steps:
|
|
792
|
+
style = ""
|
|
793
|
+
if step.status == "completed":
|
|
794
|
+
style = "green"
|
|
795
|
+
elif step.status == "in_progress":
|
|
796
|
+
style = "yellow"
|
|
797
|
+
elif step.status == "failed":
|
|
798
|
+
style = "red"
|
|
799
|
+
desc = f"[{style}]{step.description}[/{style}]" if style else step.description
|
|
800
|
+
lines.append(f" {step.checkbox} {desc}")
|
|
801
|
+
return "\n".join(lines)
|
|
802
|
+
|
|
803
|
+
def render_compact_progress(self, current_index: int) -> str:
|
|
804
|
+
"""Render a token-efficient progress view for LLM context.
|
|
805
|
+
|
|
806
|
+
Shows completed steps as one-liners and only the current step in full.
|
|
807
|
+
Pending steps are listed by index only.
|
|
808
|
+
"""
|
|
809
|
+
if not self.plan_steps:
|
|
810
|
+
return ""
|
|
811
|
+
completed = sum(1 for s in self.plan_steps if s.status == "completed")
|
|
812
|
+
total = len(self.plan_steps)
|
|
813
|
+
lines = [f"Progress: {completed}/{total} steps done."]
|
|
814
|
+
for step in self.plan_steps:
|
|
815
|
+
if step.status == "completed":
|
|
816
|
+
lines.append(f" [x] Step {step.index} (done)")
|
|
817
|
+
elif step.index == current_index:
|
|
818
|
+
lines.append(f" [~] Step {step.index}: {step.description} << CURRENT")
|
|
819
|
+
else:
|
|
820
|
+
lines.append(f" [ ] Step {step.index}: {step.description}")
|
|
821
|
+
return "\n".join(lines)
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
SESSIONS_DIR = os.path.join(".aru", "sessions")
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
def _generate_session_id() -> str:
|
|
828
|
+
"""Generate a short, unique session ID like 'a3f7b2'."""
|
|
829
|
+
raw = f"{time.time()}-{os.getpid()}-{random.randint(0, 999999)}"
|
|
830
|
+
return hashlib.md5(raw.encode()).hexdigest()[:8]
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
class SessionStore:
|
|
834
|
+
"""Persist and load sessions from .aru/sessions/."""
|
|
835
|
+
|
|
836
|
+
def __init__(self, base_dir: str | None = None):
|
|
837
|
+
self.base_dir = base_dir or os.path.join(os.getcwd(), SESSIONS_DIR)
|
|
838
|
+
os.makedirs(self.base_dir, exist_ok=True)
|
|
839
|
+
|
|
840
|
+
def _path(self, session_id: str) -> str:
|
|
841
|
+
return os.path.join(self.base_dir, f"{session_id}.json")
|
|
842
|
+
|
|
843
|
+
def save(self, session: Session):
|
|
844
|
+
"""Save session state to disk."""
|
|
845
|
+
session.updated_at = time.strftime("%Y-%m-%d %H:%M:%S")
|
|
846
|
+
with open(self._path(session.session_id), "w", encoding="utf-8") as f:
|
|
847
|
+
json.dump(session.to_dict(), f, indent=2, ensure_ascii=False)
|
|
848
|
+
|
|
849
|
+
def load(self, session_id: str) -> Session | None:
|
|
850
|
+
"""Load a session by ID (full or prefix match)."""
|
|
851
|
+
# Try exact match first
|
|
852
|
+
path = self._path(session_id)
|
|
853
|
+
if os.path.isfile(path):
|
|
854
|
+
return self._read(path)
|
|
855
|
+
|
|
856
|
+
# Try prefix match
|
|
857
|
+
for filename in os.listdir(self.base_dir):
|
|
858
|
+
if filename.startswith(session_id) and filename.endswith(".json"):
|
|
859
|
+
return self._read(os.path.join(self.base_dir, filename))
|
|
860
|
+
|
|
861
|
+
return None
|
|
862
|
+
|
|
863
|
+
def _read(self, path: str) -> Session | None:
|
|
864
|
+
try:
|
|
865
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
866
|
+
data = json.load(f)
|
|
867
|
+
return Session.from_dict(data)
|
|
868
|
+
except (json.JSONDecodeError, OSError, KeyError):
|
|
869
|
+
return None
|
|
870
|
+
|
|
871
|
+
def list_sessions(self, limit: int = 20) -> list[dict]:
|
|
872
|
+
"""List recent sessions, newest first."""
|
|
873
|
+
sessions = []
|
|
874
|
+
if not os.path.isdir(self.base_dir):
|
|
875
|
+
return sessions
|
|
876
|
+
|
|
877
|
+
for filename in os.listdir(self.base_dir):
|
|
878
|
+
if not filename.endswith(".json"):
|
|
879
|
+
continue
|
|
880
|
+
path = os.path.join(self.base_dir, filename)
|
|
881
|
+
try:
|
|
882
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
883
|
+
data = json.load(f)
|
|
884
|
+
sessions.append({
|
|
885
|
+
"session_id": data["session_id"],
|
|
886
|
+
"title": data.get("plan_task") or self._first_user_msg(data),
|
|
887
|
+
"model": data.get("model_ref", data.get("model_key", "?")),
|
|
888
|
+
"messages": len(data.get("history", [])),
|
|
889
|
+
"updated_at": data.get("updated_at", ""),
|
|
890
|
+
"cwd": data.get("cwd", ""),
|
|
891
|
+
})
|
|
892
|
+
except (json.JSONDecodeError, OSError, KeyError):
|
|
893
|
+
continue
|
|
894
|
+
|
|
895
|
+
sessions.sort(key=lambda s: s["updated_at"], reverse=True)
|
|
896
|
+
return sessions[:limit]
|
|
897
|
+
|
|
898
|
+
def _first_user_msg(self, data: dict) -> str:
|
|
899
|
+
for msg in data.get("history", []):
|
|
900
|
+
if msg["role"] == "user":
|
|
901
|
+
return msg["content"][:60].split("\n")[0]
|
|
902
|
+
return "(empty session)"
|
|
903
|
+
|
|
904
|
+
def load_last(self) -> Session | None:
|
|
905
|
+
"""Load the most recently updated session."""
|
|
906
|
+
sessions = self.list_sessions(limit=1)
|
|
907
|
+
if sessions:
|
|
908
|
+
return self.load(sessions[0]["session_id"])
|
|
909
|
+
return None
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
def create_general_agent(session: Session, config: AgentConfig | None = None):
|
|
913
|
+
"""Create the general-purpose agent."""
|
|
914
|
+
from agno.agent import Agent
|
|
915
|
+
from agno.compression.manager import CompressionManager
|
|
916
|
+
|
|
917
|
+
from aru.tools.codebase import GENERAL_TOOLS, _get_small_model_ref
|
|
918
|
+
|
|
919
|
+
extra = config.get_extra_instructions() if config else ""
|
|
920
|
+
|
|
921
|
+
return Agent(
|
|
922
|
+
name="Aru",
|
|
923
|
+
model=create_model(session.model_ref, max_tokens=8192),
|
|
924
|
+
tools=GENERAL_TOOLS,
|
|
925
|
+
instructions=_build_instructions("general", extra),
|
|
926
|
+
markdown=True,
|
|
927
|
+
# Compress tool results after 7 uncompressed tool calls to save tokens
|
|
928
|
+
compress_tool_results=True,
|
|
929
|
+
compression_manager=CompressionManager(
|
|
930
|
+
model=create_model(_get_small_model_ref(), max_tokens=1024),
|
|
931
|
+
compress_tool_results=True,
|
|
932
|
+
compress_tool_results_limit=7,
|
|
933
|
+
),
|
|
934
|
+
tool_call_limit=20,
|
|
935
|
+
)
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
def run_shell(command: str):
|
|
939
|
+
"""Run a shell command directly, streaming output to the terminal."""
|
|
940
|
+
console.print()
|
|
941
|
+
console.print(Panel(
|
|
942
|
+
Syntax(command, "bash", theme="monokai"),
|
|
943
|
+
title="[bold]Shell[/bold]",
|
|
944
|
+
border_style="dim",
|
|
945
|
+
expand=False,
|
|
946
|
+
))
|
|
947
|
+
try:
|
|
948
|
+
process = subprocess.Popen(
|
|
949
|
+
command,
|
|
950
|
+
shell=True,
|
|
951
|
+
stdout=subprocess.PIPE,
|
|
952
|
+
stderr=subprocess.STDOUT,
|
|
953
|
+
text=True,
|
|
954
|
+
cwd=os.getcwd(),
|
|
955
|
+
bufsize=1,
|
|
956
|
+
)
|
|
957
|
+
for line in process.stdout:
|
|
958
|
+
console.print(Text(line.rstrip()))
|
|
959
|
+
process.wait()
|
|
960
|
+
if process.returncode != 0:
|
|
961
|
+
console.print(f"[red]Exit code: {process.returncode}[/red]")
|
|
962
|
+
except KeyboardInterrupt:
|
|
963
|
+
process.kill()
|
|
964
|
+
console.print("\n[yellow]Interrupted.[/yellow]")
|
|
965
|
+
except Exception as e:
|
|
966
|
+
from rich.markup import escape
|
|
967
|
+
console.print(f"[red]Error: {escape(str(e))}[/red]")
|
|
968
|
+
console.print()
|
|
969
|
+
|
|
970
|
+
|
|
971
|
+
def _show_help(config: AgentConfig | None):
|
|
972
|
+
"""Display help with available commands."""
|
|
973
|
+
from rich.table import Table
|
|
974
|
+
|
|
975
|
+
table = Table(show_header=True, header_style="bold", box=None, padding=(0, 2))
|
|
976
|
+
table.add_column("Command", style="cyan")
|
|
977
|
+
table.add_column("Description", style="dim")
|
|
978
|
+
|
|
979
|
+
# Built-in commands
|
|
980
|
+
table.add_row("/plan <task>", "Create detailed implementation plan")
|
|
981
|
+
table.add_row("/model [provider/model]", "Switch models (e.g., ollama/llama3.1, openai/gpt-4o)")
|
|
982
|
+
table.add_row("/sessions", "List recent sessions")
|
|
983
|
+
table.add_row("/commands", "List custom commands")
|
|
984
|
+
table.add_row("/skills", "List available skills")
|
|
985
|
+
table.add_row("/help", "Show this help")
|
|
986
|
+
table.add_row("/quit", "Exit aru")
|
|
987
|
+
table.add_row("! <cmd>", "Run shell command")
|
|
988
|
+
|
|
989
|
+
# Custom commands
|
|
990
|
+
if config and config.commands:
|
|
991
|
+
table.add_row("", "") # Separator
|
|
992
|
+
for name, cmd_def in config.commands.items():
|
|
993
|
+
table.add_row(f"/{name}", cmd_def.description)
|
|
994
|
+
|
|
995
|
+
console.print(table)
|
|
996
|
+
console.print()
|
|
997
|
+
|
|
998
|
+
|
|
999
|
+
THINKING_PHRASES = [
|
|
1000
|
+
"Thinking...",
|
|
1001
|
+
"Cooking...",
|
|
1002
|
+
"Working...",
|
|
1003
|
+
"Making magic...",
|
|
1004
|
+
"Brewing ideas...",
|
|
1005
|
+
"Crunching code...",
|
|
1006
|
+
"Connecting the dots...",
|
|
1007
|
+
"Crafting a plan...",
|
|
1008
|
+
"On it...",
|
|
1009
|
+
"Diving deep...",
|
|
1010
|
+
"Almost there...",
|
|
1011
|
+
"Putting pieces together...",
|
|
1012
|
+
"Wiring things up...",
|
|
1013
|
+
"Spinning up neurons...",
|
|
1014
|
+
"Loading creativity...",
|
|
1015
|
+
]
|
|
1016
|
+
|
|
1017
|
+
|
|
1018
|
+
class StatusBar:
|
|
1019
|
+
"""A bottom status bar that cycles through fun phrases.
|
|
1020
|
+
|
|
1021
|
+
Renders as a thin separator line + spinner text. Rich's Live calls
|
|
1022
|
+
``__rich_console__`` on every refresh tick, so we rotate the phrase
|
|
1023
|
+
based on wall-clock time — no extra threads needed.
|
|
1024
|
+
"""
|
|
1025
|
+
|
|
1026
|
+
def __init__(self, interval: float = 3.0):
|
|
1027
|
+
self._interval = interval
|
|
1028
|
+
self._phrases = list(THINKING_PHRASES)
|
|
1029
|
+
random.shuffle(self._phrases)
|
|
1030
|
+
self._index = 0
|
|
1031
|
+
self._last_switch = time.monotonic()
|
|
1032
|
+
self._override: str | None = None
|
|
1033
|
+
|
|
1034
|
+
@property
|
|
1035
|
+
def current_text(self) -> str:
|
|
1036
|
+
if self._override is not None:
|
|
1037
|
+
return self._override
|
|
1038
|
+
return self._phrases[self._index % len(self._phrases)]
|
|
1039
|
+
|
|
1040
|
+
def set_text(self, text: str):
|
|
1041
|
+
self._override = text
|
|
1042
|
+
|
|
1043
|
+
def resume_cycling(self):
|
|
1044
|
+
self._override = None
|
|
1045
|
+
self._last_switch = time.monotonic()
|
|
1046
|
+
|
|
1047
|
+
def _maybe_rotate(self):
|
|
1048
|
+
now = time.monotonic()
|
|
1049
|
+
if now - self._last_switch >= self._interval:
|
|
1050
|
+
self._last_switch = now
|
|
1051
|
+
self._index += 1
|
|
1052
|
+
if self._index >= len(self._phrases):
|
|
1053
|
+
random.shuffle(self._phrases)
|
|
1054
|
+
self._index = 0
|
|
1055
|
+
self._override = None
|
|
1056
|
+
|
|
1057
|
+
def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
|
|
1058
|
+
self._maybe_rotate()
|
|
1059
|
+
spinner = Spinner("dots", text=f"[dim]{self.current_text}[/dim]", style="cyan")
|
|
1060
|
+
yield from spinner.__rich_console__(console, options)
|
|
1061
|
+
|
|
1062
|
+
def __rich_measure__(self, console: Console, options: ConsoleOptions) -> Measurement:
|
|
1063
|
+
return Measurement(1, options.max_width)
|
|
1064
|
+
|
|
1065
|
+
|
|
1066
|
+
@dataclass
|
|
1067
|
+
class AgentRunResult:
|
|
1068
|
+
"""Result from run_agent_capture including text output and tool call history."""
|
|
1069
|
+
content: str | None = None
|
|
1070
|
+
tool_calls: list[str] = field(default_factory=list)
|
|
1071
|
+
|
|
1072
|
+
def with_tools_summary(self) -> str | None:
|
|
1073
|
+
"""Return content with appended tool call summary for session history."""
|
|
1074
|
+
if not self.content:
|
|
1075
|
+
return self.content
|
|
1076
|
+
if not self.tool_calls:
|
|
1077
|
+
return self.content
|
|
1078
|
+
tools_section = "\n".join(f" - {t}" for t in self.tool_calls)
|
|
1079
|
+
return f"{self.content}\n\n[Tools]\n{tools_section}"
|
|
1080
|
+
|
|
1081
|
+
|
|
1082
|
+
# Categories of tools that modify files (for highlighting in history)
|
|
1083
|
+
_MUTATION_TOOLS = {"write_file", "write_files", "edit_file", "edit_files", "bash", "run_command"}
|
|
1084
|
+
|
|
1085
|
+
|
|
1086
|
+
TOOL_DISPLAY_NAMES = {
|
|
1087
|
+
"read_file": "Read",
|
|
1088
|
+
"read_file_smart": "ReadSmart",
|
|
1089
|
+
"write_file": "Write",
|
|
1090
|
+
"write_files": "Write",
|
|
1091
|
+
"edit_file": "Edit",
|
|
1092
|
+
"edit_files": "Edit",
|
|
1093
|
+
"glob_search": "Glob",
|
|
1094
|
+
"grep_search": "Grep",
|
|
1095
|
+
"list_directory": "List",
|
|
1096
|
+
"bash": "Bash",
|
|
1097
|
+
"code_structure": "Structure",
|
|
1098
|
+
"find_dependencies": "Deps",
|
|
1099
|
+
"rank_files": "Rank",
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
TOOL_PRIMARY_ARG = {
|
|
1103
|
+
"read_file": "file_path",
|
|
1104
|
+
"read_file_smart": "file_path",
|
|
1105
|
+
"write_file": "file_path",
|
|
1106
|
+
"edit_file": "file_path",
|
|
1107
|
+
"glob_search": "pattern",
|
|
1108
|
+
"grep_search": "pattern",
|
|
1109
|
+
"list_directory": "directory",
|
|
1110
|
+
"bash": "command",
|
|
1111
|
+
"code_structure": "file_path",
|
|
1112
|
+
"find_dependencies": "file_path",
|
|
1113
|
+
"rank_files": "task",
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
|
|
1117
|
+
def _format_tool_label(tool_name: str, tool_args: dict | None) -> str:
|
|
1118
|
+
"""Format a tool call into a Claude Code-style label like Read(file_path)."""
|
|
1119
|
+
display = TOOL_DISPLAY_NAMES.get(tool_name, tool_name)
|
|
1120
|
+
if not tool_args:
|
|
1121
|
+
return display
|
|
1122
|
+
|
|
1123
|
+
# Batch tools: show count
|
|
1124
|
+
if tool_name == "write_files":
|
|
1125
|
+
files = tool_args.get("files", [])
|
|
1126
|
+
return f"{display}({len(files)} files)"
|
|
1127
|
+
if tool_name == "edit_files":
|
|
1128
|
+
edits = tool_args.get("edits", [])
|
|
1129
|
+
return f"{display}({len(edits)} edits)"
|
|
1130
|
+
|
|
1131
|
+
# Single-arg tools: show the primary arg value
|
|
1132
|
+
primary_key = TOOL_PRIMARY_ARG.get(tool_name)
|
|
1133
|
+
if primary_key and primary_key in tool_args:
|
|
1134
|
+
value = str(tool_args[primary_key])
|
|
1135
|
+
# Truncate long values
|
|
1136
|
+
if len(value) > 60:
|
|
1137
|
+
value = value[:57] + "..."
|
|
1138
|
+
return f"{display}({value})"
|
|
1139
|
+
|
|
1140
|
+
return display
|
|
1141
|
+
|
|
1142
|
+
|
|
1143
|
+
class ToolTracker:
|
|
1144
|
+
"""Tracks active tool calls with timing, displayed inside the Live area."""
|
|
1145
|
+
|
|
1146
|
+
def __init__(self):
|
|
1147
|
+
self._active: dict[str, tuple[str, float]] = {} # id -> (label, start_time)
|
|
1148
|
+
self._completed: list[tuple[str, float]] = [] # (label, duration)
|
|
1149
|
+
|
|
1150
|
+
def start(self, tool_id: str, label: str):
|
|
1151
|
+
self._active[tool_id] = (label, time.monotonic())
|
|
1152
|
+
|
|
1153
|
+
def complete(self, tool_id: str) -> tuple[str, float] | None:
|
|
1154
|
+
entry = self._active.pop(tool_id, None)
|
|
1155
|
+
if entry:
|
|
1156
|
+
label, start = entry
|
|
1157
|
+
duration = time.monotonic() - start
|
|
1158
|
+
self._completed.append((label, duration))
|
|
1159
|
+
return label, duration
|
|
1160
|
+
return None
|
|
1161
|
+
|
|
1162
|
+
@property
|
|
1163
|
+
def active_labels(self) -> list[tuple[str, float]]:
|
|
1164
|
+
"""Return (label, elapsed_seconds) for each active tool."""
|
|
1165
|
+
now = time.monotonic()
|
|
1166
|
+
return [(label, now - start) for label, start in self._active.values()]
|
|
1167
|
+
|
|
1168
|
+
def pop_completed(self) -> list[tuple[str, float]]:
|
|
1169
|
+
"""Drain and return completed tools since last call."""
|
|
1170
|
+
items = self._completed[:]
|
|
1171
|
+
self._completed.clear()
|
|
1172
|
+
return items
|
|
1173
|
+
|
|
1174
|
+
|
|
1175
|
+
class StreamingDisplay:
|
|
1176
|
+
"""Shows un-flushed streaming content + active tool indicators + status bar.
|
|
1177
|
+
|
|
1178
|
+
Active tools are rendered inline (inside Live) so they're always visible.
|
|
1179
|
+
Completed tools are flushed as static output above Live.
|
|
1180
|
+
"""
|
|
1181
|
+
|
|
1182
|
+
def __init__(self, status_bar: StatusBar):
|
|
1183
|
+
self.status_bar = status_bar
|
|
1184
|
+
self.tool_tracker = ToolTracker()
|
|
1185
|
+
self._flushed_len: int = 0
|
|
1186
|
+
self._accumulated: str = ""
|
|
1187
|
+
self._content: Markdown | None = None
|
|
1188
|
+
|
|
1189
|
+
def set_content(self, accumulated: str):
|
|
1190
|
+
self._accumulated = accumulated
|
|
1191
|
+
delta = accumulated[self._flushed_len:]
|
|
1192
|
+
self.content = Markdown(delta) if delta else None
|
|
1193
|
+
|
|
1194
|
+
def flush(self):
|
|
1195
|
+
delta = self._accumulated[self._flushed_len:]
|
|
1196
|
+
if delta:
|
|
1197
|
+
console.print(Markdown(delta))
|
|
1198
|
+
self._flushed_len = len(self._accumulated)
|
|
1199
|
+
self.content = None
|
|
1200
|
+
|
|
1201
|
+
@property
|
|
1202
|
+
def content(self) -> Markdown | None:
|
|
1203
|
+
return self._content
|
|
1204
|
+
|
|
1205
|
+
@content.setter
|
|
1206
|
+
def content(self, value: Markdown | None):
|
|
1207
|
+
self._content = value
|
|
1208
|
+
|
|
1209
|
+
def __rich_console__(self, rconsole: Console, options: ConsoleOptions) -> RenderResult:
|
|
1210
|
+
if self._content is not None:
|
|
1211
|
+
yield self._content
|
|
1212
|
+
yield Text()
|
|
1213
|
+
|
|
1214
|
+
# Render active tools with spinner and elapsed time
|
|
1215
|
+
active = self.tool_tracker.active_labels
|
|
1216
|
+
if active:
|
|
1217
|
+
for label, elapsed in active:
|
|
1218
|
+
elapsed_str = f"{elapsed:.1f}s" if elapsed >= 1.0 else ""
|
|
1219
|
+
tool_line = Text.assemble(
|
|
1220
|
+
(" ", ""),
|
|
1221
|
+
("↻ ", "bold cyan"),
|
|
1222
|
+
(label, "bold"),
|
|
1223
|
+
(f" {elapsed_str}" if elapsed_str else "", "dim"),
|
|
1224
|
+
)
|
|
1225
|
+
yield tool_line
|
|
1226
|
+
yield Text()
|
|
1227
|
+
|
|
1228
|
+
yield self.status_bar
|
|
1229
|
+
|
|
1230
|
+
def __rich_measure__(self, rconsole: Console, options: ConsoleOptions) -> Measurement:
|
|
1231
|
+
return Measurement(1, options.max_width)
|
|
1232
|
+
|
|
1233
|
+
|
|
1234
|
+
async def run_agent_capture(agent, message: str, session: "Session | None" = None, lightweight: bool = False) -> AgentRunResult:
|
|
1235
|
+
"""Run agent with async streaming display and parallel tool execution.
|
|
1236
|
+
|
|
1237
|
+
Args:
|
|
1238
|
+
agent: The Agno agent to run.
|
|
1239
|
+
message: The user message/prompt.
|
|
1240
|
+
session: Optional session for history and context.
|
|
1241
|
+
lightweight: If True, skip tree/git/plan context and history (for executor steps).
|
|
1242
|
+
|
|
1243
|
+
Returns:
|
|
1244
|
+
AgentRunResult with text content and list of tool call labels.
|
|
1245
|
+
"""
|
|
1246
|
+
from agno.models.message import Message
|
|
1247
|
+
from agno.run.agent import (
|
|
1248
|
+
RunContentEvent,
|
|
1249
|
+
RunOutput,
|
|
1250
|
+
ToolCallCompletedEvent,
|
|
1251
|
+
ToolCallStartedEvent,
|
|
1252
|
+
)
|
|
1253
|
+
|
|
1254
|
+
console.print()
|
|
1255
|
+
final_content = None
|
|
1256
|
+
collected_tool_calls: list[str] = []
|
|
1257
|
+
|
|
1258
|
+
try:
|
|
1259
|
+
from aru.tools.codebase import set_display, set_live
|
|
1260
|
+
from aru.tools.tasklist import set_live as tasklist_set_live, set_display as tasklist_set_display
|
|
1261
|
+
|
|
1262
|
+
status = StatusBar(interval=3.0)
|
|
1263
|
+
display = StreamingDisplay(status)
|
|
1264
|
+
tracker = display.tool_tracker
|
|
1265
|
+
|
|
1266
|
+
# Build enriched message with environment context (using cache)
|
|
1267
|
+
dynamic_parts = []
|
|
1268
|
+
cwd = os.getcwd()
|
|
1269
|
+
dynamic_parts.append(f"The current working directory is: {cwd}")
|
|
1270
|
+
|
|
1271
|
+
if session and not lightweight:
|
|
1272
|
+
env_context_parts = []
|
|
1273
|
+
tree_text = session.get_cached_tree(cwd)
|
|
1274
|
+
if tree_text:
|
|
1275
|
+
env_context_parts.append(f"Directory Tree (max depth 3):\n```text\n{tree_text}\n```")
|
|
1276
|
+
|
|
1277
|
+
git_status = session.get_cached_git_status(cwd)
|
|
1278
|
+
if git_status:
|
|
1279
|
+
env_context_parts.append(f"Git status:\n{git_status}")
|
|
1280
|
+
|
|
1281
|
+
if env_context_parts:
|
|
1282
|
+
dynamic_parts.append("## Environment Context\n" + "\n\n".join(env_context_parts))
|
|
1283
|
+
|
|
1284
|
+
# Include only compact plan progress (not full plan text)
|
|
1285
|
+
if session.current_plan:
|
|
1286
|
+
dynamic_parts.append(f"## Active Plan\nTask: {session.plan_task}\n\n{session.render_plan_progress()}")
|
|
1287
|
+
|
|
1288
|
+
# Token budget warning
|
|
1289
|
+
warning = session.check_budget_warning()
|
|
1290
|
+
if warning:
|
|
1291
|
+
console.print(warning)
|
|
1292
|
+
|
|
1293
|
+
dynamic_context = "\n\n".join(dynamic_parts)
|
|
1294
|
+
run_message = f"{dynamic_context}\n\n---\n\n## Current Task/Message\n{message}"
|
|
1295
|
+
|
|
1296
|
+
# Build conversation history as real messages for the LLM
|
|
1297
|
+
# Skip history for lightweight executor steps — they don't need prior conversation
|
|
1298
|
+
from aru.context import prune_history
|
|
1299
|
+
history_messages: list[Message] = []
|
|
1300
|
+
if session and session.history and not lightweight:
|
|
1301
|
+
# The last message is the current user input (already added before calling this function)
|
|
1302
|
+
prior_history = session.history[:-1]
|
|
1303
|
+
pruned = prune_history(prior_history)
|
|
1304
|
+
for msg in pruned:
|
|
1305
|
+
history_messages.append(Message(role=msg["role"], content=msg["content"], from_history=True))
|
|
1306
|
+
|
|
1307
|
+
# Combine: history messages + current enriched message
|
|
1308
|
+
if history_messages:
|
|
1309
|
+
history_messages.append(Message(role="user", content=run_message))
|
|
1310
|
+
agent_input = history_messages
|
|
1311
|
+
else:
|
|
1312
|
+
agent_input = run_message
|
|
1313
|
+
|
|
1314
|
+
run_output = None
|
|
1315
|
+
with Live(display, console=console, refresh_per_second=10) as live:
|
|
1316
|
+
set_live(live)
|
|
1317
|
+
set_display(display)
|
|
1318
|
+
tasklist_set_live(live)
|
|
1319
|
+
tasklist_set_display(display)
|
|
1320
|
+
accumulated = ""
|
|
1321
|
+
async for event in agent.arun(agent_input, stream=True, stream_events=True, yield_run_output=True):
|
|
1322
|
+
if isinstance(event, RunOutput):
|
|
1323
|
+
run_output = event
|
|
1324
|
+
break
|
|
1325
|
+
|
|
1326
|
+
if isinstance(event, ToolCallStartedEvent):
|
|
1327
|
+
if hasattr(event, "tool") and event.tool:
|
|
1328
|
+
tool_name = event.tool.tool_name or "tool"
|
|
1329
|
+
tool_args = event.tool.tool_args or None
|
|
1330
|
+
tool_id = getattr(event.tool, "tool_call_id", None) or tool_name
|
|
1331
|
+
else:
|
|
1332
|
+
tool_name = getattr(event, "tool_name", "tool")
|
|
1333
|
+
tool_args = getattr(event, "tool_args", None)
|
|
1334
|
+
tool_id = getattr(event, "tool_call_id", None) or tool_name
|
|
1335
|
+
label = _format_tool_label(tool_name, tool_args)
|
|
1336
|
+
collected_tool_calls.append(label)
|
|
1337
|
+
# Flush any accumulated content before tool runs
|
|
1338
|
+
if accumulated[display._flushed_len:]:
|
|
1339
|
+
live.stop()
|
|
1340
|
+
display.flush()
|
|
1341
|
+
live.start()
|
|
1342
|
+
live._live_render._shape = None
|
|
1343
|
+
tracker.start(tool_id, label)
|
|
1344
|
+
status.set_text(f"{label}...")
|
|
1345
|
+
live.update(display)
|
|
1346
|
+
|
|
1347
|
+
elif isinstance(event, ToolCallCompletedEvent):
|
|
1348
|
+
if hasattr(event, "tool") and event.tool:
|
|
1349
|
+
tool_id = getattr(event.tool, "tool_call_id", None) or getattr(event.tool, "tool_name", "tool")
|
|
1350
|
+
else:
|
|
1351
|
+
tool_id = getattr(event, "tool_call_id", None) or getattr(event, "tool_name", "tool")
|
|
1352
|
+
|
|
1353
|
+
result = tracker.complete(tool_id)
|
|
1354
|
+
# Flush completed tools as static output above Live
|
|
1355
|
+
for label, duration in tracker.pop_completed():
|
|
1356
|
+
dur_str = f" {duration:.1f}s" if duration >= 0.5 else ""
|
|
1357
|
+
live.console.print(Text.assemble(
|
|
1358
|
+
(" ", ""),
|
|
1359
|
+
("\u2713 ", "bold green"),
|
|
1360
|
+
(label, "dim"),
|
|
1361
|
+
(dur_str, "dim cyan"),
|
|
1362
|
+
))
|
|
1363
|
+
if not tracker.active_labels:
|
|
1364
|
+
status.resume_cycling()
|
|
1365
|
+
live.update(display)
|
|
1366
|
+
|
|
1367
|
+
elif isinstance(event, RunContentEvent):
|
|
1368
|
+
if hasattr(event, "content") and event.content:
|
|
1369
|
+
accumulated += event.content
|
|
1370
|
+
unflushed = accumulated[display._flushed_len:]
|
|
1371
|
+
|
|
1372
|
+
# Auto-flush long chunks to prevent rich.Live smearing
|
|
1373
|
+
if unflushed.count("\n") > 15:
|
|
1374
|
+
break_point = unflushed.rfind("\n\n")
|
|
1375
|
+
if break_point == -1:
|
|
1376
|
+
break_point = unflushed.rfind("\n")
|
|
1377
|
+
|
|
1378
|
+
if break_point != -1:
|
|
1379
|
+
chunk = unflushed[:break_point + 1]
|
|
1380
|
+
# Only flush if we are outside of a code block (balanced ```)
|
|
1381
|
+
if chunk.count("```") % 2 == 0:
|
|
1382
|
+
# Clear live content before stopping to prevent
|
|
1383
|
+
# rich.Live re-rendering stale text on stop()
|
|
1384
|
+
display.content = None
|
|
1385
|
+
live.stop()
|
|
1386
|
+
console.print(Markdown(chunk))
|
|
1387
|
+
display._flushed_len += len(chunk)
|
|
1388
|
+
live.start()
|
|
1389
|
+
live._live_render._shape = None
|
|
1390
|
+
|
|
1391
|
+
display.set_content(accumulated)
|
|
1392
|
+
live.update(display)
|
|
1393
|
+
|
|
1394
|
+
set_live(None)
|
|
1395
|
+
set_display(None)
|
|
1396
|
+
|
|
1397
|
+
if run_output and session and hasattr(run_output, "metrics"):
|
|
1398
|
+
session.track_tokens(run_output.metrics)
|
|
1399
|
+
|
|
1400
|
+
# Layer 3: Auto-compact conversation when approaching context limits
|
|
1401
|
+
from aru.context import should_compact, compact_conversation
|
|
1402
|
+
if should_compact(session.total_input_tokens, session.model_id):
|
|
1403
|
+
try:
|
|
1404
|
+
session.history = await compact_conversation(
|
|
1405
|
+
session.history, session.model_ref, session.plan_task
|
|
1406
|
+
)
|
|
1407
|
+
console.print("[dim]Context compacted to save tokens.[/dim]")
|
|
1408
|
+
except Exception:
|
|
1409
|
+
pass # compaction is best-effort
|
|
1410
|
+
|
|
1411
|
+
# Print only un-flushed content
|
|
1412
|
+
final_content = accumulated or final_content
|
|
1413
|
+
remaining = (final_content or "")[display._flushed_len:]
|
|
1414
|
+
if remaining:
|
|
1415
|
+
console.print(Markdown(remaining))
|
|
1416
|
+
|
|
1417
|
+
except (KeyboardInterrupt, asyncio.CancelledError):
|
|
1418
|
+
set_live(None)
|
|
1419
|
+
set_display(None)
|
|
1420
|
+
console.print("\n[yellow]Interrupted.[/yellow]")
|
|
1421
|
+
except Exception as e:
|
|
1422
|
+
set_live(None)
|
|
1423
|
+
set_display(None)
|
|
1424
|
+
from rich.markup import escape
|
|
1425
|
+
console.print(f"[red]Error: {escape(str(e))}[/red]")
|
|
1426
|
+
|
|
1427
|
+
console.print()
|
|
1428
|
+
return AgentRunResult(content=final_content, tool_calls=collected_tool_calls)
|
|
1429
|
+
|
|
1430
|
+
|
|
1431
|
+
def ask_yes_no(prompt: str) -> bool:
|
|
1432
|
+
"""Ask the user a yes/no question."""
|
|
1433
|
+
try:
|
|
1434
|
+
answer = console.input(f"[bold yellow]{prompt} (y/n):[/bold yellow] ").strip().lower()
|
|
1435
|
+
return answer in ("y", "yes", "s", "sim")
|
|
1436
|
+
except (EOFError, KeyboardInterrupt):
|
|
1437
|
+
return False
|
|
1438
|
+
|
|
1439
|
+
|
|
1440
|
+
def _extract_plan_file_paths(plan_text: str) -> list[str]:
|
|
1441
|
+
"""Extract file paths mentioned in plan steps (e.g., 'in `aru/cli.py`')."""
|
|
1442
|
+
# Match backtick-quoted paths that look like file paths
|
|
1443
|
+
matches = re.findall(r"`([^`]+\.\w{1,5})`", plan_text or "")
|
|
1444
|
+
seen = set()
|
|
1445
|
+
paths = []
|
|
1446
|
+
for m in matches:
|
|
1447
|
+
norm = os.path.normpath(m)
|
|
1448
|
+
if norm not in seen and os.path.isfile(norm):
|
|
1449
|
+
seen.add(norm)
|
|
1450
|
+
paths.append(norm)
|
|
1451
|
+
return paths
|
|
1452
|
+
|
|
1453
|
+
|
|
1454
|
+
def _build_file_context(file_paths: list[str], max_total: int = 20_000) -> str:
|
|
1455
|
+
"""Read files and build a context string, respecting a total char budget."""
|
|
1456
|
+
if not file_paths:
|
|
1457
|
+
return ""
|
|
1458
|
+
parts = []
|
|
1459
|
+
total = 0
|
|
1460
|
+
for path in file_paths:
|
|
1461
|
+
try:
|
|
1462
|
+
content = open(path, "r", encoding="utf-8").read()
|
|
1463
|
+
if total + len(content) > max_total:
|
|
1464
|
+
continue
|
|
1465
|
+
total += len(content)
|
|
1466
|
+
parts.append(f"### `{path}`\n```\n{content}\n```")
|
|
1467
|
+
except Exception:
|
|
1468
|
+
continue
|
|
1469
|
+
if not parts:
|
|
1470
|
+
return ""
|
|
1471
|
+
return "## Pre-loaded file contents (do NOT re-read these files)\n\n" + "\n\n".join(parts)
|
|
1472
|
+
|
|
1473
|
+
|
|
1474
|
+
async def execute_plan_steps(session: Session, executor_factory) -> str | None:
|
|
1475
|
+
"""Execute plan steps one by one with live progress tracking.
|
|
1476
|
+
|
|
1477
|
+
Shows a checkbox progress panel that updates as each step completes.
|
|
1478
|
+
Each step runs as a separate executor call with full context.
|
|
1479
|
+
"""
|
|
1480
|
+
# Pre-load files mentioned in the plan to avoid redundant reads per step
|
|
1481
|
+
plan_files = _extract_plan_file_paths(session.current_plan)
|
|
1482
|
+
file_context = _build_file_context(plan_files)
|
|
1483
|
+
|
|
1484
|
+
if not session.plan_steps:
|
|
1485
|
+
# No structured steps — fall back to single execution
|
|
1486
|
+
executor = executor_factory()
|
|
1487
|
+
exec_prompt = (
|
|
1488
|
+
f"Execute the following plan step by step.\n\n"
|
|
1489
|
+
f"## Task\n{session.plan_task}\n\n"
|
|
1490
|
+
f"## Plan\n{session.current_plan}"
|
|
1491
|
+
)
|
|
1492
|
+
run_result = await run_agent_capture(executor, exec_prompt, session, lightweight=True)
|
|
1493
|
+
return run_result.with_tools_summary()
|
|
1494
|
+
|
|
1495
|
+
all_results = []
|
|
1496
|
+
completed_context = ""
|
|
1497
|
+
|
|
1498
|
+
for step in session.plan_steps:
|
|
1499
|
+
# Show current progress
|
|
1500
|
+
console.print()
|
|
1501
|
+
console.print(Panel(
|
|
1502
|
+
Text.from_markup(session.render_plan_progress()),
|
|
1503
|
+
title="[bold]Plan Progress[/bold]",
|
|
1504
|
+
border_style="blue",
|
|
1505
|
+
padding=(0, 1),
|
|
1506
|
+
))
|
|
1507
|
+
console.print()
|
|
1508
|
+
|
|
1509
|
+
# Mark step as in progress
|
|
1510
|
+
step.status = "in_progress"
|
|
1511
|
+
console.print(f"[bold yellow]>>> Step {step.index}:[/bold yellow] {step.description}")
|
|
1512
|
+
|
|
1513
|
+
# Build step-specific prompt — compact to save tokens
|
|
1514
|
+
# Only show current step + compact progress (not full descriptions of other steps)
|
|
1515
|
+
compact_progress = session.render_compact_progress(step.index)
|
|
1516
|
+
step_prompt_parts = [
|
|
1517
|
+
f"## Task: {session.plan_task}\n",
|
|
1518
|
+
f"## Current Step ({step.index}/{len(session.plan_steps)})\n{step.description}\n",
|
|
1519
|
+
f"## Progress\n{compact_progress}\n",
|
|
1520
|
+
"IMPORTANT: Just execute this step. Do NOT repeat completed steps or summarize.",
|
|
1521
|
+
]
|
|
1522
|
+
if file_context:
|
|
1523
|
+
step_prompt_parts.insert(1, file_context)
|
|
1524
|
+
step_prompt = "\n".join(step_prompt_parts)
|
|
1525
|
+
|
|
1526
|
+
# Reset task store for each new step
|
|
1527
|
+
from aru.tools.tasklist import reset_task_store
|
|
1528
|
+
reset_task_store()
|
|
1529
|
+
|
|
1530
|
+
# Execute this step (lightweight=True to skip tree/git/history)
|
|
1531
|
+
executor = executor_factory()
|
|
1532
|
+
try:
|
|
1533
|
+
run_result = await run_agent_capture(executor, step_prompt, session, lightweight=True)
|
|
1534
|
+
content = run_result.content
|
|
1535
|
+
|
|
1536
|
+
# Check task store as ground truth for step completion
|
|
1537
|
+
from aru.tools.tasklist import get_task_store
|
|
1538
|
+
store = get_task_store()
|
|
1539
|
+
all_tasks = store.get_all()
|
|
1540
|
+
tasks_completed = sum(1 for t in all_tasks if t["status"] == "completed")
|
|
1541
|
+
tasks_failed = sum(1 for t in all_tasks if t["status"] == "failed")
|
|
1542
|
+
tasks_total = len(all_tasks)
|
|
1543
|
+
tasks_all_done = tasks_total > 0 and (tasks_completed + tasks_failed == tasks_total)
|
|
1544
|
+
|
|
1545
|
+
# Determine step outcome: task store takes precedence over content
|
|
1546
|
+
step_failed = False
|
|
1547
|
+
if tasks_all_done:
|
|
1548
|
+
if tasks_failed > 0 and tasks_completed == 0:
|
|
1549
|
+
step_failed = True
|
|
1550
|
+
# else: at least some tasks completed → step succeeded
|
|
1551
|
+
elif content:
|
|
1552
|
+
step_failed = (
|
|
1553
|
+
content.startswith("Error")
|
|
1554
|
+
or "Error from OpenAI API" in content
|
|
1555
|
+
or "Error in Agent run" in content
|
|
1556
|
+
)
|
|
1557
|
+
|
|
1558
|
+
if step_failed:
|
|
1559
|
+
step.status = "failed"
|
|
1560
|
+
fail_msg = content[:200] if content else f"{tasks_failed}/{tasks_total} subtasks failed"
|
|
1561
|
+
console.print(f"\n[red]Step {step.index} failed: {fail_msg}[/red]")
|
|
1562
|
+
if not get_skip_permissions() and not ask_yes_no("Continue with remaining steps?"):
|
|
1563
|
+
break
|
|
1564
|
+
elif content or tasks_all_done:
|
|
1565
|
+
step.status = "completed"
|
|
1566
|
+
# Build step result text
|
|
1567
|
+
summary = content or f"All {tasks_completed} subtasks completed."
|
|
1568
|
+
step_text = f"### Step {step.index}: {step.description}\n{summary}"
|
|
1569
|
+
if run_result.tool_calls:
|
|
1570
|
+
tools_str = ", ".join(run_result.tool_calls)
|
|
1571
|
+
step_text += f"\nTools: {tools_str}"
|
|
1572
|
+
all_results.append(step_text)
|
|
1573
|
+
completed_context += f"\n- Step {step.index} ({step.description}): Done"
|
|
1574
|
+
else:
|
|
1575
|
+
step.status = "completed"
|
|
1576
|
+
completed_context += f"\n- Step {step.index} ({step.description}): Done (no output)"
|
|
1577
|
+
except (KeyboardInterrupt, asyncio.CancelledError):
|
|
1578
|
+
step.status = "failed"
|
|
1579
|
+
console.print(f"\n[yellow]Step {step.index} interrupted.[/yellow]")
|
|
1580
|
+
# Ask if user wants to continue with remaining steps
|
|
1581
|
+
if not get_skip_permissions() and not ask_yes_no("Continue with remaining steps?"):
|
|
1582
|
+
break
|
|
1583
|
+
except Exception as e:
|
|
1584
|
+
step.status = "failed"
|
|
1585
|
+
console.print(f"\n[red]Step {step.index} failed: {e}[/red]")
|
|
1586
|
+
if not get_skip_permissions() and not ask_yes_no("Continue with remaining steps?"):
|
|
1587
|
+
break
|
|
1588
|
+
|
|
1589
|
+
# Final progress display
|
|
1590
|
+
console.print()
|
|
1591
|
+
console.print(Panel(
|
|
1592
|
+
Text.from_markup(session.render_plan_progress()),
|
|
1593
|
+
title="[bold]Plan Complete[/bold]",
|
|
1594
|
+
border_style="green" if all(s.status == "completed" for s in session.plan_steps) else "yellow",
|
|
1595
|
+
padding=(0, 1),
|
|
1596
|
+
))
|
|
1597
|
+
|
|
1598
|
+
return "\n\n".join(all_results) if all_results else None
|
|
1599
|
+
|
|
1600
|
+
|
|
1601
|
+
async def run_cli(skip_permissions: bool = False, resume_id: str | None = None):
|
|
1602
|
+
"""Main REPL loop."""
|
|
1603
|
+
from aru.tools.codebase import set_console, set_model_id, set_small_model_ref, set_skip_permissions, reset_allowed_actions, set_permission_rules, set_on_file_mutation
|
|
1604
|
+
set_console(console)
|
|
1605
|
+
set_skip_permissions(skip_permissions)
|
|
1606
|
+
|
|
1607
|
+
store = SessionStore()
|
|
1608
|
+
|
|
1609
|
+
def _sync_model(sess: Session):
|
|
1610
|
+
"""Sync the model IDs to the tools module from the session's model_ref."""
|
|
1611
|
+
set_model_id(sess.model_id)
|
|
1612
|
+
# Determine small model for sub-agents based on provider
|
|
1613
|
+
small_ref = config.model_defaults.get("small") if config else None
|
|
1614
|
+
if not small_ref:
|
|
1615
|
+
provider_key, _ = resolve_model_ref(sess.model_ref)
|
|
1616
|
+
# Use same provider but pick a small/fast model
|
|
1617
|
+
_small_defaults = {
|
|
1618
|
+
"anthropic": "anthropic/claude-haiku-4-5",
|
|
1619
|
+
"openai": "openai/gpt-4o-mini",
|
|
1620
|
+
"groq": "groq/llama-3.1-8b-instant",
|
|
1621
|
+
"deepseek": "deepseek/deepseek-chat",
|
|
1622
|
+
"ollama": "ollama/llama3.1",
|
|
1623
|
+
}
|
|
1624
|
+
small_ref = _small_defaults.get(provider_key, sess.model_ref)
|
|
1625
|
+
set_small_model_ref(small_ref)
|
|
1626
|
+
|
|
1627
|
+
# Load project configuration (AGENTS.md, .agents/commands, .agents/skills)
|
|
1628
|
+
config = load_config()
|
|
1629
|
+
if config.agents_md:
|
|
1630
|
+
console.print("[dim]Loaded AGENTS.md[/dim]")
|
|
1631
|
+
if config.commands:
|
|
1632
|
+
console.print(f"[dim]Loaded {len(config.commands)} custom command(s): {', '.join(f'/{k}' for k in config.commands)}[/dim]")
|
|
1633
|
+
if config.skills:
|
|
1634
|
+
console.print(f"[dim]Loaded {len(config.skills)} skill(s): {', '.join(config.skills.keys())}[/dim]")
|
|
1635
|
+
permission_allow = config.permissions.get("allow", [])
|
|
1636
|
+
if permission_allow:
|
|
1637
|
+
set_permission_rules(permission_allow)
|
|
1638
|
+
console.print(f"[dim]Loaded {len(permission_allow)} permission rule(s)[/dim]")
|
|
1639
|
+
|
|
1640
|
+
extra_instructions = config.get_extra_instructions()
|
|
1641
|
+
|
|
1642
|
+
# Resume or create session
|
|
1643
|
+
if resume_id:
|
|
1644
|
+
if resume_id == "last":
|
|
1645
|
+
session = store.load_last()
|
|
1646
|
+
else:
|
|
1647
|
+
session = store.load(resume_id)
|
|
1648
|
+
if session is None:
|
|
1649
|
+
console.print(f"[red]Session not found: {resume_id}[/red]")
|
|
1650
|
+
return
|
|
1651
|
+
console.print(Markdown(f"# aru - Resuming session `{session.session_id}`"))
|
|
1652
|
+
console.print(f"[dim]Title: {session.title}[/dim]")
|
|
1653
|
+
console.print(f"[dim]Messages: {len(session.history)} | Created: {session.created_at}[/dim]")
|
|
1654
|
+
if session.history:
|
|
1655
|
+
console.print(f"[green]Session loaded — {len(session.history)} messages restored.[/green]")
|
|
1656
|
+
if session.current_plan:
|
|
1657
|
+
console.print(f"[dim]Active plan: {session.plan_task}[/dim]")
|
|
1658
|
+
if session.plan_steps:
|
|
1659
|
+
completed = sum(1 for s in session.plan_steps if s.status == "completed")
|
|
1660
|
+
console.print(f"[dim]Steps: {completed}/{len(session.plan_steps)} completed[/dim]")
|
|
1661
|
+
# Restore model
|
|
1662
|
+
_sync_model(session)
|
|
1663
|
+
else:
|
|
1664
|
+
session = Session()
|
|
1665
|
+
# Apply default model from config if set
|
|
1666
|
+
if config.model_defaults.get("default"):
|
|
1667
|
+
session.model_ref = config.model_defaults["default"]
|
|
1668
|
+
_render_home(session, skip_permissions)
|
|
1669
|
+
|
|
1670
|
+
# Wire file-mutation callback so context cache (tree/git) is invalidated
|
|
1671
|
+
set_on_file_mutation(session.invalidate_context_cache)
|
|
1672
|
+
|
|
1673
|
+
planner = None
|
|
1674
|
+
executor = None
|
|
1675
|
+
paste_state = PasteState()
|
|
1676
|
+
prompt_session = _create_prompt_session(paste_state, config)
|
|
1677
|
+
|
|
1678
|
+
# Startup: load MCP tools
|
|
1679
|
+
from aru.tools.codebase import load_mcp_tools
|
|
1680
|
+
await load_mcp_tools()
|
|
1681
|
+
|
|
1682
|
+
while True:
|
|
1683
|
+
try:
|
|
1684
|
+
paste_state.clear()
|
|
1685
|
+
_render_input_separator()
|
|
1686
|
+
model_tb = session.model_display
|
|
1687
|
+
user_text = (
|
|
1688
|
+
await asyncio.to_thread(
|
|
1689
|
+
prompt_session.prompt,
|
|
1690
|
+
HTML('<b><ansigreen>❯</ansigreen></b> '),
|
|
1691
|
+
multiline=False,
|
|
1692
|
+
bottom_toolbar=HTML(
|
|
1693
|
+
f' <style fg="ansigray">{model_tb}</style>'
|
|
1694
|
+
f' <style fg="ansigray">│</style>'
|
|
1695
|
+
f' <style fg="ansigray">/help</style>'
|
|
1696
|
+
f' <style fg="ansigray">│</style>'
|
|
1697
|
+
f' <style fg="ansigray">Esc+Enter newline</style>'
|
|
1698
|
+
),
|
|
1699
|
+
)
|
|
1700
|
+
).strip()
|
|
1701
|
+
_render_input_separator()
|
|
1702
|
+
except (EOFError, KeyboardInterrupt, asyncio.CancelledError):
|
|
1703
|
+
store.save(session)
|
|
1704
|
+
console.print(f"\n[dim]Session saved: {session.session_id}[/dim]")
|
|
1705
|
+
console.print(f"[dim]Resume with:[/dim] [bold cyan]aru --resume {session.session_id}[/bold cyan]")
|
|
1706
|
+
console.print("[dim]Bye![/dim]")
|
|
1707
|
+
from aru.tools.mcp_client import cleanup_mcp
|
|
1708
|
+
await cleanup_mcp()
|
|
1709
|
+
break
|
|
1710
|
+
|
|
1711
|
+
user_input = _sanitize_input(paste_state.build_message(user_text))
|
|
1712
|
+
|
|
1713
|
+
# Resolve @file mentions
|
|
1714
|
+
resolved, injected = _resolve_mentions(user_input, os.getcwd())
|
|
1715
|
+
if resolved != user_input:
|
|
1716
|
+
console.print(f"[dim]Attached {injected} file(s) from @ mentions[/dim]")
|
|
1717
|
+
user_input = resolved
|
|
1718
|
+
|
|
1719
|
+
if paste_state.pasted_content and user_text:
|
|
1720
|
+
console.print(
|
|
1721
|
+
f"[dim] {paste_state.line_count} lines pasted[/dim] [cyan]{user_text}[/cyan]"
|
|
1722
|
+
)
|
|
1723
|
+
elif paste_state.pasted_content:
|
|
1724
|
+
console.print(
|
|
1725
|
+
f"[dim] {paste_state.line_count} lines pasted[/dim]"
|
|
1726
|
+
)
|
|
1727
|
+
|
|
1728
|
+
if not user_input:
|
|
1729
|
+
continue
|
|
1730
|
+
|
|
1731
|
+
# Reset "allow all" approvals for each new user message
|
|
1732
|
+
reset_allowed_actions()
|
|
1733
|
+
|
|
1734
|
+
if user_input.lower() in ("/quit", "/exit", "quit", "exit"):
|
|
1735
|
+
store.save(session)
|
|
1736
|
+
console.print(f"[dim]Session saved: {session.session_id}[/dim]")
|
|
1737
|
+
console.print(f"[dim]Resume with:[/dim] [bold cyan]aru --resume {session.session_id}[/bold cyan]")
|
|
1738
|
+
console.print("[dim]Bye![/dim]")
|
|
1739
|
+
from aru.tools.mcp_client import cleanup_mcp
|
|
1740
|
+
await cleanup_mcp()
|
|
1741
|
+
break
|
|
1742
|
+
|
|
1743
|
+
if user_input == "/model" or user_input.startswith("/model "):
|
|
1744
|
+
arg = user_input[6:].strip()
|
|
1745
|
+
if not arg:
|
|
1746
|
+
console.print(f"[bold]Current model:[/bold] {session.model_display} ({session.model_id})")
|
|
1747
|
+
console.print()
|
|
1748
|
+
# Show model aliases from aru.json
|
|
1749
|
+
if config.model_defaults:
|
|
1750
|
+
non_default = {k: v for k, v in config.model_defaults.items() if k != "default"}
|
|
1751
|
+
if non_default:
|
|
1752
|
+
console.print("[bold]Model aliases (aru.json):[/bold]")
|
|
1753
|
+
for alias, ref in non_default.items():
|
|
1754
|
+
console.print(f" [cyan]{alias}[/cyan] → {ref}")
|
|
1755
|
+
console.print()
|
|
1756
|
+
console.print("[bold]Aliases:[/bold]")
|
|
1757
|
+
for alias, ref in MODEL_ALIASES.items():
|
|
1758
|
+
console.print(f" [cyan]{alias}[/cyan] → {ref}")
|
|
1759
|
+
console.print()
|
|
1760
|
+
console.print("[bold]Providers:[/bold]")
|
|
1761
|
+
for pkey, pconfig in list_providers().items():
|
|
1762
|
+
dflt = pconfig.default_model or "—"
|
|
1763
|
+
console.print(f" [cyan]{pkey}[/cyan] ({pconfig.name}) — default: {dflt}")
|
|
1764
|
+
console.print()
|
|
1765
|
+
console.print("[dim]Usage: /model <provider/model> (e.g., /model ollama/llama3.1, /model openai/gpt-4o)[/dim]")
|
|
1766
|
+
else:
|
|
1767
|
+
arg_lower = arg.lower()
|
|
1768
|
+
try:
|
|
1769
|
+
# Resolve config aliases (aru.json "models" section) first
|
|
1770
|
+
resolved_ref = config.model_defaults.get(arg_lower, arg_lower) if config.model_defaults else arg_lower
|
|
1771
|
+
# Validate the model reference resolves to a known provider
|
|
1772
|
+
provider_key, model_name = resolve_model_ref(resolved_ref)
|
|
1773
|
+
from aru.providers import get_provider
|
|
1774
|
+
provider = get_provider(provider_key)
|
|
1775
|
+
if provider is None:
|
|
1776
|
+
available = ", ".join(sorted(list_providers().keys()))
|
|
1777
|
+
console.print(f"[yellow]Unknown provider '{provider_key}'. Available: {available}[/yellow]")
|
|
1778
|
+
else:
|
|
1779
|
+
session.model_ref = resolved_ref if "/" in resolved_ref else (
|
|
1780
|
+
MODEL_ALIASES.get(resolved_ref, resolved_ref)
|
|
1781
|
+
)
|
|
1782
|
+
_sync_model(session)
|
|
1783
|
+
planner = None
|
|
1784
|
+
executor = None
|
|
1785
|
+
console.print(f"[bold green]Switched to {session.model_display}[/bold green] ({session.model_id})")
|
|
1786
|
+
except Exception as e:
|
|
1787
|
+
console.print(f"[yellow]Error: {e}[/yellow]")
|
|
1788
|
+
continue
|
|
1789
|
+
|
|
1790
|
+
if user_input.lower() in ("/sessions", "/list"):
|
|
1791
|
+
sessions = store.list_sessions()
|
|
1792
|
+
if not sessions:
|
|
1793
|
+
console.print("[dim]No saved sessions.[/dim]")
|
|
1794
|
+
else:
|
|
1795
|
+
console.print("[bold]Recent sessions:[/bold]\n")
|
|
1796
|
+
for s in sessions:
|
|
1797
|
+
sid = s["session_id"]
|
|
1798
|
+
title = s["title"][:50]
|
|
1799
|
+
msgs = s["messages"]
|
|
1800
|
+
updated = s["updated_at"]
|
|
1801
|
+
model = s["model"]
|
|
1802
|
+
is_current = " [green](current)[/green]" if sid == session.session_id else ""
|
|
1803
|
+
console.print(f" [bold cyan]{sid}[/bold cyan] {title} [dim]({msgs} msgs, {model}, {updated})[/dim]{is_current}")
|
|
1804
|
+
console.print(f"\n[dim]Resume with: aru --resume <id>[/dim]")
|
|
1805
|
+
continue
|
|
1806
|
+
|
|
1807
|
+
if user_input.lower() == "/commands":
|
|
1808
|
+
if not config.commands:
|
|
1809
|
+
console.print("[dim]No custom commands found. Add .md files to .agents/commands/[/dim]")
|
|
1810
|
+
else:
|
|
1811
|
+
console.print("[bold]Custom commands:[/bold]\n")
|
|
1812
|
+
for name, cmd_def in config.commands.items():
|
|
1813
|
+
console.print(f" [bold cyan]/{name}[/bold cyan] [dim]{cmd_def.description}[/dim]")
|
|
1814
|
+
console.print(f"\n[dim]Source: .agents/commands/[/dim]")
|
|
1815
|
+
continue
|
|
1816
|
+
|
|
1817
|
+
if user_input.lower() == "/skills":
|
|
1818
|
+
if not config.skills:
|
|
1819
|
+
console.print("[dim]No skills found. Add .md files to .agents/skills/[/dim]")
|
|
1820
|
+
else:
|
|
1821
|
+
console.print("[bold]Available skills:[/bold]\n")
|
|
1822
|
+
for name, skill in config.skills.items():
|
|
1823
|
+
console.print(f" [bold cyan]{name}[/bold cyan] [dim]{skill.description}[/dim]")
|
|
1824
|
+
console.print(f"\n[dim]Source: .agents/skills/[/dim]")
|
|
1825
|
+
console.print(f"\n[dim]Source: .agents/skills/[/dim]")
|
|
1826
|
+
continue
|
|
1827
|
+
|
|
1828
|
+
if user_input.lower() == "/mcp":
|
|
1829
|
+
from aru.tools.codebase import ALL_TOOLS
|
|
1830
|
+
from agno.tools import Function
|
|
1831
|
+
mcp_tools = [t for t in ALL_TOOLS if isinstance(t, Function) and getattr(t, "name", "").count("__") > 0]
|
|
1832
|
+
if not mcp_tools:
|
|
1833
|
+
console.print("[dim]No MCP tools loaded. Check aru.mcp.json config.[/dim]")
|
|
1834
|
+
else:
|
|
1835
|
+
console.print(f"[bold]Loaded MCP Tools ({len(mcp_tools)}):[/bold]\n")
|
|
1836
|
+
for t in mcp_tools:
|
|
1837
|
+
console.print(f" [bold cyan]{t.name}[/bold cyan] [dim]{t.description}[/dim]")
|
|
1838
|
+
continue
|
|
1839
|
+
|
|
1840
|
+
if user_input.lower() == "/help":
|
|
1841
|
+
_show_help(config)
|
|
1842
|
+
continue
|
|
1843
|
+
|
|
1844
|
+
if user_input.startswith("! "):
|
|
1845
|
+
cmd = user_input[2:].strip()
|
|
1846
|
+
if not cmd:
|
|
1847
|
+
console.print("[yellow]Usage: ! <command>[/yellow]")
|
|
1848
|
+
continue
|
|
1849
|
+
run_shell(cmd)
|
|
1850
|
+
|
|
1851
|
+
elif user_input.startswith("/plan "):
|
|
1852
|
+
task = user_input[6:].strip()
|
|
1853
|
+
if not task:
|
|
1854
|
+
console.print("[yellow]Usage: /plan <task description>[/yellow]")
|
|
1855
|
+
continue
|
|
1856
|
+
|
|
1857
|
+
console.print("[bold magenta]Planning...[/bold magenta]")
|
|
1858
|
+
if planner is None:
|
|
1859
|
+
planner = create_planner(session.model_ref, extra_instructions)
|
|
1860
|
+
|
|
1861
|
+
# No need to manually inject session context into prompt; run_agent_capture will do it.
|
|
1862
|
+
prompt = task
|
|
1863
|
+
|
|
1864
|
+
plan_result = await run_agent_capture(planner, prompt, session, lightweight=True)
|
|
1865
|
+
plan_content = plan_result.content
|
|
1866
|
+
|
|
1867
|
+
if plan_content and config and config.plan_reviewer:
|
|
1868
|
+
console.print("[dim]Reviewing scope...[/dim]")
|
|
1869
|
+
reviewed = await review_plan(task, plan_content)
|
|
1870
|
+
if reviewed != plan_content:
|
|
1871
|
+
plan_content = reviewed
|
|
1872
|
+
console.print(Markdown(plan_content))
|
|
1873
|
+
|
|
1874
|
+
if plan_content:
|
|
1875
|
+
session.set_plan(task, plan_content)
|
|
1876
|
+
session.add_message("user", f"/plan {task}")
|
|
1877
|
+
session.add_message("assistant", f"[Plan]\n{plan_content}")
|
|
1878
|
+
|
|
1879
|
+
# Show parsed steps
|
|
1880
|
+
if session.plan_steps:
|
|
1881
|
+
console.print(f"\n[bold]{len(session.plan_steps)} steps detected.[/bold]")
|
|
1882
|
+
|
|
1883
|
+
if get_skip_permissions() or ask_yes_no("Execute this plan?"):
|
|
1884
|
+
console.print("[bold green]Executing plan...[/bold green]")
|
|
1885
|
+
|
|
1886
|
+
# Use lightweight instructions for executor steps (skip README.md)
|
|
1887
|
+
light_instructions = config.get_extra_instructions(lightweight=True) if config else ""
|
|
1888
|
+
|
|
1889
|
+
def make_executor():
|
|
1890
|
+
return create_executor(session.model_ref, light_instructions)
|
|
1891
|
+
|
|
1892
|
+
result = await execute_plan_steps(session, make_executor)
|
|
1893
|
+
if result:
|
|
1894
|
+
session.add_message("assistant", f"[Execution]\n{result}")
|
|
1895
|
+
|
|
1896
|
+
session.clear_plan()
|
|
1897
|
+
|
|
1898
|
+
elif user_input.startswith("/") and not user_input.startswith("//"):
|
|
1899
|
+
# Check for custom commands from .agents/commands/
|
|
1900
|
+
parts = user_input[1:].split(None, 1)
|
|
1901
|
+
cmd_name = parts[0].lower()
|
|
1902
|
+
cmd_args = parts[1] if len(parts) > 1 else ""
|
|
1903
|
+
|
|
1904
|
+
if cmd_name in config.commands:
|
|
1905
|
+
cmd_def = config.commands[cmd_name]
|
|
1906
|
+
prompt = render_command_template(cmd_def.template, cmd_args)
|
|
1907
|
+
console.print(f"[bold magenta]Running /{cmd_name}...[/bold magenta]")
|
|
1908
|
+
|
|
1909
|
+
agent = create_general_agent(session, config)
|
|
1910
|
+
session.add_message("user", user_input)
|
|
1911
|
+
run_result = await run_agent_capture(agent, prompt, session)
|
|
1912
|
+
if run_result.content:
|
|
1913
|
+
session.add_message("assistant", run_result.with_tools_summary())
|
|
1914
|
+
else:
|
|
1915
|
+
console.print(f"[yellow]Unknown command: /{cmd_name}[/yellow]")
|
|
1916
|
+
console.print(f"[dim]Built-in: /plan, /model, /sessions, /commands, /skills, /quit[/dim]")
|
|
1917
|
+
if config.commands:
|
|
1918
|
+
console.print(f"[dim]Custom: {', '.join(f'/{k}' for k in config.commands)}[/dim]")
|
|
1919
|
+
|
|
1920
|
+
else:
|
|
1921
|
+
agent = create_general_agent(session, config)
|
|
1922
|
+
session.add_message("user", user_input)
|
|
1923
|
+
run_result = await run_agent_capture(agent, user_input, session)
|
|
1924
|
+
if run_result.content:
|
|
1925
|
+
session.add_message("assistant", run_result.with_tools_summary())
|
|
1926
|
+
|
|
1927
|
+
# Show token usage and auto-save
|
|
1928
|
+
if session.token_summary:
|
|
1929
|
+
console.print(f"[dim]{session.token_summary}[/dim]")
|
|
1930
|
+
store.save(session)
|
|
1931
|
+
|
|
1932
|
+
|
|
1933
|
+
def _list_sessions_and_exit():
|
|
1934
|
+
"""Print saved sessions and exit."""
|
|
1935
|
+
store = SessionStore()
|
|
1936
|
+
sessions = store.list_sessions()
|
|
1937
|
+
if not sessions:
|
|
1938
|
+
console.print("[dim]No saved sessions.[/dim]")
|
|
1939
|
+
return
|
|
1940
|
+
console.print("[bold]Recent sessions:[/bold]\n")
|
|
1941
|
+
for s in sessions:
|
|
1942
|
+
sid = s["session_id"]
|
|
1943
|
+
title = s["title"][:50]
|
|
1944
|
+
msgs = s["messages"]
|
|
1945
|
+
updated = s["updated_at"]
|
|
1946
|
+
model = s["model"]
|
|
1947
|
+
console.print(f" [bold cyan]{sid}[/bold cyan] {title} [dim]({msgs} msgs, {model}, {updated})[/dim]")
|
|
1948
|
+
console.print(f"\n[dim]Resume with: aru --resume <id>[/dim]")
|
|
1949
|
+
|
|
1950
|
+
|
|
1951
|
+
def main():
|
|
1952
|
+
"""Entry point for the aru CLI."""
|
|
1953
|
+
from dotenv import load_dotenv
|
|
1954
|
+
|
|
1955
|
+
load_dotenv()
|
|
1956
|
+
args = sys.argv[1:]
|
|
1957
|
+
skip_permissions = "--dangerously-skip-permissions" in args
|
|
1958
|
+
|
|
1959
|
+
# --list: show sessions and exit
|
|
1960
|
+
if "--list" in args:
|
|
1961
|
+
_list_sessions_and_exit()
|
|
1962
|
+
return
|
|
1963
|
+
|
|
1964
|
+
# --resume [id]: resume a session (or "last" if no id given)
|
|
1965
|
+
resume_id = None
|
|
1966
|
+
if "--resume" in args:
|
|
1967
|
+
idx = args.index("--resume")
|
|
1968
|
+
if idx + 1 < len(args) and not args[idx + 1].startswith("--"):
|
|
1969
|
+
resume_id = args[idx + 1]
|
|
1970
|
+
else:
|
|
1971
|
+
resume_id = "last"
|
|
1972
|
+
|
|
1973
|
+
try:
|
|
1974
|
+
asyncio.run(run_cli(skip_permissions=skip_permissions, resume_id=resume_id))
|
|
1975
|
+
except (KeyboardInterrupt, asyncio.CancelledError, SystemExit):
|
|
1976
|
+
_graceful_exit()
|
|
1977
|
+
except Exception as e:
|
|
1978
|
+
from rich.markup import escape
|
|
1979
|
+
console.print(f"\n[bold red]Fatal error: {escape(str(e))}[/bold red]")
|
|
1980
|
+
_graceful_exit()
|
|
1981
|
+
|
|
1982
|
+
|
|
1983
|
+
def _graceful_exit():
|
|
1984
|
+
"""Save session and show resume hint on exit."""
|
|
1985
|
+
try:
|
|
1986
|
+
store = SessionStore()
|
|
1987
|
+
last = store.load_last()
|
|
1988
|
+
if last:
|
|
1989
|
+
console.print(f"\n[dim]Session saved: {last.session_id}[/dim]")
|
|
1990
|
+
console.print(f"[dim]Resume with:[/dim] [bold cyan]aru --resume {last.session_id}[/bold cyan]")
|
|
1991
|
+
except Exception:
|
|
1992
|
+
pass
|
|
1993
|
+
console.print("[dim]Bye![/dim]")
|