aizen-ai-cli 2.2.5__tar.gz → 2.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.0}/PKG-INFO +9 -2
  2. {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.0}/README.md +8 -1
  3. {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.0}/aizen/commands.py +5 -0
  4. {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.0}/aizen/config.py +25 -7
  5. {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.0}/aizen/main.py +186 -67
  6. {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.0}/aizen/session.py +3 -2
  7. {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.0}/aizen/tools.py +201 -9
  8. {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.0}/aizen/utils.py +11 -0
  9. {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.0}/aizen_ai_cli.egg-info/PKG-INFO +9 -2
  10. {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.0}/pyproject.toml +1 -1
  11. {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.0}/setup.py +1 -1
  12. {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.0}/tests/test_tools.py +3 -3
  13. {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.0}/MANIFEST.in +0 -0
  14. {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.0}/aizen/__init__.py +0 -0
  15. {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.0}/aizen/context.py +0 -0
  16. {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.0}/aizen/exceptions.py +0 -0
  17. {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.0}/aizen/logging_config.py +0 -0
  18. {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.0}/aizen/mcp.py +0 -0
  19. {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.0}/aizen/plugins.py +0 -0
  20. {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.0}/aizen/retry.py +0 -0
  21. {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.0}/aizen_ai_cli.egg-info/SOURCES.txt +0 -0
  22. {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.0}/aizen_ai_cli.egg-info/dependency_links.txt +0 -0
  23. {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.0}/aizen_ai_cli.egg-info/entry_points.txt +0 -0
  24. {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.0}/aizen_ai_cli.egg-info/requires.txt +0 -0
  25. {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.0}/aizen_ai_cli.egg-info/top_level.txt +0 -0
  26. {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.0}/requirements.txt +0 -0
  27. {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.0}/setup.cfg +0 -0
  28. {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.0}/tests/test_commands.py +0 -0
  29. {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.0}/tests/test_config.py +0 -0
  30. {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.0}/tests/test_context.py +0 -0
  31. {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.0}/tests/test_main.py +0 -0
  32. {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.0}/tests/test_mcp.py +0 -0
  33. {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.0}/tests/test_session.py +0 -0
  34. {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.0}/tests/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aizen-ai-cli
3
- Version: 2.2.5
3
+ Version: 2.4.0
4
4
  Summary: Aizen AI Agent — A professional-grade AI coding assistant for your terminal.
5
5
  Author: Irtaza Malik
6
6
  License: MIT
@@ -54,9 +54,12 @@ A helpful AI coding assistant you can use right in your terminal. Aizen reads yo
54
54
  - **SQLite Session Persistence** — Session storage is powered by a SQLite database (`~/.aizen_sessions/aizen.db`), auto-migrating older JSON sessions.
55
55
  - **Project-Specific Rules** — Customizes agent behavior per repository by auto-loading `.aizen_rules` or `.cursorrules` from the current working directory.
56
56
  - **Smart Autocomplete** — `@`-mention files with Tab completion that respects `.gitignore` and supports directory traversal.
57
+ - **Vision Support** — Attach images natively (e.g., `@mockup.png`) and Aizen will automatically encode them for Vision APIs (GPT-4o, Claude 3.5 Sonnet).
58
+ - **Real-time Command Streaming** — Long-running shell commands stream their output live to the terminal instead of freezing with a spinner.
59
+ - **Smart Context Pruning** — Automatically drops old, large file attachments first when hitting the context limit before resorting to LLM summarization.
57
60
 
58
61
  ### Tools
59
- Aizen has 9 built-in tools the AI can use:
62
+ Aizen has 10 built-in tools the AI can use:
60
63
 
61
64
  | Tool | Description |
62
65
  |------|-------------|
@@ -70,6 +73,8 @@ Aizen has 9 built-in tools the AI can use:
70
73
  | `list_directory` | List files/folders with sizes, respecting `.gitignore` |
71
74
  | `grep_search` | Search for text or regex patterns across the codebase |
72
75
  | `find_files` | Find files by glob pattern (e.g., `*.py`, `Dockerfile`) |
76
+ | `get_file_outline` | Extract AST outline of a Python file (classes, methods, docstrings) without blowing up the context window |
77
+ | `web_search` | Search the web for current information, docs, or API references |
73
78
 
74
79
  ### Commands
75
80
 
@@ -93,9 +98,11 @@ Aizen has 9 built-in tools the AI can use:
93
98
  | `/export [file]` | Export conversation to a Markdown file |
94
99
  | `/config` | View current configuration |
95
100
  | `/mcp` | View configured MCP servers and their connection status |
101
+ | `/auto [task]` | Enter a fully autonomous agentic loop to execute a complex task step-by-step |
96
102
 
97
103
  ### Safety & UX
98
104
  - **Command Safety** — Read-only commands (`ls`, `cat`, `git status`, etc.) auto-execute. Destructive commands (`rm`, `sudo`, etc.) always require confirmation.
105
+ - **Autonomous Limits** — The `/auto` mode enforces a strict 25-step execution limit to prevent infinite loops and runaway costs.
99
106
  - **`--yolo` Mode** — Auto-approve all operations for power users.
100
107
  - **Background Tasks** — Run builds, tests, or other long-running tasks asynchronously while continuing to interact with Aizen.
101
108
  - **File Backups** — Every file modification creates a backup. Use `/undo` to restore.
@@ -14,9 +14,12 @@ A helpful AI coding assistant you can use right in your terminal. Aizen reads yo
14
14
  - **SQLite Session Persistence** — Session storage is powered by a SQLite database (`~/.aizen_sessions/aizen.db`), auto-migrating older JSON sessions.
15
15
  - **Project-Specific Rules** — Customizes agent behavior per repository by auto-loading `.aizen_rules` or `.cursorrules` from the current working directory.
16
16
  - **Smart Autocomplete** — `@`-mention files with Tab completion that respects `.gitignore` and supports directory traversal.
17
+ - **Vision Support** — Attach images natively (e.g., `@mockup.png`) and Aizen will automatically encode them for Vision APIs (GPT-4o, Claude 3.5 Sonnet).
18
+ - **Real-time Command Streaming** — Long-running shell commands stream their output live to the terminal instead of freezing with a spinner.
19
+ - **Smart Context Pruning** — Automatically drops old, large file attachments first when hitting the context limit before resorting to LLM summarization.
17
20
 
18
21
  ### Tools
19
- Aizen has 9 built-in tools the AI can use:
22
+ Aizen has 10 built-in tools the AI can use:
20
23
 
21
24
  | Tool | Description |
22
25
  |------|-------------|
@@ -30,6 +33,8 @@ Aizen has 9 built-in tools the AI can use:
30
33
  | `list_directory` | List files/folders with sizes, respecting `.gitignore` |
31
34
  | `grep_search` | Search for text or regex patterns across the codebase |
32
35
  | `find_files` | Find files by glob pattern (e.g., `*.py`, `Dockerfile`) |
36
+ | `get_file_outline` | Extract AST outline of a Python file (classes, methods, docstrings) without blowing up the context window |
37
+ | `web_search` | Search the web for current information, docs, or API references |
33
38
 
34
39
  ### Commands
35
40
 
@@ -53,9 +58,11 @@ Aizen has 9 built-in tools the AI can use:
53
58
  | `/export [file]` | Export conversation to a Markdown file |
54
59
  | `/config` | View current configuration |
55
60
  | `/mcp` | View configured MCP servers and their connection status |
61
+ | `/auto [task]` | Enter a fully autonomous agentic loop to execute a complex task step-by-step |
56
62
 
57
63
  ### Safety & UX
58
64
  - **Command Safety** — Read-only commands (`ls`, `cat`, `git status`, etc.) auto-execute. Destructive commands (`rm`, `sudo`, etc.) always require confirmation.
65
+ - **Autonomous Limits** — The `/auto` mode enforces a strict 25-step execution limit to prevent infinite loops and runaway costs.
59
66
  - **`--yolo` Mode** — Auto-approve all operations for power users.
60
67
  - **Background Tasks** — Run builds, tests, or other long-running tasks asynchronously while continuing to interact with Aizen.
61
68
  - **File Backups** — Every file modification creates a backup. Use `/undo` to restore.
@@ -44,6 +44,7 @@ SLASH_COMMANDS = [
44
44
  ("/mcp", "View configured MCP servers and their status"),
45
45
  ("/commit", "Auto-generate and commit changes"),
46
46
  ("/diff", "Show all uncommitted changes"),
47
+ ("/auto", "Enter autonomous agentic mode for a complex task"),
47
48
  ]
48
49
 
49
50
  # In-memory checkpoint storage for conversation branching
@@ -284,6 +285,10 @@ async def handle_slash_command(
284
285
  help_table.add_row(" 🔀 /commit", "Auto-generate and commit changes")
285
286
  help_table.add_row(" 📊 /diff", "Show all uncommitted changes")
286
287
 
288
+ # ── Agent ──
289
+ help_table.add_row(f"[bold {Theme.MUTED}]── Agent ──[/bold {Theme.MUTED}]", "")
290
+ help_table.add_row(" 🤖 /auto [task]", "Enter autonomous mode for a complex task (max iterations apply)")
291
+
287
292
  # ── Shortcuts ──
288
293
  help_table.add_row(f"[bold {Theme.MUTED}]── Shortcuts ──[/bold {Theme.MUTED}]", "")
289
294
  help_table.add_row(f" [{Theme.PINK}]@file / @url[/{Theme.PINK}]", "Attach file context or web URL")
@@ -4,7 +4,6 @@ import logging
4
4
  import os
5
5
  import shutil
6
6
  import ssl
7
- import sys
8
7
  import threading
9
8
  import time
10
9
  import urllib.error
@@ -21,7 +20,7 @@ logger = logging.getLogger("aizen")
21
20
 
22
21
  # Read version from installed package metadata (stays in sync with pyproject.toml).
23
22
  # Falls back to a hardcoded value only when running from source without installing.
24
- _FALLBACK_VERSION = "2.2.5"
23
+ _FALLBACK_VERSION = "2.4.0"
25
24
  try:
26
25
  VERSION = _pkg_version("aizen-ai-cli")
27
26
  except PackageNotFoundError:
@@ -83,6 +82,8 @@ DANGEROUS_PATTERNS = [
83
82
  r"\brm\s", r"\bsudo\b", r"\bchmod\b", r"\bchown\b", r"\bmkfs\b",
84
83
  r"\bdd\b", r":\(\)\{", r"\bkill\b", r"\bpkill\b", r"\bshutdown\b",
85
84
  r"\breboot\b", r">\s*/dev/", r"\bcurl\b.*\|\s*(ba)?sh",
85
+ r"\bmktemp\b.*>", r"\btruncate\b", r"\bmv\s+/(?!tmp)", r"chmod\s+777",
86
+ r"git\s+push\s+--force", r"\bdocker\s+run\b", r"\bpip\s+install\b", r"\bnpm\s+install\b",
86
87
  # Shell injection patterns
87
88
  r"`[^`]+`", # Backtick command substitution
88
89
  r"\$\([^)]+\)", # $() command substitution
@@ -209,13 +210,30 @@ def migrate_legacy_data():
209
210
 
210
211
  def load_config() -> dict:
211
212
  migrate_legacy_data()
213
+
214
+ config = {}
215
+ # Load global config
212
216
  if os.path.exists(CONFIG_PATH):
213
217
  try:
214
218
  with open(CONFIG_PATH) as f:
215
- return json.load(f)
219
+ config = json.load(f)
216
220
  except Exception as e:
217
- logger.debug("Failed to load config file: %s", e)
218
- return {}
221
+ logger.debug("Failed to load global config file: %s", e)
222
+
223
+ # Merge local config if present
224
+ local_config_path = os.path.join(os.getcwd(), ".aizen_config.json")
225
+ if os.path.exists(local_config_path):
226
+ try:
227
+ with open(local_config_path) as f:
228
+ local_config = json.load(f)
229
+ # Merge local config keys (overriding global ones)
230
+ if isinstance(local_config, dict):
231
+ config.update(local_config)
232
+ console.print(f"{Theme.SYS} Local config loaded from [#d3fbff]{local_config_path}[/#d3fbff]")
233
+ except Exception as e:
234
+ logger.debug("Failed to load local config file: %s", e)
235
+
236
+ return config
219
237
 
220
238
 
221
239
  def get_mcp_servers(config: dict) -> dict:
@@ -252,8 +270,8 @@ def get_api_key(config: dict, reset: bool = False) -> str:
252
270
 
253
271
  key = getpass.getpass("API Key: ").strip()
254
272
  if not key:
255
- console.print("[bold red]Error:[/bold red] API Key cannot be empty.")
256
- sys.exit(1)
273
+ from .exceptions import APIKeyError
274
+ raise APIKeyError("API Key cannot be empty.")
257
275
 
258
276
  config["OPENROUTER_API_KEY"] = key
259
277
  save_config(config)
@@ -5,7 +5,9 @@ Aizen AI Agent — A professional-grade AI coding assistant for your terminal.
5
5
 
6
6
  import argparse
7
7
  import asyncio
8
+ import base64
8
9
  import json
10
+ import mimetypes
9
11
  import os
10
12
  import random
11
13
  import re
@@ -43,6 +45,7 @@ from .config import (
43
45
  set_active_model,
44
46
  )
45
47
  from .context import ContextManager
48
+ from .exceptions import APIKeyError, SessionCorruptedError
46
49
  from .logging_config import logger, setup_logging
47
50
  from .mcp import MCPManager
48
51
  from .plugins import plugin_manager
@@ -81,6 +84,8 @@ def inject_file_context(user_input: str) -> str:
81
84
  if not matches and not context_blocks:
82
85
  return user_input
83
86
 
87
+ images = []
88
+
84
89
  for item in set(matches):
85
90
  if item.startswith("http://") or item.startswith("https://"):
86
91
  console.print(f" [dim]🌐 Fetching: {item}[/dim]")
@@ -92,17 +97,28 @@ def inject_file_context(user_input: str) -> str:
92
97
  f'<url_context url="{item}">\n{content}\n</url_context>'
93
98
  )
94
99
  elif os.path.isfile(item):
95
- try:
96
- with open(item, encoding="utf-8", errors="ignore") as f:
97
- content = f.read()
98
- context_blocks.append(
99
- f'<file_context path="{item}">\n{content}\n</file_context>'
100
- )
101
- console.print(f" [dim]📎 Attached: {item}[/dim]")
102
- except Exception as e:
103
- console.print(
104
- f" [dim yellow]⚠️ Failed to read {item}: {e}[/dim yellow]"
105
- )
100
+ ext = os.path.splitext(item)[1].lower()
101
+ if ext in [".png", ".jpg", ".jpeg", ".webp", ".gif"]:
102
+ try:
103
+ with open(item, "rb") as f:
104
+ b64_img = base64.b64encode(f.read()).decode("utf-8")
105
+ mime_type = mimetypes.guess_type(item)[0] or "image/png"
106
+ images.append(f"data:{mime_type};base64,{b64_img}")
107
+ console.print(f" [dim]🖼️ Attached image: {item}[/dim]")
108
+ except Exception as e:
109
+ console.print(f" [dim yellow]⚠️ Failed to read image {item}: {e}[/dim yellow]")
110
+ else:
111
+ try:
112
+ with open(item, encoding="utf-8", errors="ignore") as f:
113
+ content = f.read()
114
+ context_blocks.append(
115
+ f'<file_context path="{item}">\n{content}\n</file_context>'
116
+ )
117
+ console.print(f" [dim]📎 Attached: {item}[/dim]")
118
+ except Exception as e:
119
+ console.print(
120
+ f" [dim yellow]⚠️ Failed to read {item}: {e}[/dim yellow]"
121
+ )
106
122
  elif os.path.isdir(item):
107
123
  try:
108
124
  tree_output = generate_directory_tree(item)
@@ -119,6 +135,13 @@ def inject_file_context(user_input: str) -> str:
119
135
 
120
136
  if context_blocks:
121
137
  user_input += "\n\n" + "\n".join(context_blocks)
138
+
139
+ if images:
140
+ content_list = [{"type": "text", "text": user_input}]
141
+ for img_url in images:
142
+ content_list.append({"type": "image_url", "image_url": {"url": img_url}})
143
+ return content_list
144
+
122
145
  return user_input
123
146
 
124
147
 
@@ -144,6 +167,12 @@ def parse_args():
144
167
  action="store_true",
145
168
  help="Enable verbose logging output to console.",
146
169
  )
170
+ parser.add_argument(
171
+ "--max-iterations",
172
+ type=int,
173
+ default=25,
174
+ help="Maximum iterations for autonomous mode (default: 25).",
175
+ )
147
176
  return parser.parse_args()
148
177
 
149
178
  @retry_with_backoff(max_retries=3, backoff_base=2.0)
@@ -189,6 +218,10 @@ async def main_loop():
189
218
  api_base = config.get("API_BASE_URL", "https://openrouter.ai/api/v1")
190
219
  auto_approve = args.yolo or config.get("auto_approve", False)
191
220
 
221
+ is_auto_mode = False
222
+ max_auto_iterations = getattr(args, "max_iterations", 25)
223
+ auto_iteration_count = 0
224
+
192
225
  client = AsyncOpenAI(base_url=api_base, api_key=api_key)
193
226
 
194
227
  token_tracker = TokenTracker()
@@ -242,10 +275,43 @@ async def main_loop():
242
275
  "scrollbar.button": f"bg:{Theme.ACCENT}",
243
276
  })
244
277
 
278
+ def get_bottom_toolbar():
279
+ # Get dynamic stats
280
+ cost = token_tracker.get_estimated_cost(get_active_model())
281
+ cost_str = f" (${cost:.3f})" if cost > 0 else ""
282
+ msgs = token_tracker.message_count
283
+ tokens = token_tracker.total_tokens
284
+ model = get_active_model()
285
+ ctx_pct = context_manager.usage_percent
286
+
287
+ # Color coding for context usage
288
+ if ctx_pct >= 85:
289
+ ctx_color = "fg:#f87171" # ERROR
290
+ elif ctx_pct >= 75:
291
+ ctx_color = "fg:#fbbf24" # WARNING
292
+ else:
293
+ ctx_color = "fg:#4ade80" # SUCCESS
294
+
295
+ return FormattedText([
296
+ ("bg:#1e1b2e fg:#6b7280", " ◈ "),
297
+ ("bg:#1e1b2e fg:#e2e8f0", f"{tokens:,} tokens"),
298
+ ("bg:#1e1b2e fg:#4ade80 bold", f"{cost_str}"),
299
+ ("bg:#1e1b2e fg:#4b5563", " │ "),
300
+ ("bg:#1e1b2e fg:#e2e8f0", f"{msgs}"),
301
+ ("bg:#1e1b2e fg:#6b7280", " msgs"),
302
+ ("bg:#1e1b2e fg:#4b5563", " │ "),
303
+ ("bg:#1e1b2e fg:#22d3ee", f"{model}"),
304
+ ("bg:#1e1b2e fg:#4b5563", " │ "),
305
+ ("bg:#1e1b2e fg:#6b7280", "ctx: "),
306
+ (f"bg:#1e1b2e {ctx_color} bold", f"{ctx_pct}%"),
307
+ ("bg:#1e1b2e", " "),
308
+ ])
309
+
245
310
  session: PromptSession = PromptSession(
246
311
  completer=AizenCompleter(),
247
312
  key_bindings=kb,
248
- style=cyberpunk_style
313
+ style=cyberpunk_style,
314
+ bottom_toolbar=get_bottom_toolbar
249
315
  )
250
316
 
251
317
  messages = [{"role": "system", "content": build_system_prompt(config)}]
@@ -298,13 +364,32 @@ async def main_loop():
298
364
 
299
365
  # ── Slash Commands ──
300
366
  if user_input.strip().startswith("/"):
301
- should_retry = await handle_slash_command(
302
- user_input.strip(), messages, token_tracker, mcp_manager, client
303
- )
304
- if should_retry and messages and messages[-1]["role"] == "user":
367
+ if user_input.strip().startswith("/auto"):
368
+ task_desc = user_input.strip()[5:].strip()
369
+ if not task_desc:
370
+ console.print(f" [{Theme.WARNING}]Please provide a task. Usage: /auto <task>[/{Theme.WARNING}]")
371
+ continue
372
+ auto_approve = True
373
+ is_auto_mode = True
374
+ auto_iteration_count = 0
375
+ messages.append({
376
+ "role": "user",
377
+ "content": (
378
+ f"AUTONOMOUS MODE INITIATED.\nTask: {task_desc}\n\n"
379
+ "You are now in fully autonomous mode. Break the task into steps, execute them using your tools, "
380
+ "verify the results, and do NOT stop to ask for permission. Keep running tools until the task is completely finished."
381
+ )
382
+ })
383
+ console.print(f" [{Theme.PRIMARY}]🤖 Autonomous mode engaged! YOLO is active.[/{Theme.PRIMARY}]\n")
305
384
  pass # Fall through to the agent loop
306
385
  else:
307
- continue
386
+ should_retry = await handle_slash_command(
387
+ user_input.strip(), messages, token_tracker, mcp_manager, client
388
+ )
389
+ if should_retry and messages and messages[-1]["role"] == "user":
390
+ pass # Fall through to the agent loop
391
+ else:
392
+ continue
308
393
  else:
309
394
  user_input = inject_file_context(user_input)
310
395
  messages.append({"role": "user", "content": user_input})
@@ -318,42 +403,63 @@ async def main_loop():
318
403
  if warning:
319
404
  console.print(f"[yellow]{warning}[/yellow]\n")
320
405
 
321
- # ── Auto-compact if context is critically full (>90%) ──
322
- if context_manager.needs_auto_compact() and len(messages) > 6:
323
- console.print("[dim yellow]⚡ Auto-compacting conversation to stay within context limits...[/dim yellow]")
324
- system_msg = messages[0]
325
- recent = messages[-4:]
326
- middle = messages[1:-4]
327
- if middle:
328
- user_topics = [
329
- m["content"][:100]
330
- for m in middle
331
- if m["role"] == "user" and m.get("content")
332
- ]
333
- summary = (
334
- "Previous conversation summary: The user and assistant discussed "
335
- + "; ".join(user_topics[:5])
336
- + ". The assistant helped with these requests using code analysis and editing tools."
337
- )
338
- messages[:] = [
339
- system_msg,
340
- {"role": "user", "content": f"Previous conversation summary:\n{summary}"},
341
- {
342
- "role": "assistant",
343
- "content": "Understood. I have the context from our previous discussion. How can I continue helping?",
344
- },
345
- ] + recent
346
- console.print(
347
- f"[green]✓ Auto-compacted {len(middle)} messages into a summary.[/green]\n"
348
- )
349
- # Recalculate token usage after compaction
350
- estimated_total = context_manager.estimate_messages_tokens(
351
- messages, token_tracker.estimate_tokens
352
- )
353
- context_manager.update(estimated_total)
406
+ # ── Auto-compact if context is critically full ──
407
+ if context_manager.needs_auto_compact() and len(messages) > 4:
408
+ console.print("[dim yellow]⚡ Context limit reached. Attempting smart pruning...[/dim yellow]")
409
+ dropped_count = 0
410
+
411
+ # First pass: try semantic truncation (dropping file/url/dir context blocks)
412
+ for msg in messages[1:-2]:
413
+ if msg["role"] == "user" and msg.get("content"):
414
+ old_content = msg["content"]
415
+ new_content = re.sub(r'<file_context path="[^"]+">.*?</file_context>', '[File context dropped]', old_content, flags=re.DOTALL)
416
+ new_content = re.sub(r'<url_context url="[^"]+">.*?</url_context>', '[URL context dropped]', new_content, flags=re.DOTALL)
417
+ new_content = re.sub(r'<directory_context path="[^"]+">.*?</directory_context>', '[Directory context dropped]', new_content, flags=re.DOTALL)
418
+ new_content = re.sub(r'<command_context cmd="[^"]+">.*?</command_context>', '[Command context dropped]', new_content, flags=re.DOTALL)
419
+
420
+ if old_content != new_content:
421
+ msg["content"] = new_content
422
+ dropped_count += 1
423
+
424
+ estimated_total = context_manager.estimate_messages_tokens(messages, token_tracker.estimate_tokens)
425
+ context_manager.update(estimated_total)
426
+
427
+ # Second pass: if still over threshold, do naive summarization
428
+ if context_manager.needs_auto_compact() and len(messages) > 6:
429
+ console.print("[dim yellow]⚡ Context still full. Compacting older messages...[/dim yellow]")
430
+ system_msg = messages[0]
431
+ recent = messages[-4:]
432
+ middle = messages[1:-4]
433
+ if middle:
434
+ user_topics = [m["content"][:100] for m in middle if m["role"] == "user" and m.get("content")]
435
+ summary = "Previous conversation summary: The user and assistant discussed " + "; ".join(user_topics[:5]) + ". The assistant helped with these requests."
436
+ messages[:] = [
437
+ system_msg,
438
+ {"role": "user", "content": f"Previous conversation summary:\n{summary}"},
439
+ {"role": "assistant", "content": "Understood. I have the context. How can I continue helping?"},
440
+ ] + recent
441
+ console.print(f"[green]✓ Auto-compacted {len(middle)} messages into a summary.[/green]\n")
442
+ elif dropped_count > 0:
443
+ console.print(f"[green]✓ Pruned attached contexts from {dropped_count} past messages to save space.[/green]\n")
444
+
445
+ estimated_total = context_manager.estimate_messages_tokens(messages, token_tracker.estimate_tokens)
446
+ context_manager.update(estimated_total)
354
447
 
355
448
  # ── Agent Loop ──────────────────────────────────────────────────
356
449
  while True:
450
+ if is_auto_mode:
451
+ auto_iteration_count += 1
452
+ if auto_iteration_count > max_auto_iterations:
453
+ console.print(f" [{Theme.WARNING}]⚠️ Autonomous mode reached iteration limit ({max_auto_iterations}). Exiting auto mode.[/{Theme.WARNING}]")
454
+ is_auto_mode = False
455
+ auto_approve = args.yolo or config.get("auto_approve", False)
456
+ messages.append({
457
+ "role": "user",
458
+ "content": f"You have reached the maximum number of autonomous iterations ({max_auto_iterations}). Please provide a brief summary of what you have accomplished and what remains."
459
+ })
460
+ auto_iteration_count = 0
461
+ # Continue to let it generate the summary
462
+
357
463
  full_content = ""
358
464
  accumulated_tool_calls = {}
359
465
 
@@ -369,7 +475,10 @@ async def main_loop():
369
475
  "Synthesizing...",
370
476
  ]
371
477
  )
372
- spinner_display = Spinner("dots2", text=Text(f" {spinner_label}", style=f"{Theme.MUTED} italic"), style=f"{Theme.PRIMARY} bold")
478
+ if is_auto_mode:
479
+ spinner_display = Spinner("dots2", text=Text(f" [Step {auto_iteration_count}/{max_auto_iterations}] {spinner_label}", style=f"{Theme.MUTED} italic"), style=f"{Theme.PRIMARY} bold")
480
+ else:
481
+ spinner_display = Spinner("dots2", text=Text(f" {spinner_label}", style=f"{Theme.MUTED} italic"), style=f"{Theme.PRIMARY} bold")
373
482
 
374
483
  try:
375
484
  with Live(
@@ -583,25 +692,25 @@ async def main_loop():
583
692
  "content": truncate_output(result),
584
693
  }
585
694
 
586
- tool_results = await asyncio.gather(*[_exec_tool(tc) for tc in tool_calls_list])
695
+ tool_results = await asyncio.gather(
696
+ *[_exec_tool(tc) for tc in tool_calls_list],
697
+ return_exceptions=True,
698
+ )
699
+ # Handle individual tool failures gracefully
700
+ for i, result in enumerate(tool_results):
701
+ if isinstance(result, Exception):
702
+ logger.error("Tool execution failed: %s", result)
703
+ tool_results[i] = {
704
+ "role": "tool",
705
+ "tool_call_id": tool_calls_list[i]["id"],
706
+ "name": tool_calls_list[i]["function"]["name"],
707
+ "content": f"Error: Tool execution failed — {type(result).__name__}: {result}",
708
+ }
587
709
  messages.extend(tool_results)
588
710
 
589
711
  # Continue the loop — model processes tool results
590
712
 
591
- # ── Footer ──
592
- cost = token_tracker.get_estimated_cost(get_active_model())
593
- cost_display = f" [bold {Theme.SUCCESS}](${cost:.3f})[/bold {Theme.SUCCESS}]" if cost > 0 else ""
594
-
595
- console.print(
596
- f"\n [{Theme.DIM_BORDER}]╰─[/{Theme.DIM_BORDER}] "
597
- f"[{Theme.MUTED}]◈[/{Theme.MUTED}] [{Theme.TEXT}]{token_tracker.total_tokens:,}[/{Theme.TEXT}][{Theme.MUTED}] tokens{cost_display}[/{Theme.MUTED}]"
598
- f" [{Theme.DIM_BORDER}]│[/{Theme.DIM_BORDER}] "
599
- f"[{Theme.TEXT}]{token_tracker.message_count}[/{Theme.TEXT}][{Theme.MUTED}] msgs[/{Theme.MUTED}]"
600
- f" [{Theme.DIM_BORDER}]│[/{Theme.DIM_BORDER}] "
601
- f"[{Theme.ACCENT}]{get_active_model()}[/{Theme.ACCENT}]"
602
- f" [{Theme.DIM_BORDER}]│[/{Theme.DIM_BORDER}] "
603
- f"{context_manager.get_footer_text()}\n"
604
- )
713
+ # Footer is now handled by the persistent bottom_toolbar
605
714
 
606
715
  except (KeyboardInterrupt, EOFError):
607
716
  # Auto-save on interrupt
@@ -620,8 +729,18 @@ async def main_loop():
620
729
  except Exception as e:
621
730
  logger.exception("Unhandled error in main loop: %s", e)
622
731
  console.print(f"\n [bold {Theme.ERROR}]✖ Error[/bold {Theme.ERROR}] [{Theme.TEXT}]{e}[/{Theme.TEXT}]")
732
+
623
733
  def main():
624
- asyncio.run(main_loop())
734
+ try:
735
+ asyncio.run(main_loop())
736
+ except APIKeyError as e:
737
+ console.print(f"[bold red]API Key Error:[/bold red] {e}")
738
+ sys.exit(1)
739
+ except SessionCorruptedError as e:
740
+ console.print(f"[bold red]Session Error:[/bold red] {e}")
741
+ sys.exit(1)
742
+ except KeyboardInterrupt:
743
+ sys.exit(0)
625
744
 
626
745
  if __name__ == "__main__":
627
746
  main()
@@ -116,8 +116,9 @@ def load_session(name: str) -> list | None:
116
116
  if row:
117
117
  try:
118
118
  return json.loads(row[0])
119
- except json.JSONDecodeError:
120
- return None
119
+ except json.JSONDecodeError as e:
120
+ from .exceptions import SessionCorruptedError
121
+ raise SessionCorruptedError(f"Session '{name}' is corrupted: {e}")
121
122
  return None
122
123
 
123
124
 
@@ -4,7 +4,9 @@ import fnmatch
4
4
  import json
5
5
  import os
6
6
  import re
7
+ import shutil
7
8
  import subprocess
9
+ import tempfile
8
10
  import threading
9
11
  import time
10
12
  import uuid
@@ -89,6 +91,9 @@ def _ask_permission(prompt_text: str, auto_approve: bool = False) -> bool:
89
91
  background_tasks: dict[str, dict[str, Any]] = {}
90
92
  background_tasks_lock = threading.Lock() # Protects background_tasks dict
91
93
 
94
+ _fuzzy_file_cache = None
95
+ _fuzzy_file_cache_time = 0
96
+
92
97
  def fuzzy_match_file(filepath: str) -> str | None:
93
98
  """
94
99
  If the exact filepath does not exist, searches the current directory tree
@@ -97,16 +102,26 @@ def fuzzy_match_file(filepath: str) -> str | None:
97
102
  if not filepath or filepath.startswith("/") or filepath.startswith("~"):
98
103
  return None # Only fuzzy match relative paths safely
99
104
 
100
- ignore_patterns = load_gitignore_patterns()
101
- all_files = []
105
+ global _fuzzy_file_cache, _fuzzy_file_cache_time
106
+ import time
107
+ now = time.time()
108
+
109
+ if _fuzzy_file_cache is not None and (now - _fuzzy_file_cache_time) < 10:
110
+ all_files = _fuzzy_file_cache
111
+ else:
112
+ ignore_patterns = load_gitignore_patterns()
113
+ all_files = []
114
+
115
+ # Collect all available files in the tree
116
+ for root, dirs, files in os.walk("."):
117
+ dirs[:] = [d for d in dirs if not should_ignore(os.path.join(root, d), ignore_patterns)]
118
+ for f in files:
119
+ path = os.path.relpath(os.path.join(root, f), ".")
120
+ if not should_ignore(path, ignore_patterns):
121
+ all_files.append(path)
102
122
 
103
- # Collect all available files in the tree
104
- for root, dirs, files in os.walk("."):
105
- dirs[:] = [d for d in dirs if not should_ignore(os.path.join(root, d), ignore_patterns)]
106
- for f in files:
107
- path = os.path.relpath(os.path.join(root, f), ".")
108
- if not should_ignore(path, ignore_patterns):
109
- all_files.append(path)
123
+ _fuzzy_file_cache = all_files
124
+ _fuzzy_file_cache_time = now
110
125
 
111
126
  # Use difflib to find the closest match
112
127
  matches = difflib.get_close_matches(filepath, all_files, n=1, cutoff=0.75)
@@ -366,6 +381,40 @@ tools = [
366
381
  },
367
382
  },
368
383
  },
384
+ {
385
+ "type": "function",
386
+ "function": {
387
+ "name": "get_file_outline",
388
+ "description": "Extracts the abstract syntax tree (AST) outline of a Python file, showing classes, methods, and docstrings without the full implementation details. Useful for exploring large codebases.",
389
+ "parameters": {
390
+ "type": "object",
391
+ "properties": {
392
+ "filepath": {
393
+ "type": "string",
394
+ "description": "Path to the Python file.",
395
+ }
396
+ },
397
+ "required": ["filepath"],
398
+ },
399
+ },
400
+ },
401
+ {
402
+ "type": "function",
403
+ "function": {
404
+ "name": "web_search",
405
+ "description": "Search the web for current information. Use when you need up-to-date docs, error messages, or API references.",
406
+ "parameters": {
407
+ "type": "object",
408
+ "properties": {
409
+ "query": {
410
+ "type": "string",
411
+ "description": "Search query",
412
+ }
413
+ },
414
+ "required": ["query"],
415
+ },
416
+ },
417
+ },
369
418
  ]
370
419
 
371
420
 
@@ -507,6 +556,57 @@ def _check_git_dirty(filepath: str) -> None:
507
556
  except Exception:
508
557
  pass # Not a git repo or git not installed
509
558
 
559
+ def get_file_outline(filepath: str) -> str:
560
+ """Extract AST/regex outline of a Python or JS/TS file."""
561
+ try:
562
+ if not os.path.exists(filepath):
563
+ match = fuzzy_match_file(filepath)
564
+ if match:
565
+ filepath = match
566
+ else:
567
+ return f"Error: File '{filepath}' does not exist."
568
+
569
+ with open(filepath, encoding="utf-8", errors="ignore") as f:
570
+ content = f.read()
571
+
572
+ outline = [f"File: {filepath}\n"]
573
+
574
+ if filepath.endswith('.py'):
575
+ import ast
576
+ tree = ast.parse(content)
577
+ for node in tree.body:
578
+ if isinstance(node, ast.ClassDef):
579
+ doc = ast.get_docstring(node)
580
+ doc_str = f' """{doc}"""\n' if doc else ''
581
+ outline.append(f"class {node.name}:\n{doc_str}")
582
+ for child in node.body:
583
+ if isinstance(child, ast.FunctionDef):
584
+ cdoc = ast.get_docstring(child)
585
+ cdoc_str = f' """{cdoc}"""\n' if cdoc else ''
586
+ outline.append(f" def {child.name}(...):\n{cdoc_str}")
587
+ elif isinstance(node, ast.FunctionDef):
588
+ doc = ast.get_docstring(node)
589
+ doc_str = f' """{doc}"""\n' if doc else ''
590
+ outline.append(f"def {node.name}(...):\n{doc_str}")
591
+ elif filepath.endswith(('.js', '.ts', '.jsx', '.tsx')):
592
+ import re
593
+ lines = content.splitlines()
594
+ for line in lines:
595
+ line_s = line.strip()
596
+ if re.match(r'^(export\s+)?(default\s+)?class\s+\w+', line_s):
597
+ outline.append(line_s)
598
+ elif re.match(r'^(export\s+)?(default\s+)?(async\s+)?function\s+\w+', line_s):
599
+ outline.append(line_s)
600
+ elif re.match(r'^(export\s+)?(const|let|var)\s+\w+\s*=\s*(async\s*)?(\([^)]*\)|[^=]+)\s*=>', line_s):
601
+ outline.append(line_s)
602
+ else:
603
+ return f"Error: '{filepath}' language is not supported for outlining. Use read_file instead."
604
+
605
+ if len(outline) == 1:
606
+ return outline[0] + "\nNo classes or functions found."
607
+ return "\n".join(outline)
608
+ except Exception as e:
609
+ return f"Error getting file outline: {e}"
510
610
 
511
611
  def read_file(filepath: str) -> str:
512
612
  """Read file contents with safety checks for size and binary detection."""
@@ -627,6 +727,10 @@ def write_file_with_diff(filepath: str, content: str, auto_approve: bool = False
627
727
  syntax = Syntax(preview, lang, theme="monokai", line_numbers=True)
628
728
  console.print(syntax)
629
729
 
730
+ syntax_err = _validate_syntax(filepath, content)
731
+ if syntax_err:
732
+ return f"Error: The edit introduces a syntax or linting error and was aborted.\n{syntax_err}"
733
+
630
734
  # YOLO mode: skip confirmation
631
735
  with terminal_lock:
632
736
  if not _ask_permission(" ▸ Allow?", auto_approve):
@@ -651,6 +755,33 @@ def _validate_syntax(filepath: str, file_content: str) -> str | None:
651
755
  ast.parse(file_content)
652
756
  except SyntaxError as e:
653
757
  return f"SyntaxError in Python code: {e.msg} at line {e.lineno}, col {e.offset}"
758
+
759
+ # Additional Linting
760
+ if shutil.which("ruff"):
761
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as tmp:
762
+ tmp.write(file_content)
763
+ tmp_path = tmp.name
764
+ try:
765
+ result = subprocess.run(["ruff", "check", tmp_path], capture_output=True, text=True)
766
+ if result.returncode != 0:
767
+ output = result.stdout.replace(tmp_path, os.path.basename(filepath))
768
+ return f"Ruff Linting Error:\n{output}"
769
+ finally:
770
+ os.unlink(tmp_path)
771
+
772
+ elif filepath.endswith((".js", ".jsx", ".ts", ".tsx")):
773
+ if shutil.which("eslint"):
774
+ ext = os.path.splitext(filepath)[1]
775
+ with tempfile.NamedTemporaryFile(mode="w", suffix=ext, delete=False) as tmp:
776
+ tmp.write(file_content)
777
+ tmp_path = tmp.name
778
+ try:
779
+ result = subprocess.run(["eslint", "--no-warn-ignored", tmp_path], capture_output=True, text=True)
780
+ if result.returncode != 0:
781
+ output = result.stdout.replace(tmp_path, os.path.basename(filepath))
782
+ return f"ESLint Error:\n{output}"
783
+ finally:
784
+ os.unlink(tmp_path)
654
785
  return None
655
786
 
656
787
  def _fuzzy_find_block(file_lines: list[str], target_content: str, start_line: int, end_line: int) -> str | None:
@@ -1033,6 +1164,26 @@ def grep_search(query: str, path: str = ".", is_regex: bool = False) -> str:
1033
1164
  if not os.path.exists(path):
1034
1165
  return f"Error: Path '{path}' does not exist."
1035
1166
 
1167
+ import shutil
1168
+ import subprocess
1169
+ if shutil.which("rg"):
1170
+ args = ["rg", "-n", "-m", "50"]
1171
+ if not is_regex:
1172
+ args.append("-F")
1173
+ args.extend(["-i", query, path])
1174
+ try:
1175
+ result = subprocess.run(args, capture_output=True, text=True, timeout=10)
1176
+ if result.stdout:
1177
+ lines = result.stdout.splitlines()
1178
+ if len(lines) >= 50:
1179
+ lines.append("\n(Showing first 50 results)")
1180
+ return "\n".join(lines)
1181
+ if result.returncode == 1:
1182
+ return "No matches found."
1183
+ except Exception as e:
1184
+ logger.debug("ripgrep failed, falling back to python search: %s", e)
1185
+ # Fall back to pure Python if rg fails
1186
+
1036
1187
  if is_regex:
1037
1188
  try:
1038
1189
  pattern = re.compile(query, re.IGNORECASE)
@@ -1119,6 +1270,35 @@ def find_files(pattern: str, path: str = ".") -> str:
1119
1270
  return f"Error finding files: {e}"
1120
1271
 
1121
1272
 
1273
+ def web_search_impl(query: str) -> str:
1274
+ import re
1275
+ import urllib.parse
1276
+ import urllib.request
1277
+ url = "https://html.duckduckgo.com/html/?q=" + urllib.parse.quote(query)
1278
+ try:
1279
+ req = urllib.request.Request(
1280
+ url,
1281
+ headers={'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'}
1282
+ )
1283
+ with urllib.request.urlopen(req, timeout=10) as response:
1284
+ html = response.read().decode('utf-8', errors='ignore')
1285
+
1286
+ results = []
1287
+ snippets = re.findall(r'<a class="result__snippet[^>]*href="([^"]+)"[^>]*>(.*?)</a>', html, re.IGNORECASE | re.DOTALL)
1288
+
1289
+ for href, text in snippets[:5]:
1290
+ clean_href = urllib.parse.unquote(href.replace('//duckduckgo.com/l/?uddg=', '').split('&')[0])
1291
+ clean_text = re.sub(r'<[^>]+>', '', text).strip()
1292
+ results.append(f"URL: {clean_href}\nSnippet: {clean_text}\n")
1293
+
1294
+ if not results:
1295
+ return "No results found or unable to parse search page."
1296
+
1297
+ return "\n".join(results)
1298
+ except Exception as e:
1299
+ return f"Error performing web search: {e}"
1300
+
1301
+
1122
1302
  # ─── Tool Dispatcher ───────────────────────────────────────────────────────────
1123
1303
 
1124
1304
  def execute_tool(tool_call, auto_approve: bool = False) -> str:
@@ -1226,6 +1406,12 @@ def execute_tool(tool_call, auto_approve: bool = False) -> str:
1226
1406
  console.print(tool_label)
1227
1407
  return truncate_output(grep_search(query, path, is_regex))
1228
1408
 
1409
+ elif func_name == "web_search":
1410
+ query = str(args.get("query", ""))
1411
+ tool_label.append(f" → '{query or '?'}'", style="dim")
1412
+ console.print(tool_label)
1413
+ return truncate_output(web_search_impl(query))
1414
+
1229
1415
  elif func_name == "find_files":
1230
1416
  pattern = str(args.get("pattern", ""))
1231
1417
  path = str(args.get("path", "."))
@@ -1233,6 +1419,12 @@ def execute_tool(tool_call, auto_approve: bool = False) -> str:
1233
1419
  console.print(tool_label)
1234
1420
  return truncate_output(find_files(pattern, path))
1235
1421
 
1422
+ elif func_name == "get_file_outline":
1423
+ filepath = str(args.get("filepath", ""))
1424
+ tool_label.append(f" → {filepath or '?'}", style="dim")
1425
+ console.print(tool_label)
1426
+ return truncate_output(get_file_outline(filepath))
1427
+
1236
1428
  else:
1237
1429
  # Check if a plugin handles this tool
1238
1430
  plugin_result = plugin_manager.execute_tool(tool_call, auto_approve=auto_approve)
@@ -248,7 +248,15 @@ def truncate_output(text: str, max_chars: int = 4000) -> str:
248
248
  )
249
249
 
250
250
 
251
+ _gitignore_cache = None
252
+ _gitignore_cache_time = 0
253
+
251
254
  def load_gitignore_patterns() -> list:
255
+ global _gitignore_cache, _gitignore_cache_time
256
+ now = time.time()
257
+ if _gitignore_cache is not None and (now - _gitignore_cache_time) < 5:
258
+ return _gitignore_cache
259
+
252
260
  patterns = [
253
261
  ".git/", "node_modules/", "__pycache__/", "venv/", ".env",
254
262
  "dist/", "build/", "*.egg-info/", ".DS_Store",
@@ -262,6 +270,9 @@ def load_gitignore_patterns() -> list:
262
270
  patterns.append(line)
263
271
  except Exception as e:
264
272
  logger.debug("Failed to load .gitignore: %s", e)
273
+
274
+ _gitignore_cache = patterns
275
+ _gitignore_cache_time = now
265
276
  return patterns
266
277
 
267
278
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aizen-ai-cli
3
- Version: 2.2.5
3
+ Version: 2.4.0
4
4
  Summary: Aizen AI Agent — A professional-grade AI coding assistant for your terminal.
5
5
  Author: Irtaza Malik
6
6
  License: MIT
@@ -54,9 +54,12 @@ A helpful AI coding assistant you can use right in your terminal. Aizen reads yo
54
54
  - **SQLite Session Persistence** — Session storage is powered by a SQLite database (`~/.aizen_sessions/aizen.db`), auto-migrating older JSON sessions.
55
55
  - **Project-Specific Rules** — Customizes agent behavior per repository by auto-loading `.aizen_rules` or `.cursorrules` from the current working directory.
56
56
  - **Smart Autocomplete** — `@`-mention files with Tab completion that respects `.gitignore` and supports directory traversal.
57
+ - **Vision Support** — Attach images natively (e.g., `@mockup.png`) and Aizen will automatically encode them for Vision APIs (GPT-4o, Claude 3.5 Sonnet).
58
+ - **Real-time Command Streaming** — Long-running shell commands stream their output live to the terminal instead of freezing with a spinner.
59
+ - **Smart Context Pruning** — Automatically drops old, large file attachments first when hitting the context limit before resorting to LLM summarization.
57
60
 
58
61
  ### Tools
59
- Aizen has 9 built-in tools the AI can use:
62
+ Aizen has 10 built-in tools the AI can use:
60
63
 
61
64
  | Tool | Description |
62
65
  |------|-------------|
@@ -70,6 +73,8 @@ Aizen has 9 built-in tools the AI can use:
70
73
  | `list_directory` | List files/folders with sizes, respecting `.gitignore` |
71
74
  | `grep_search` | Search for text or regex patterns across the codebase |
72
75
  | `find_files` | Find files by glob pattern (e.g., `*.py`, `Dockerfile`) |
76
+ | `get_file_outline` | Extract AST outline of a Python file (classes, methods, docstrings) without blowing up the context window |
77
+ | `web_search` | Search the web for current information, docs, or API references |
73
78
 
74
79
  ### Commands
75
80
 
@@ -93,9 +98,11 @@ Aizen has 9 built-in tools the AI can use:
93
98
  | `/export [file]` | Export conversation to a Markdown file |
94
99
  | `/config` | View current configuration |
95
100
  | `/mcp` | View configured MCP servers and their connection status |
101
+ | `/auto [task]` | Enter a fully autonomous agentic loop to execute a complex task step-by-step |
96
102
 
97
103
  ### Safety & UX
98
104
  - **Command Safety** — Read-only commands (`ls`, `cat`, `git status`, etc.) auto-execute. Destructive commands (`rm`, `sudo`, etc.) always require confirmation.
105
+ - **Autonomous Limits** — The `/auto` mode enforces a strict 25-step execution limit to prevent infinite loops and runaway costs.
99
106
  - **`--yolo` Mode** — Auto-approve all operations for power users.
100
107
  - **Background Tasks** — Run builds, tests, or other long-running tasks asynchronously while continuing to interact with Aizen.
101
108
  - **File Backups** — Every file modification creates a backup. Use `/undo` to restore.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "aizen-ai-cli"
7
- version = "2.2.5"
7
+ version = "2.4.0"
8
8
  description = "Aizen AI Agent — A professional-grade AI coding assistant for your terminal."
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -10,7 +10,7 @@ def parse_requirements(filename):
10
10
 
11
11
  setup(
12
12
  name="aizen-ai-cli",
13
- version="2.2.4",
13
+ version="2.4.0",
14
14
  description="Aizen AI Agent — A professional-grade AI coding assistant for your terminal.",
15
15
  packages=["aizen"],
16
16
  install_requires=parse_requirements("requirements.txt"),
@@ -153,15 +153,15 @@ class TestWriteFile:
153
153
 
154
154
  def test_write_creates_parent_dirs(self, tmp_dir):
155
155
  filepath = os.path.join(tmp_dir, "deep", "nested", "file.py")
156
- result = write_file_with_diff(filepath, "content\n", auto_approve=True)
156
+ result = write_file_with_diff(filepath, "print('content')\n", auto_approve=True)
157
157
  assert "✓" in result
158
158
  assert os.path.exists(filepath)
159
159
 
160
160
  def test_write_overwrite_existing(self, sample_file):
161
- result = write_file_with_diff(sample_file, "new content\n", auto_approve=True)
161
+ result = write_file_with_diff(sample_file, "print('new content')\n", auto_approve=True)
162
162
  assert "✓" in result
163
163
  with open(sample_file) as f:
164
- assert f.read() == "new content\n"
164
+ assert f.read() == "print('new content')\n"
165
165
 
166
166
  def test_write_no_changes(self, sample_file):
167
167
  with open(sample_file) as f:
File without changes
File without changes
File without changes