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.
- {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.0}/PKG-INFO +9 -2
- {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.0}/README.md +8 -1
- {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.0}/aizen/commands.py +5 -0
- {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.0}/aizen/config.py +25 -7
- {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.0}/aizen/main.py +186 -67
- {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.0}/aizen/session.py +3 -2
- {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.0}/aizen/tools.py +201 -9
- {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.0}/aizen/utils.py +11 -0
- {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.0}/aizen_ai_cli.egg-info/PKG-INFO +9 -2
- {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.0}/pyproject.toml +1 -1
- {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.0}/setup.py +1 -1
- {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.0}/tests/test_tools.py +3 -3
- {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.0}/MANIFEST.in +0 -0
- {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.0}/aizen/__init__.py +0 -0
- {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.0}/aizen/context.py +0 -0
- {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.0}/aizen/exceptions.py +0 -0
- {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.0}/aizen/logging_config.py +0 -0
- {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.0}/aizen/mcp.py +0 -0
- {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.0}/aizen/plugins.py +0 -0
- {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.0}/aizen/retry.py +0 -0
- {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.0}/aizen_ai_cli.egg-info/SOURCES.txt +0 -0
- {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.0}/aizen_ai_cli.egg-info/dependency_links.txt +0 -0
- {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.0}/aizen_ai_cli.egg-info/entry_points.txt +0 -0
- {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.0}/aizen_ai_cli.egg-info/requires.txt +0 -0
- {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.0}/aizen_ai_cli.egg-info/top_level.txt +0 -0
- {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.0}/requirements.txt +0 -0
- {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.0}/setup.cfg +0 -0
- {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.0}/tests/test_commands.py +0 -0
- {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.0}/tests/test_config.py +0 -0
- {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.0}/tests/test_context.py +0 -0
- {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.0}/tests/test_main.py +0 -0
- {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.0}/tests/test_mcp.py +0 -0
- {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.0}/tests/test_session.py +0 -0
- {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.
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
219
|
+
config = json.load(f)
|
|
216
220
|
except Exception as e:
|
|
217
|
-
logger.debug("Failed to load config file: %s", e)
|
|
218
|
-
|
|
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
|
-
|
|
256
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
302
|
-
user_input.strip()
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
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
|
|
322
|
-
if context_manager.needs_auto_compact() and len(messages) >
|
|
323
|
-
console.print("[dim yellow]⚡
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
messages
|
|
352
|
-
|
|
353
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
101
|
-
|
|
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
|
-
|
|
104
|
-
|
|
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.
|
|
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
|
|
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.
|
|
@@ -10,7 +10,7 @@ def parse_requirements(filename):
|
|
|
10
10
|
|
|
11
11
|
setup(
|
|
12
12
|
name="aizen-ai-cli",
|
|
13
|
-
version="2.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|