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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.1}/PKG-INFO +9 -2
  2. {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.1}/README.md +8 -1
  3. aizen_ai_cli-2.4.1/aizen/agent.py +274 -0
  4. {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.1}/aizen/commands.py +37 -18
  5. {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.1}/aizen/config.py +31 -10
  6. {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.1}/aizen/context.py +61 -0
  7. aizen_ai_cli-2.4.1/aizen/main.py +499 -0
  8. {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.1}/aizen/session.py +49 -36
  9. aizen_ai_cli-2.4.1/aizen/tools/__init__.py +13 -0
  10. aizen_ai_cli-2.4.1/aizen/tools/commands.py +222 -0
  11. aizen_ai_cli-2.4.1/aizen/tools/dispatcher.py +437 -0
  12. aizen_ai_cli-2.4.1/aizen/tools/file_ops.py +309 -0
  13. aizen_ai_cli-2.4.1/aizen/tools/helpers.py +352 -0
  14. aizen_ai_cli-2.4.1/aizen/tools/search.py +199 -0
  15. {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.1}/aizen/utils.py +11 -0
  16. {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.1}/aizen_ai_cli.egg-info/PKG-INFO +9 -2
  17. {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.1}/aizen_ai_cli.egg-info/SOURCES.txt +14 -2
  18. {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.1}/pyproject.toml +3 -1
  19. {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.1}/setup.py +1 -1
  20. aizen_ai_cli-2.4.1/tests/test_agent.py +178 -0
  21. aizen_ai_cli-2.4.1/tests/test_commands.py +96 -0
  22. {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.1}/tests/test_config.py +5 -1
  23. aizen_ai_cli-2.4.1/tests/test_dispatcher.py +85 -0
  24. aizen_ai_cli-2.4.1/tests/test_file_ops.py +168 -0
  25. aizen_ai_cli-2.4.1/tests/test_helpers.py +46 -0
  26. aizen_ai_cli-2.4.1/tests/test_plugins.py +114 -0
  27. aizen_ai_cli-2.4.1/tests/test_retry.py +149 -0
  28. aizen_ai_cli-2.4.1/tests/test_search.py +78 -0
  29. aizen_ai_cli-2.2.5/aizen/main.py +0 -627
  30. aizen_ai_cli-2.2.5/aizen/tools.py +0 -1244
  31. aizen_ai_cli-2.2.5/tests/test_commands.py +0 -212
  32. aizen_ai_cli-2.2.5/tests/test_tools.py +0 -427
  33. {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.1}/MANIFEST.in +0 -0
  34. {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.1}/aizen/__init__.py +0 -0
  35. {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.1}/aizen/exceptions.py +0 -0
  36. {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.1}/aizen/logging_config.py +0 -0
  37. {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.1}/aizen/mcp.py +0 -0
  38. {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.1}/aizen/plugins.py +0 -0
  39. {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.1}/aizen/retry.py +0 -0
  40. {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.1}/aizen_ai_cli.egg-info/dependency_links.txt +0 -0
  41. {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.1}/aizen_ai_cli.egg-info/entry_points.txt +0 -0
  42. {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.1}/aizen_ai_cli.egg-info/requires.txt +0 -0
  43. {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.1}/aizen_ai_cli.egg-info/top_level.txt +0 -0
  44. {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.1}/requirements.txt +0 -0
  45. {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.1}/setup.cfg +0 -0
  46. {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.1}/tests/test_context.py +0 -0
  47. {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.1}/tests/test_main.py +0 -0
  48. {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.1}/tests/test_mcp.py +0 -0
  49. {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.1}/tests/test_session.py +0 -0
  50. {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.1}/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.1
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.
@@ -0,0 +1,274 @@
1
+ """
2
+ AgentRunner — Encapsulates the core agent turn loop.
3
+
4
+ Extracted from main.py to enable:
5
+ - Isolated testing with mocked clients
6
+ - Cleaner separation between CLI plumbing and agent logic
7
+ - Reuse in non-interactive contexts (e.g., scripted pipelines)
8
+ """
9
+
10
+ import asyncio
11
+ import json
12
+ import random
13
+ from typing import Any
14
+
15
+ from rich.live import Live
16
+ from rich.markdown import Markdown
17
+ from rich.spinner import Spinner
18
+ from rich.text import Text
19
+
20
+ from .config import Theme, console, get_active_model
21
+ from .logging_config import logger
22
+ from .tools import execute_tool
23
+ from .utils import Struct, truncate_output
24
+
25
+
26
+ class AgentRunner:
27
+ """Handles a single conversational turn: stream → parse → execute tools → loop."""
28
+
29
+ def __init__(
30
+ self,
31
+ client,
32
+ active_tools: list[dict],
33
+ context_manager,
34
+ token_tracker,
35
+ mcp_manager=None,
36
+ auto_approve: bool = False,
37
+ is_auto_mode: bool = False,
38
+ auto_iteration_count: int = 0,
39
+ max_auto_iterations: int = 50,
40
+ ):
41
+ self.client = client
42
+ self.active_tools = active_tools
43
+ self.context_manager = context_manager
44
+ self.token_tracker = token_tracker
45
+ self.mcp_manager = mcp_manager
46
+ self.auto_approve = auto_approve
47
+ self.is_auto_mode = is_auto_mode
48
+ self.auto_iteration_count = auto_iteration_count
49
+ self.max_auto_iterations = max_auto_iterations
50
+
51
+ async def run_turn(self, messages: list[dict]) -> None:
52
+ """
53
+ Execute a full agent turn: stream the model's response, handle tool calls,
54
+ and loop until the model produces a final text response (no more tool calls).
55
+ """
56
+ while True:
57
+ if self.is_auto_mode:
58
+ self.auto_iteration_count += 1
59
+ if self.auto_iteration_count > self.max_auto_iterations:
60
+ console.print(
61
+ f" [{Theme.WARNING}]⚠️ Autonomous mode reached iteration limit "
62
+ f"({self.max_auto_iterations}). Exiting auto mode.[/{Theme.WARNING}]"
63
+ )
64
+ self.is_auto_mode = False
65
+ messages.append({
66
+ "role": "user",
67
+ "content": (
68
+ f"You have reached the maximum number of autonomous iterations "
69
+ f"({self.max_auto_iterations}). Please provide a brief summary "
70
+ f"of what you have accomplished and what remains."
71
+ ),
72
+ })
73
+ self.auto_iteration_count = 0
74
+
75
+ # Stream the response
76
+ stream_result = await self._stream_response(messages)
77
+ if stream_result is None:
78
+ break # Error occurred (already printed to console)
79
+
80
+ full_content, tool_calls_list, api_usage = stream_result
81
+
82
+ # Track tokens
83
+ self._track_tokens(messages, full_content, api_usage)
84
+
85
+ # Add assistant message to history
86
+ assistant_msg: dict[str, Any] = {
87
+ "role": "assistant",
88
+ "content": full_content or "",
89
+ }
90
+ if tool_calls_list:
91
+ assistant_msg["tool_calls"] = tool_calls_list
92
+ messages.append(assistant_msg)
93
+
94
+ # If no tool calls, we're done with this turn
95
+ if not tool_calls_list:
96
+ break
97
+
98
+ # Execute tool calls
99
+ tool_results = await self._execute_tools(tool_calls_list)
100
+ messages.extend(tool_results)
101
+
102
+ # Loop continues — model processes tool results
103
+
104
+ async def _stream_response(
105
+ self, messages: list[dict]
106
+ ) -> tuple[str, list[dict], Any] | None:
107
+ """
108
+ Stream a response from the model.
109
+
110
+ Returns (full_content, tool_calls_list, api_usage) or None on error.
111
+ """
112
+ full_content = ""
113
+ accumulated_tool_calls: dict[int, dict] = {}
114
+ api_usage = None
115
+
116
+ spinner_label = random.choice([
117
+ "Thinking...", "Analyzing...", "Reasoning...",
118
+ "Processing...", "Considering...", "Exploring...", "Synthesizing...",
119
+ ])
120
+
121
+ if self.is_auto_mode:
122
+ spinner_text = Text(
123
+ f" [Step {self.auto_iteration_count}/{self.max_auto_iterations}] {spinner_label}",
124
+ style=f"{Theme.MUTED} italic",
125
+ )
126
+ else:
127
+ spinner_text = Text(f" {spinner_label}", style=f"{Theme.MUTED} italic")
128
+
129
+ spinner_display = Spinner("dots2", text=spinner_text, style=f"{Theme.PRIMARY} bold")
130
+
131
+ try:
132
+ with Live(
133
+ spinner_display, console=console, refresh_per_second=8
134
+ ) as live:
135
+ from openai import AsyncStream
136
+
137
+ model = get_active_model()
138
+ api_params: dict[str, Any] = {
139
+ "model": model,
140
+ "messages": messages,
141
+ "stream": True,
142
+ "stream_options": {"include_usage": True},
143
+ }
144
+ if self.active_tools:
145
+ api_params["tools"] = self.active_tools
146
+ api_params["tool_choice"] = "auto"
147
+
148
+ stream: AsyncStream = await self.client.chat.completions.create(**api_params)
149
+
150
+ async for chunk in stream:
151
+ if hasattr(chunk, "usage") and chunk.usage:
152
+ api_usage = chunk.usage
153
+
154
+ delta = chunk.choices[0].delta if chunk.choices else None
155
+ if not delta:
156
+ continue
157
+
158
+ if delta.content:
159
+ full_content += delta.content
160
+ if full_content.strip():
161
+ try:
162
+ display_content = f"**◆ AIZEN:** {full_content}"
163
+ rendered = Markdown(display_content)
164
+ live.update(rendered)
165
+ except Exception:
166
+ display_text = Text.from_markup(
167
+ f"{Theme.BADGE} {full_content}"
168
+ )
169
+ live.update(display_text)
170
+
171
+ if delta.tool_calls:
172
+ for tc in delta.tool_calls:
173
+ idx = tc.index
174
+ if idx not in accumulated_tool_calls:
175
+ accumulated_tool_calls[idx] = {
176
+ "id": "", "name": "", "arguments": "", "type": "function",
177
+ }
178
+ if tc.id:
179
+ accumulated_tool_calls[idx]["id"] = tc.id
180
+ if tc.function:
181
+ if tc.function.name:
182
+ accumulated_tool_calls[idx]["name"] += tc.function.name
183
+ if tc.function.arguments:
184
+ accumulated_tool_calls[idx]["arguments"] += tc.function.arguments
185
+
186
+ names = [v["name"] for v in accumulated_tool_calls.values() if v["name"]]
187
+ if names and not full_content.strip():
188
+ tool_text = Text()
189
+ tool_text.append(" ◆ ", style=f"bold {Theme.ACCENT}")
190
+ tool_text.append("Invoking ", style=f"{Theme.TEXT}")
191
+ tool_text.append(f"{', '.join(names)}", style=f"bold {Theme.ACCENT}")
192
+ tool_text.append(" ...", style=f"{Theme.MUTED}")
193
+ live.update(tool_text)
194
+
195
+ except Exception as e:
196
+ # Re-raise — let the caller (main_loop) handle specific exception types
197
+ raise
198
+
199
+ # Build tool calls list
200
+ tool_calls_list: list[dict[str, Any]] = []
201
+ for idx in sorted(accumulated_tool_calls.keys()):
202
+ tc = accumulated_tool_calls[idx]
203
+ tool_calls_list.append({
204
+ "id": tc["id"],
205
+ "type": "function",
206
+ "function": {
207
+ "name": tc["name"],
208
+ "arguments": tc["arguments"],
209
+ },
210
+ })
211
+
212
+ return full_content, tool_calls_list, api_usage
213
+
214
+ async def _execute_tools(self, tool_calls_list: list[dict]) -> list[dict]:
215
+ """Execute tool calls (in parallel where safe) and return tool result messages."""
216
+
217
+ async def _exec_tool(tc_dict: dict) -> dict:
218
+ func_name = tc_dict["function"]["name"]
219
+ if func_name.startswith("mcp_") and self.mcp_manager:
220
+ try:
221
+ args = json.loads(tc_dict["function"]["arguments"])
222
+ result = await self.mcp_manager.call_tool(func_name, args)
223
+ except json.JSONDecodeError:
224
+ result = f"Error: Invalid JSON arguments for {func_name}."
225
+ else:
226
+ func_struct = Struct(**tc_dict["function"])
227
+ tc_struct = Struct(
228
+ id=tc_dict["id"],
229
+ type=tc_dict["type"],
230
+ function=func_struct,
231
+ )
232
+ result = await asyncio.to_thread(execute_tool, tc_struct, self.auto_approve)
233
+
234
+ return {
235
+ "role": "tool",
236
+ "tool_call_id": tc_dict["id"],
237
+ "name": func_name,
238
+ "content": truncate_output(result),
239
+ }
240
+
241
+ tool_results = await asyncio.gather(
242
+ *[_exec_tool(tc) for tc in tool_calls_list],
243
+ return_exceptions=True,
244
+ )
245
+
246
+ # Handle individual tool failures gracefully
247
+ for i, result in enumerate(tool_results):
248
+ if isinstance(result, Exception):
249
+ logger.error("Tool execution failed: %s", result)
250
+ tool_results[i] = {
251
+ "role": "tool",
252
+ "tool_call_id": tool_calls_list[i]["id"],
253
+ "name": tool_calls_list[i]["function"]["name"],
254
+ "content": f"Error: Tool execution failed — {type(result).__name__}: {result}",
255
+ }
256
+
257
+ return list(tool_results)
258
+
259
+ def _track_tokens(self, messages, full_content, api_usage):
260
+ """Update token tracking from API usage or estimation."""
261
+ if api_usage and hasattr(api_usage, "prompt_tokens"):
262
+ self.token_tracker.add_api_usage(
263
+ api_usage.prompt_tokens or 0,
264
+ api_usage.completion_tokens or 0,
265
+ )
266
+ self.context_manager.update(
267
+ (api_usage.prompt_tokens or 0) + (api_usage.completion_tokens or 0)
268
+ )
269
+ elif full_content:
270
+ estimated_input = self.context_manager.estimate_messages_tokens(
271
+ messages, self.token_tracker.estimate_tokens
272
+ )
273
+ estimated_output = self.token_tracker.estimate_tokens(full_content)
274
+ self.token_tracker.add_usage(estimated_input, estimated_output)
@@ -5,8 +5,10 @@ import re
5
5
  import subprocess
6
6
  from datetime import datetime
7
7
 
8
+ import questionary
9
+
8
10
  from prompt_toolkit.completion import Completer, Completion
9
- from prompt_toolkit.shortcuts import prompt
11
+ from prompt_toolkit import PromptSession
10
12
  from rich.table import Table
11
13
 
12
14
  from .config import (
@@ -44,6 +46,7 @@ SLASH_COMMANDS = [
44
46
  ("/mcp", "View configured MCP servers and their status"),
45
47
  ("/commit", "Auto-generate and commit changes"),
46
48
  ("/diff", "Show all uncommitted changes"),
49
+ ("/auto", "Enter autonomous agentic mode for a complex task"),
47
50
  ]
48
51
 
49
52
  # In-memory checkpoint storage for conversation branching
@@ -284,6 +287,10 @@ async def handle_slash_command(
284
287
  help_table.add_row(" 🔀 /commit", "Auto-generate and commit changes")
285
288
  help_table.add_row(" 📊 /diff", "Show all uncommitted changes")
286
289
 
290
+ # ── Agent ──
291
+ help_table.add_row(f"[bold {Theme.MUTED}]── Agent ──[/bold {Theme.MUTED}]", "")
292
+ help_table.add_row(" 🤖 /auto [task]", "Enter autonomous mode for a complex task (max iterations apply)")
293
+
287
294
  # ── Shortcuts ──
288
295
  help_table.add_row(f"[bold {Theme.MUTED}]── Shortcuts ──[/bold {Theme.MUTED}]", "")
289
296
  help_table.add_row(f" [{Theme.PINK}]@file / @url[/{Theme.PINK}]", "Attach file context or web URL")
@@ -416,12 +423,8 @@ async def handle_slash_command(
416
423
  # Attempt LLM-based summarization for much better context retention
417
424
  console.print(f" [{Theme.MUTED}]Summarizing conversation with AI...[/{Theme.MUTED}]")
418
425
  try:
419
- from openai import AsyncOpenAI as _AsyncOpenAI
420
-
421
- _config = load_config()
422
- _api_key = _config.get("OPENROUTER_API_KEY", "")
423
- _api_base = _config.get("API_BASE_URL", "https://openrouter.ai/api/v1")
424
- _client = _AsyncOpenAI(base_url=_api_base, api_key=_api_key)
426
+ # Use the client passed to handle_slash_command
427
+ _client = client
425
428
 
426
429
  # Build a summarization request from the middle messages
427
430
  summary_messages = [
@@ -601,8 +604,8 @@ async def handle_slash_command(
601
604
  console.print(f" [{Theme.WARNING}]No changes found to commit.[/{Theme.WARNING}]\n")
602
605
  return False
603
606
 
604
- answer = prompt("No staged changes. Stage all current changes? [Y/n] ")
605
- if answer.lower() not in ("y", "yes", ""):
607
+ answer = await questionary.confirm("No staged changes. Stage all current changes?").ask_async()
608
+ if not answer:
606
609
  console.print(f" [{Theme.WARNING}]Commit aborted.[/{Theme.WARNING}]\n")
607
610
  return False
608
611
 
@@ -626,24 +629,40 @@ async def handle_slash_command(
626
629
  messages=commit_messages,
627
630
  max_tokens=200,
628
631
  )
629
- commit_msg = response.choices[0].message.content.strip()
632
+ commit_content = response.choices[0].message.content
633
+ commit_msg = commit_content.strip() if commit_content else ""
630
634
  # Remove any markdown codeblocks if model didn't listen
631
635
  commit_msg = commit_msg.replace("```text", "").replace("```", "").strip()
632
636
 
633
- console.print(f"\n [bold {Theme.TEXT}]Generated Commit Message:[/bold {Theme.TEXT}]")
634
- console.print(f" [{Theme.ACCENT}]{commit_msg}[/{Theme.ACCENT}]\n")
635
-
636
- action = prompt("Commit with this message? [Y/n/e(dit)] ")
637
- action = action.lower().strip()
637
+ if not commit_msg:
638
+ console.print(f"\n [{Theme.WARNING}]⚠️ The model failed to generate a commit message.[/{Theme.WARNING}]")
639
+ action = "Edit message"
640
+ else:
641
+ console.print(f"\n [bold {Theme.TEXT}]Generated Commit Message:[/bold {Theme.TEXT}]")
642
+ console.print(f" [{Theme.ACCENT}]{commit_msg}[/{Theme.ACCENT}]\n")
643
+
644
+ action = await questionary.select(
645
+ "Commit with this message?",
646
+ choices=[
647
+ "Yes, commit this",
648
+ "Edit message",
649
+ "Cancel"
650
+ ]
651
+ ).ask_async()
638
652
 
639
- if action in ("y", "yes", ""):
653
+ if action == "Yes, commit this":
640
654
  final_msg = commit_msg
641
- elif action in ("e", "edit"):
642
- final_msg = prompt("Edit message: ", default=commit_msg)
655
+ elif action == "Edit message":
656
+ final_msg = await questionary.text("Edit message:", default=commit_msg).ask_async()
643
657
  else:
644
658
  console.print("[yellow]Commit aborted.[/yellow]\n")
645
659
  return False
646
660
 
661
+ final_msg = final_msg.strip()
662
+ if not final_msg:
663
+ console.print(f" [{Theme.ERROR}]Error: Commit message cannot be empty. Aborted.[/{Theme.ERROR}]\n")
664
+ return False
665
+
647
666
  subprocess.run(["git", "commit", "-m", final_msg], check=True)
648
667
  console.print(f" [{Theme.SUCCESS}]✓ Committed successfully.[/{Theme.SUCCESS}]\n")
649
668
 
@@ -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.1"
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
@@ -168,12 +169,14 @@ def build_system_prompt(config: dict | None = None) -> str:
168
169
 
169
170
  return "\n".join(parts)
170
171
 
171
- # Global state for active model
172
+ # Global state for active model (protected by lock for thread safety)
172
173
  active_model = DEFAULT_MODEL
174
+ _model_lock = threading.Lock()
173
175
 
174
176
  def set_active_model(model_name: str, save: bool = False):
175
177
  global active_model
176
- active_model = model_name
178
+ with _model_lock:
179
+ active_model = model_name
177
180
  if save:
178
181
  try:
179
182
  config = load_config()
@@ -184,7 +187,8 @@ def set_active_model(model_name: str, save: bool = False):
184
187
  logger.error("Failed to save default model: %s", e)
185
188
 
186
189
  def get_active_model() -> str:
187
- return active_model
190
+ with _model_lock:
191
+ return active_model
188
192
 
189
193
  # ─── Configuration ──────────────────────────────────────────────────────────────
190
194
 
@@ -209,13 +213,30 @@ def migrate_legacy_data():
209
213
 
210
214
  def load_config() -> dict:
211
215
  migrate_legacy_data()
216
+
217
+ config = {}
218
+ # Load global config
212
219
  if os.path.exists(CONFIG_PATH):
213
220
  try:
214
221
  with open(CONFIG_PATH) as f:
215
- return json.load(f)
222
+ config = json.load(f)
216
223
  except Exception as e:
217
- logger.debug("Failed to load config file: %s", e)
218
- return {}
224
+ logger.debug("Failed to load global config file: %s", e)
225
+
226
+ # Merge local config if present
227
+ local_config_path = os.path.join(os.getcwd(), ".aizen_config.json")
228
+ if os.path.exists(local_config_path):
229
+ try:
230
+ with open(local_config_path) as f:
231
+ local_config = json.load(f)
232
+ # Merge local config keys (overriding global ones)
233
+ if isinstance(local_config, dict):
234
+ config.update(local_config)
235
+ console.print(f"{Theme.SYS} Local config loaded from [#d3fbff]{local_config_path}[/#d3fbff]")
236
+ except Exception as e:
237
+ logger.debug("Failed to load local config file: %s", e)
238
+
239
+ return config
219
240
 
220
241
 
221
242
  def get_mcp_servers(config: dict) -> dict:
@@ -252,8 +273,8 @@ def get_api_key(config: dict, reset: bool = False) -> str:
252
273
 
253
274
  key = getpass.getpass("API Key: ").strip()
254
275
  if not key:
255
- console.print("[bold red]Error:[/bold red] API Key cannot be empty.")
256
- sys.exit(1)
276
+ from .exceptions import APIKeyError
277
+ raise APIKeyError("API Key cannot be empty.")
257
278
 
258
279
  config["OPENROUTER_API_KEY"] = key
259
280
  save_config(config)
@@ -171,3 +171,64 @@ class ContextManager:
171
171
  def get_footer_text(self) -> str:
172
172
  """Get a compact footer string showing context usage."""
173
173
  return f"[{Theme.MUTED}]ctx:[/{Theme.MUTED}] {self.get_usage_bar(10)}"
174
+
175
+
176
+ class ContextPruner:
177
+ """Handles smart pruning and summarization of old conversation context."""
178
+
179
+ @staticmethod
180
+ def prune_attached_contexts(messages: list[dict]) -> int:
181
+ """
182
+ Removes <file_context>, <url_context>, etc. blocks from older user messages.
183
+ Returns the number of messages modified.
184
+ """
185
+ import re
186
+ dropped_count = 0
187
+
188
+ # Keep the system prompt and the last couple of turns intact
189
+ if len(messages) <= 3:
190
+ return 0
191
+
192
+ for msg in messages[1:-2]:
193
+ if msg.get("role") == "user" and msg.get("content"):
194
+ old_content = msg["content"]
195
+ new_content = re.sub(r'<file_context path="[^"]+">.*?</file_context>', '[File context dropped]', old_content, flags=re.DOTALL)
196
+ new_content = re.sub(r'<url_context url="[^"]+">.*?</url_context>', '[URL context dropped]', new_content, flags=re.DOTALL)
197
+ new_content = re.sub(r'<directory_context path="[^"]+">.*?</directory_context>', '[Directory context dropped]', new_content, flags=re.DOTALL)
198
+ new_content = re.sub(r'<command_context cmd="[^"]+">.*?</command_context>', '[Command context dropped]', new_content, flags=re.DOTALL)
199
+
200
+ if old_content != new_content:
201
+ msg["content"] = new_content
202
+ dropped_count += 1
203
+
204
+ return dropped_count
205
+
206
+ @staticmethod
207
+ def summarize_old_messages(messages: list[dict], recent_count: int = 4) -> list[dict]:
208
+ """
209
+ Condenses older messages into a naive summary to save tokens.
210
+ Modifies the `messages` list in place and returns the summary message text.
211
+ """
212
+ if len(messages) <= recent_count + 2:
213
+ return ""
214
+
215
+ system_msg = messages[0]
216
+ recent = messages[-recent_count:]
217
+ middle = messages[1:-recent_count]
218
+
219
+ user_topics = [
220
+ m["content"][:100].replace('\n', ' ')
221
+ for m in middle
222
+ if m.get("role") == "user" and m.get("content")
223
+ ]
224
+
225
+ summary = "Previous conversation summary: The user and assistant discussed " + "; ".join(user_topics[:5]) + ". The assistant helped with these requests."
226
+
227
+ messages[:] = [
228
+ system_msg,
229
+ {"role": "user", "content": f"Previous conversation summary:\n{summary}"},
230
+ {"role": "assistant", "content": "Understood. I have the context. How can I continue helping?"},
231
+ ] + recent
232
+
233
+ return summary
234
+