aizen-ai-cli 2.4.0__tar.gz → 2.4.2__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 (52) hide show
  1. aizen_ai_cli-2.4.2/PKG-INFO +103 -0
  2. aizen_ai_cli-2.4.2/README.md +63 -0
  3. aizen_ai_cli-2.4.2/aizen/agent.py +279 -0
  4. {aizen_ai_cli-2.4.0 → aizen_ai_cli-2.4.2}/aizen/commands.py +36 -18
  5. {aizen_ai_cli-2.4.0 → aizen_ai_cli-2.4.2}/aizen/config.py +7 -4
  6. {aizen_ai_cli-2.4.0 → aizen_ai_cli-2.4.2}/aizen/context.py +61 -0
  7. {aizen_ai_cli-2.4.0 → aizen_ai_cli-2.4.2}/aizen/main.py +47 -294
  8. {aizen_ai_cli-2.4.0 → aizen_ai_cli-2.4.2}/aizen/session.py +46 -34
  9. aizen_ai_cli-2.4.2/aizen/tools/__init__.py +13 -0
  10. aizen_ai_cli-2.4.2/aizen/tools/commands.py +279 -0
  11. aizen_ai_cli-2.4.2/aizen/tools/dispatcher.py +437 -0
  12. aizen_ai_cli-2.4.2/aizen/tools/file_ops.py +309 -0
  13. aizen_ai_cli-2.4.2/aizen/tools/helpers.py +352 -0
  14. aizen_ai_cli-2.4.2/aizen/tools/search.py +199 -0
  15. {aizen_ai_cli-2.4.0 → aizen_ai_cli-2.4.2}/aizen/utils.py +1 -1
  16. aizen_ai_cli-2.4.2/aizen_ai_cli.egg-info/PKG-INFO +103 -0
  17. {aizen_ai_cli-2.4.0 → aizen_ai_cli-2.4.2}/aizen_ai_cli.egg-info/SOURCES.txt +14 -2
  18. {aizen_ai_cli-2.4.0 → aizen_ai_cli-2.4.2}/pyproject.toml +3 -1
  19. {aizen_ai_cli-2.4.0 → aizen_ai_cli-2.4.2}/setup.py +1 -1
  20. aizen_ai_cli-2.4.2/tests/test_agent.py +178 -0
  21. aizen_ai_cli-2.4.2/tests/test_commands.py +89 -0
  22. {aizen_ai_cli-2.4.0 → aizen_ai_cli-2.4.2}/tests/test_config.py +5 -1
  23. aizen_ai_cli-2.4.2/tests/test_dispatcher.py +85 -0
  24. aizen_ai_cli-2.4.2/tests/test_file_ops.py +168 -0
  25. aizen_ai_cli-2.4.2/tests/test_helpers.py +46 -0
  26. aizen_ai_cli-2.4.2/tests/test_plugins.py +114 -0
  27. aizen_ai_cli-2.4.2/tests/test_retry.py +149 -0
  28. aizen_ai_cli-2.4.2/tests/test_search.py +78 -0
  29. aizen_ai_cli-2.4.0/PKG-INFO +0 -276
  30. aizen_ai_cli-2.4.0/README.md +0 -236
  31. aizen_ai_cli-2.4.0/aizen/tools.py +0 -1436
  32. aizen_ai_cli-2.4.0/aizen_ai_cli.egg-info/PKG-INFO +0 -276
  33. aizen_ai_cli-2.4.0/tests/test_commands.py +0 -212
  34. aizen_ai_cli-2.4.0/tests/test_tools.py +0 -427
  35. {aizen_ai_cli-2.4.0 → aizen_ai_cli-2.4.2}/MANIFEST.in +0 -0
  36. {aizen_ai_cli-2.4.0 → aizen_ai_cli-2.4.2}/aizen/__init__.py +0 -0
  37. {aizen_ai_cli-2.4.0 → aizen_ai_cli-2.4.2}/aizen/exceptions.py +0 -0
  38. {aizen_ai_cli-2.4.0 → aizen_ai_cli-2.4.2}/aizen/logging_config.py +0 -0
  39. {aizen_ai_cli-2.4.0 → aizen_ai_cli-2.4.2}/aizen/mcp.py +0 -0
  40. {aizen_ai_cli-2.4.0 → aizen_ai_cli-2.4.2}/aizen/plugins.py +0 -0
  41. {aizen_ai_cli-2.4.0 → aizen_ai_cli-2.4.2}/aizen/retry.py +0 -0
  42. {aizen_ai_cli-2.4.0 → aizen_ai_cli-2.4.2}/aizen_ai_cli.egg-info/dependency_links.txt +0 -0
  43. {aizen_ai_cli-2.4.0 → aizen_ai_cli-2.4.2}/aizen_ai_cli.egg-info/entry_points.txt +0 -0
  44. {aizen_ai_cli-2.4.0 → aizen_ai_cli-2.4.2}/aizen_ai_cli.egg-info/requires.txt +0 -0
  45. {aizen_ai_cli-2.4.0 → aizen_ai_cli-2.4.2}/aizen_ai_cli.egg-info/top_level.txt +0 -0
  46. {aizen_ai_cli-2.4.0 → aizen_ai_cli-2.4.2}/requirements.txt +0 -0
  47. {aizen_ai_cli-2.4.0 → aizen_ai_cli-2.4.2}/setup.cfg +0 -0
  48. {aizen_ai_cli-2.4.0 → aizen_ai_cli-2.4.2}/tests/test_context.py +0 -0
  49. {aizen_ai_cli-2.4.0 → aizen_ai_cli-2.4.2}/tests/test_main.py +0 -0
  50. {aizen_ai_cli-2.4.0 → aizen_ai_cli-2.4.2}/tests/test_mcp.py +0 -0
  51. {aizen_ai_cli-2.4.0 → aizen_ai_cli-2.4.2}/tests/test_session.py +0 -0
  52. {aizen_ai_cli-2.4.0 → aizen_ai_cli-2.4.2}/tests/test_utils.py +0 -0
@@ -0,0 +1,103 @@
1
+ Metadata-Version: 2.4
2
+ Name: aizen-ai-cli
3
+ Version: 2.4.2
4
+ Summary: Aizen AI Agent — A professional-grade AI coding assistant for your terminal.
5
+ Author: Irtaza Malik
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/irtaza302/aizen-agent
8
+ Project-URL: Repository, https://github.com/irtaza302/aizen-agent
9
+ Project-URL: Issues, https://github.com/irtaza302/aizen-agent/issues
10
+ Keywords: ai,cli,coding-assistant,terminal,openrouter,llm
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Classifier: Topic :: Software Development
22
+ Classifier: Topic :: Utilities
23
+ Requires-Python: >=3.10
24
+ Description-Content-Type: text/markdown
25
+ Requires-Dist: openai>=1.0
26
+ Requires-Dist: python-dotenv>=1.0
27
+ Requires-Dist: rich>=13.0
28
+ Requires-Dist: prompt_toolkit>=3.0
29
+ Requires-Dist: questionary>=2.0.0
30
+ Requires-Dist: mcp>=1.0.0
31
+ Provides-Extra: tiktoken
32
+ Requires-Dist: tiktoken>=0.5; extra == "tiktoken"
33
+ Provides-Extra: dev
34
+ Requires-Dist: pytest>=7.0; extra == "dev"
35
+ Requires-Dist: pytest-cov>=4.0; extra == "dev"
36
+ Requires-Dist: pytest-mock>=3.0; extra == "dev"
37
+ Requires-Dist: pytest-asyncio>=0.23.0; extra == "dev"
38
+ Requires-Dist: ruff>=0.1; extra == "dev"
39
+ Requires-Dist: mypy>=1.0; extra == "dev"
40
+
41
+ # Aizen AI Agent 🚀
42
+
43
+ [![CI](https://github.com/irtaza302/aizen-agent/actions/workflows/ci.yml/badge.svg)](https://github.com/irtaza302/aizen-agent/actions/workflows/ci.yml)
44
+
45
+ Aizen is a powerful, asynchronous AI assistant that integrates seamlessly into your terminal workflow. It reads your code, edits files safely, runs commands, and provides real‑time, richly formatted assistance—all while keeping costs transparent and sessions persistent.
46
+
47
+ ## 🌟 Key Benefits
48
+
49
+ - **Effortless Integration** — Operates directly in your terminal, preserving shell state across commands.
50
+ - **Intelligent Editing** — Perform precise, color‑coded file edits with `edit_file`.
51
+ - **Background Execution** — Run long‑running tasks asynchronously and retrieve results later.
52
+ - **Cost‑Aware Usage** — Real‑time cost estimation for all major LLMs.
53
+ - **Persistent Sessions** — Save and restore conversations with checkpoints.
54
+ - **Rich Visual Feedback** — Stream responses with live previews and animated thought indicators.
55
+ - **Extensible Architecture** — Custom plugins and project‑specific rules tailor Aizen to your workflow.
56
+ - **Comprehensive Logging** — Rotating logs with optional verbose output for debugging.
57
+
58
+ ## 🚀 Core Features
59
+
60
+ ### Asynchronous Architecture
61
+ - Fully asynchronous operations using `asyncio` and `AsyncOpenAI` for concurrent processing, parallel tool runs, and streaming.
62
+
63
+ ### Stateful Terminal Session
64
+ - Environment variables and directory changes persist across interactions.
65
+
66
+ ### Rich Markdown Rendering
67
+ - Full Markdown support with headers, code blocks, lists, and styling via Rich.
68
+
69
+ ### Surgical File Editing
70
+ - Precise search‑and‑replace with color‑coded diff previews (`edit_file`).
71
+
72
+ ### Vision Support
73
+ - Native image handling and encoding for Vision APIs (e.g., GPT‑4o, Claude 3.5 Sonnet).
74
+
75
+ ### Real‑Time Command Streaming
76
+ - Background command execution with async streaming of stdout/stderr; use `run_command --background`.
77
+
78
+ ## 🎛️ Workflow Tools
79
+
80
+ - **Background Tasks** — Run non‑blocking commands; monitor with `check_background_task`; cancel with `kill_background_task`.
81
+ - **Session Persistence** — Powered by SQLite (`~/.aizen_sessions/aizen.db`), auto‑migrating older JSON sessions.
82
+ - **Project‑Specific Rules** — Auto‑load `.aizen_rules` or `.cursorrules` for repo‑specific behavior.
83
+ - **Smart Autocomplete** — TAB‑completion with `.gitignore` awareness and directory traversal.
84
+
85
+ ## 💰 Cost Tracking
86
+
87
+ - Real‑time token counting for inputs and outputs.
88
+ - Current cost estimate shown in the CLI status bar.
89
+ - Supports Anthropic (Claude 3.5/3.7 Sonnet, Opus, Haiku), Google (Gemini 2.5 Pro/Flash), and OpenAI (GPT‑4o, o1, o3‑mini).
90
+
91
+ ## 📌 Session Management
92
+
93
+ - `/checkpoint [name]` — Save conversation snapshots.
94
+ - `/restore [name]` — Restore a previous checkpoint.
95
+
96
+ ## 📁 Structured Logging
97
+
98
+ - Logs stored at `~/.aizen_logs/aizen.log` (rotated, 5 MB caps, 3 files).
99
+ - Verbose flag mirrors output to console.
100
+
101
+ ## 📦 Publishing & Development
102
+
103
+ - Use `publish.sh` to build and publish to PyPI, NPM, and PyInstaller binaries.
@@ -0,0 +1,63 @@
1
+ # Aizen AI Agent 🚀
2
+
3
+ [![CI](https://github.com/irtaza302/aizen-agent/actions/workflows/ci.yml/badge.svg)](https://github.com/irtaza302/aizen-agent/actions/workflows/ci.yml)
4
+
5
+ Aizen is a powerful, asynchronous AI assistant that integrates seamlessly into your terminal workflow. It reads your code, edits files safely, runs commands, and provides real‑time, richly formatted assistance—all while keeping costs transparent and sessions persistent.
6
+
7
+ ## 🌟 Key Benefits
8
+
9
+ - **Effortless Integration** — Operates directly in your terminal, preserving shell state across commands.
10
+ - **Intelligent Editing** — Perform precise, color‑coded file edits with `edit_file`.
11
+ - **Background Execution** — Run long‑running tasks asynchronously and retrieve results later.
12
+ - **Cost‑Aware Usage** — Real‑time cost estimation for all major LLMs.
13
+ - **Persistent Sessions** — Save and restore conversations with checkpoints.
14
+ - **Rich Visual Feedback** — Stream responses with live previews and animated thought indicators.
15
+ - **Extensible Architecture** — Custom plugins and project‑specific rules tailor Aizen to your workflow.
16
+ - **Comprehensive Logging** — Rotating logs with optional verbose output for debugging.
17
+
18
+ ## 🚀 Core Features
19
+
20
+ ### Asynchronous Architecture
21
+ - Fully asynchronous operations using `asyncio` and `AsyncOpenAI` for concurrent processing, parallel tool runs, and streaming.
22
+
23
+ ### Stateful Terminal Session
24
+ - Environment variables and directory changes persist across interactions.
25
+
26
+ ### Rich Markdown Rendering
27
+ - Full Markdown support with headers, code blocks, lists, and styling via Rich.
28
+
29
+ ### Surgical File Editing
30
+ - Precise search‑and‑replace with color‑coded diff previews (`edit_file`).
31
+
32
+ ### Vision Support
33
+ - Native image handling and encoding for Vision APIs (e.g., GPT‑4o, Claude 3.5 Sonnet).
34
+
35
+ ### Real‑Time Command Streaming
36
+ - Background command execution with async streaming of stdout/stderr; use `run_command --background`.
37
+
38
+ ## 🎛️ Workflow Tools
39
+
40
+ - **Background Tasks** — Run non‑blocking commands; monitor with `check_background_task`; cancel with `kill_background_task`.
41
+ - **Session Persistence** — Powered by SQLite (`~/.aizen_sessions/aizen.db`), auto‑migrating older JSON sessions.
42
+ - **Project‑Specific Rules** — Auto‑load `.aizen_rules` or `.cursorrules` for repo‑specific behavior.
43
+ - **Smart Autocomplete** — TAB‑completion with `.gitignore` awareness and directory traversal.
44
+
45
+ ## 💰 Cost Tracking
46
+
47
+ - Real‑time token counting for inputs and outputs.
48
+ - Current cost estimate shown in the CLI status bar.
49
+ - Supports Anthropic (Claude 3.5/3.7 Sonnet, Opus, Haiku), Google (Gemini 2.5 Pro/Flash), and OpenAI (GPT‑4o, o1, o3‑mini).
50
+
51
+ ## 📌 Session Management
52
+
53
+ - `/checkpoint [name]` — Save conversation snapshots.
54
+ - `/restore [name]` — Restore a previous checkpoint.
55
+
56
+ ## 📁 Structured Logging
57
+
58
+ - Logs stored at `~/.aizen_logs/aizen.log` (rotated, 5 MB caps, 3 files).
59
+ - Verbose flag mirrors output to console.
60
+
61
+ ## 📦 Publishing & Development
62
+
63
+ - Use `publish.sh` to build and publish to PyPI, NPM, and PyInstaller binaries.
@@ -0,0 +1,279 @@
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
+ import re
14
+ from typing import Any
15
+
16
+ from rich.live import Live
17
+ from rich.markdown import Markdown
18
+ from rich.spinner import Spinner
19
+ from rich.text import Text
20
+
21
+ from .config import Theme, console, get_active_model
22
+ from .logging_config import logger
23
+ from .tools import execute_tool
24
+ from .utils import Struct, truncate_output
25
+
26
+
27
+ class AgentRunner:
28
+ """Handles a single conversational turn: stream → parse → execute tools → loop."""
29
+
30
+ def __init__(
31
+ self,
32
+ client,
33
+ active_tools: list[dict],
34
+ context_manager,
35
+ token_tracker,
36
+ mcp_manager=None,
37
+ auto_approve: bool = False,
38
+ is_auto_mode: bool = False,
39
+ auto_iteration_count: int = 0,
40
+ max_auto_iterations: int = 50,
41
+ ):
42
+ self.client = client
43
+ self.active_tools = active_tools
44
+ self.context_manager = context_manager
45
+ self.token_tracker = token_tracker
46
+ self.mcp_manager = mcp_manager
47
+ self.auto_approve = auto_approve
48
+ self.is_auto_mode = is_auto_mode
49
+ self.auto_iteration_count = auto_iteration_count
50
+ self.max_auto_iterations = max_auto_iterations
51
+
52
+ async def run_turn(self, messages: list[dict]) -> None:
53
+ """
54
+ Execute a full agent turn: stream the model's response, handle tool calls,
55
+ and loop until the model produces a final text response (no more tool calls).
56
+ """
57
+ while True:
58
+ if self.is_auto_mode:
59
+ self.auto_iteration_count += 1
60
+ if self.auto_iteration_count > self.max_auto_iterations:
61
+ console.print(
62
+ f" [{Theme.WARNING}]⚠️ Autonomous mode reached iteration limit "
63
+ f"({self.max_auto_iterations}). Exiting auto mode.[/{Theme.WARNING}]"
64
+ )
65
+ self.is_auto_mode = False
66
+ messages.append({
67
+ "role": "user",
68
+ "content": (
69
+ f"You have reached the maximum number of autonomous iterations "
70
+ f"({self.max_auto_iterations}). Please provide a brief summary "
71
+ f"of what you have accomplished and what remains."
72
+ ),
73
+ })
74
+ self.auto_iteration_count = 0
75
+
76
+ # Stream the response
77
+ stream_result = await self._stream_response(messages)
78
+ if stream_result is None:
79
+ break # Error occurred (already printed to console)
80
+
81
+ full_content, tool_calls_list, api_usage = stream_result
82
+
83
+ # Track tokens
84
+ self._track_tokens(messages, full_content, api_usage)
85
+
86
+ # Add assistant message to history
87
+ assistant_msg: dict[str, Any] = {
88
+ "role": "assistant",
89
+ "content": full_content or "",
90
+ }
91
+ if tool_calls_list:
92
+ assistant_msg["tool_calls"] = tool_calls_list
93
+ messages.append(assistant_msg)
94
+
95
+ # If no tool calls, we're done with this turn
96
+ if not tool_calls_list:
97
+ break
98
+
99
+ # Execute tool calls
100
+ tool_results = await self._execute_tools(tool_calls_list)
101
+ messages.extend(tool_results)
102
+
103
+ # Loop continues — model processes tool results
104
+
105
+ async def _stream_response(
106
+ self, messages: list[dict]
107
+ ) -> tuple[str, list[dict], Any] | None:
108
+ """
109
+ Stream a response from the model.
110
+
111
+ Returns (full_content, tool_calls_list, api_usage) or None on error.
112
+ """
113
+ full_content = ""
114
+ accumulated_tool_calls: dict[int, dict] = {}
115
+ api_usage = None
116
+
117
+ spinner_label = random.choice([
118
+ "Thinking...", "Analyzing...", "Reasoning...",
119
+ "Processing...", "Considering...", "Exploring...", "Synthesizing...",
120
+ ])
121
+
122
+ if self.is_auto_mode:
123
+ spinner_text = Text(
124
+ f" [Step {self.auto_iteration_count}/{self.max_auto_iterations}] {spinner_label}",
125
+ style=f"{Theme.MUTED} italic",
126
+ )
127
+ else:
128
+ spinner_text = Text(f" {spinner_label}", style=f"{Theme.MUTED} italic")
129
+
130
+ spinner_display = Spinner("dots2", text=spinner_text, style=f"{Theme.PRIMARY} bold")
131
+
132
+ try:
133
+ with Live(
134
+ spinner_display, console=console, refresh_per_second=8
135
+ ) as live:
136
+ from openai import AsyncStream
137
+
138
+ model = get_active_model()
139
+ api_params: dict[str, Any] = {
140
+ "model": model,
141
+ "messages": messages,
142
+ "stream": True,
143
+ "stream_options": {"include_usage": True},
144
+ }
145
+ if self.active_tools:
146
+ api_params["tools"] = self.active_tools
147
+ api_params["tool_choice"] = "auto"
148
+
149
+ stream: AsyncStream = await self.client.chat.completions.create(**api_params)
150
+
151
+ async for chunk in stream:
152
+ if hasattr(chunk, "usage") and chunk.usage:
153
+ api_usage = chunk.usage
154
+
155
+ delta = chunk.choices[0].delta if chunk.choices else None
156
+ if not delta:
157
+ continue
158
+
159
+ if delta.content:
160
+ full_content += delta.content
161
+ if full_content.strip():
162
+ try:
163
+ # Strip reasoning/thought tags for cleaner UI display
164
+ cleaned_content = re.sub(r'<think>.*?(?:</think>|$)', '', full_content, flags=re.DOTALL)
165
+ cleaned_content = re.sub(r'<\|channel>thought.*?(?:<channel\|>|$)', '', cleaned_content, flags=re.DOTALL)
166
+
167
+ display_content = f"**◆ AIZEN:** {cleaned_content.strip()}"
168
+ rendered = Markdown(display_content)
169
+ live.update(rendered)
170
+ except Exception:
171
+ display_text = Text.from_markup(
172
+ f"{Theme.BADGE} {full_content}"
173
+ )
174
+ live.update(display_text)
175
+
176
+ if delta.tool_calls:
177
+ for tc in delta.tool_calls:
178
+ idx = tc.index
179
+ if idx not in accumulated_tool_calls:
180
+ accumulated_tool_calls[idx] = {
181
+ "id": "", "name": "", "arguments": "", "type": "function",
182
+ }
183
+ if tc.id:
184
+ accumulated_tool_calls[idx]["id"] = tc.id
185
+ if tc.function:
186
+ if tc.function.name:
187
+ accumulated_tool_calls[idx]["name"] += tc.function.name
188
+ if tc.function.arguments:
189
+ accumulated_tool_calls[idx]["arguments"] += tc.function.arguments
190
+
191
+ names = [v["name"] for v in accumulated_tool_calls.values() if v["name"]]
192
+ if names and not full_content.strip():
193
+ tool_text = Text()
194
+ tool_text.append(" ◆ ", style=f"bold {Theme.ACCENT}")
195
+ tool_text.append("Invoking ", style=f"{Theme.TEXT}")
196
+ tool_text.append(f"{', '.join(names)}", style=f"bold {Theme.ACCENT}")
197
+ tool_text.append(" ...", style=f"{Theme.MUTED}")
198
+ live.update(tool_text)
199
+
200
+ except Exception:
201
+ # Re-raise — let the caller (main_loop) handle specific exception types
202
+ raise
203
+
204
+ # Build tool calls list
205
+ tool_calls_list: list[dict[str, Any]] = []
206
+ for idx in sorted(accumulated_tool_calls.keys()):
207
+ tc = accumulated_tool_calls[idx]
208
+ tool_calls_list.append({
209
+ "id": tc["id"],
210
+ "type": "function",
211
+ "function": {
212
+ "name": tc["name"],
213
+ "arguments": tc["arguments"],
214
+ },
215
+ })
216
+
217
+ return full_content, tool_calls_list, api_usage
218
+
219
+ async def _execute_tools(self, tool_calls_list: list[dict]) -> list[dict]:
220
+ """Execute tool calls (in parallel where safe) and return tool result messages."""
221
+
222
+ async def _exec_tool(tc_dict: dict) -> dict:
223
+ func_name = tc_dict["function"]["name"]
224
+ if func_name.startswith("mcp_") and self.mcp_manager:
225
+ try:
226
+ args = json.loads(tc_dict["function"]["arguments"])
227
+ result = await self.mcp_manager.call_tool(func_name, args)
228
+ except json.JSONDecodeError:
229
+ result = f"Error: Invalid JSON arguments for {func_name}."
230
+ else:
231
+ func_struct = Struct(**tc_dict["function"])
232
+ tc_struct = Struct(
233
+ id=tc_dict["id"],
234
+ type=tc_dict["type"],
235
+ function=func_struct,
236
+ )
237
+ result = await asyncio.to_thread(execute_tool, tc_struct, self.auto_approve)
238
+
239
+ return {
240
+ "role": "tool",
241
+ "tool_call_id": tc_dict["id"],
242
+ "name": func_name,
243
+ "content": truncate_output(result),
244
+ }
245
+
246
+ tool_results = await asyncio.gather(
247
+ *[_exec_tool(tc) for tc in tool_calls_list],
248
+ return_exceptions=True,
249
+ )
250
+
251
+ # Handle individual tool failures gracefully
252
+ for i, result in enumerate(tool_results):
253
+ if isinstance(result, Exception):
254
+ logger.error("Tool execution failed: %s", result)
255
+ tool_results[i] = {
256
+ "role": "tool",
257
+ "tool_call_id": tool_calls_list[i]["id"],
258
+ "name": tool_calls_list[i]["function"]["name"],
259
+ "content": f"Error: Tool execution failed — {type(result).__name__}: {result}",
260
+ }
261
+
262
+ return list(tool_results)
263
+
264
+ def _track_tokens(self, messages, full_content, api_usage):
265
+ """Update token tracking from API usage or estimation."""
266
+ if api_usage and hasattr(api_usage, "prompt_tokens"):
267
+ self.token_tracker.add_api_usage(
268
+ api_usage.prompt_tokens or 0,
269
+ api_usage.completion_tokens or 0,
270
+ )
271
+ self.context_manager.update(
272
+ (api_usage.prompt_tokens or 0) + (api_usage.completion_tokens or 0)
273
+ )
274
+ elif full_content:
275
+ estimated_input = self.context_manager.estimate_messages_tokens(
276
+ messages, self.token_tracker.estimate_tokens
277
+ )
278
+ estimated_output = self.token_tracker.estimate_tokens(full_content)
279
+ 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 (
@@ -421,12 +423,8 @@ async def handle_slash_command(
421
423
  # Attempt LLM-based summarization for much better context retention
422
424
  console.print(f" [{Theme.MUTED}]Summarizing conversation with AI...[/{Theme.MUTED}]")
423
425
  try:
424
- from openai import AsyncOpenAI as _AsyncOpenAI
425
-
426
- _config = load_config()
427
- _api_key = _config.get("OPENROUTER_API_KEY", "")
428
- _api_base = _config.get("API_BASE_URL", "https://openrouter.ai/api/v1")
429
- _client = _AsyncOpenAI(base_url=_api_base, api_key=_api_key)
426
+ # Use the client passed to handle_slash_command
427
+ _client = client
430
428
 
431
429
  # Build a summarization request from the middle messages
432
430
  summary_messages = [
@@ -606,8 +604,8 @@ async def handle_slash_command(
606
604
  console.print(f" [{Theme.WARNING}]No changes found to commit.[/{Theme.WARNING}]\n")
607
605
  return False
608
606
 
609
- answer = prompt("No staged changes. Stage all current changes? [Y/n] ")
610
- 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:
611
609
  console.print(f" [{Theme.WARNING}]Commit aborted.[/{Theme.WARNING}]\n")
612
610
  return False
613
611
 
@@ -631,24 +629,44 @@ async def handle_slash_command(
631
629
  messages=commit_messages,
632
630
  max_tokens=200,
633
631
  )
634
- 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 ""
635
634
  # Remove any markdown codeblocks if model didn't listen
636
635
  commit_msg = commit_msg.replace("```text", "").replace("```", "").strip()
637
636
 
638
- console.print(f"\n [bold {Theme.TEXT}]Generated Commit Message:[/bold {Theme.TEXT}]")
639
- console.print(f" [{Theme.ACCENT}]{commit_msg}[/{Theme.ACCENT}]\n")
640
-
641
- action = prompt("Commit with this message? [Y/n/e(dit)] ")
642
- 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()
643
652
 
644
- if action in ("y", "yes", ""):
653
+ if action == "Yes, commit this":
645
654
  final_msg = commit_msg
646
- elif action in ("e", "edit"):
647
- 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()
648
657
  else:
649
658
  console.print("[yellow]Commit aborted.[/yellow]\n")
650
659
  return False
651
660
 
661
+ if final_msg is None:
662
+ console.print(f" [{Theme.WARNING}]Commit aborted.[/{Theme.WARNING}]\n")
663
+ return False
664
+
665
+ final_msg = final_msg.strip()
666
+ if not final_msg:
667
+ console.print(f" [{Theme.ERROR}]Error: Commit message cannot be empty. Aborted.[/{Theme.ERROR}]\n")
668
+ return False
669
+
652
670
  subprocess.run(["git", "commit", "-m", final_msg], check=True)
653
671
  console.print(f" [{Theme.SUCCESS}]✓ Committed successfully.[/{Theme.SUCCESS}]\n")
654
672
 
@@ -20,7 +20,7 @@ logger = logging.getLogger("aizen")
20
20
 
21
21
  # Read version from installed package metadata (stays in sync with pyproject.toml).
22
22
  # Falls back to a hardcoded value only when running from source without installing.
23
- _FALLBACK_VERSION = "2.4.0"
23
+ _FALLBACK_VERSION = "2.4.2"
24
24
  try:
25
25
  VERSION = _pkg_version("aizen-ai-cli")
26
26
  except PackageNotFoundError:
@@ -169,12 +169,14 @@ def build_system_prompt(config: dict | None = None) -> str:
169
169
 
170
170
  return "\n".join(parts)
171
171
 
172
- # Global state for active model
172
+ # Global state for active model (protected by lock for thread safety)
173
173
  active_model = DEFAULT_MODEL
174
+ _model_lock = threading.Lock()
174
175
 
175
176
  def set_active_model(model_name: str, save: bool = False):
176
177
  global active_model
177
- active_model = model_name
178
+ with _model_lock:
179
+ active_model = model_name
178
180
  if save:
179
181
  try:
180
182
  config = load_config()
@@ -185,7 +187,8 @@ def set_active_model(model_name: str, save: bool = False):
185
187
  logger.error("Failed to save default model: %s", e)
186
188
 
187
189
  def get_active_model() -> str:
188
- return active_model
190
+ with _model_lock:
191
+ return active_model
189
192
 
190
193
  # ─── Configuration ──────────────────────────────────────────────────────────────
191
194
 
@@ -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
+