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.
- aizen_ai_cli-2.4.2/PKG-INFO +103 -0
- aizen_ai_cli-2.4.2/README.md +63 -0
- aizen_ai_cli-2.4.2/aizen/agent.py +279 -0
- {aizen_ai_cli-2.4.0 → aizen_ai_cli-2.4.2}/aizen/commands.py +36 -18
- {aizen_ai_cli-2.4.0 → aizen_ai_cli-2.4.2}/aizen/config.py +7 -4
- {aizen_ai_cli-2.4.0 → aizen_ai_cli-2.4.2}/aizen/context.py +61 -0
- {aizen_ai_cli-2.4.0 → aizen_ai_cli-2.4.2}/aizen/main.py +47 -294
- {aizen_ai_cli-2.4.0 → aizen_ai_cli-2.4.2}/aizen/session.py +46 -34
- aizen_ai_cli-2.4.2/aizen/tools/__init__.py +13 -0
- aizen_ai_cli-2.4.2/aizen/tools/commands.py +279 -0
- aizen_ai_cli-2.4.2/aizen/tools/dispatcher.py +437 -0
- aizen_ai_cli-2.4.2/aizen/tools/file_ops.py +309 -0
- aizen_ai_cli-2.4.2/aizen/tools/helpers.py +352 -0
- aizen_ai_cli-2.4.2/aizen/tools/search.py +199 -0
- {aizen_ai_cli-2.4.0 → aizen_ai_cli-2.4.2}/aizen/utils.py +1 -1
- aizen_ai_cli-2.4.2/aizen_ai_cli.egg-info/PKG-INFO +103 -0
- {aizen_ai_cli-2.4.0 → aizen_ai_cli-2.4.2}/aizen_ai_cli.egg-info/SOURCES.txt +14 -2
- {aizen_ai_cli-2.4.0 → aizen_ai_cli-2.4.2}/pyproject.toml +3 -1
- {aizen_ai_cli-2.4.0 → aizen_ai_cli-2.4.2}/setup.py +1 -1
- aizen_ai_cli-2.4.2/tests/test_agent.py +178 -0
- aizen_ai_cli-2.4.2/tests/test_commands.py +89 -0
- {aizen_ai_cli-2.4.0 → aizen_ai_cli-2.4.2}/tests/test_config.py +5 -1
- aizen_ai_cli-2.4.2/tests/test_dispatcher.py +85 -0
- aizen_ai_cli-2.4.2/tests/test_file_ops.py +168 -0
- aizen_ai_cli-2.4.2/tests/test_helpers.py +46 -0
- aizen_ai_cli-2.4.2/tests/test_plugins.py +114 -0
- aizen_ai_cli-2.4.2/tests/test_retry.py +149 -0
- aizen_ai_cli-2.4.2/tests/test_search.py +78 -0
- aizen_ai_cli-2.4.0/PKG-INFO +0 -276
- aizen_ai_cli-2.4.0/README.md +0 -236
- aizen_ai_cli-2.4.0/aizen/tools.py +0 -1436
- aizen_ai_cli-2.4.0/aizen_ai_cli.egg-info/PKG-INFO +0 -276
- aizen_ai_cli-2.4.0/tests/test_commands.py +0 -212
- aizen_ai_cli-2.4.0/tests/test_tools.py +0 -427
- {aizen_ai_cli-2.4.0 → aizen_ai_cli-2.4.2}/MANIFEST.in +0 -0
- {aizen_ai_cli-2.4.0 → aizen_ai_cli-2.4.2}/aizen/__init__.py +0 -0
- {aizen_ai_cli-2.4.0 → aizen_ai_cli-2.4.2}/aizen/exceptions.py +0 -0
- {aizen_ai_cli-2.4.0 → aizen_ai_cli-2.4.2}/aizen/logging_config.py +0 -0
- {aizen_ai_cli-2.4.0 → aizen_ai_cli-2.4.2}/aizen/mcp.py +0 -0
- {aizen_ai_cli-2.4.0 → aizen_ai_cli-2.4.2}/aizen/plugins.py +0 -0
- {aizen_ai_cli-2.4.0 → aizen_ai_cli-2.4.2}/aizen/retry.py +0 -0
- {aizen_ai_cli-2.4.0 → aizen_ai_cli-2.4.2}/aizen_ai_cli.egg-info/dependency_links.txt +0 -0
- {aizen_ai_cli-2.4.0 → aizen_ai_cli-2.4.2}/aizen_ai_cli.egg-info/entry_points.txt +0 -0
- {aizen_ai_cli-2.4.0 → aizen_ai_cli-2.4.2}/aizen_ai_cli.egg-info/requires.txt +0 -0
- {aizen_ai_cli-2.4.0 → aizen_ai_cli-2.4.2}/aizen_ai_cli.egg-info/top_level.txt +0 -0
- {aizen_ai_cli-2.4.0 → aizen_ai_cli-2.4.2}/requirements.txt +0 -0
- {aizen_ai_cli-2.4.0 → aizen_ai_cli-2.4.2}/setup.cfg +0 -0
- {aizen_ai_cli-2.4.0 → aizen_ai_cli-2.4.2}/tests/test_context.py +0 -0
- {aizen_ai_cli-2.4.0 → aizen_ai_cli-2.4.2}/tests/test_main.py +0 -0
- {aizen_ai_cli-2.4.0 → aizen_ai_cli-2.4.2}/tests/test_mcp.py +0 -0
- {aizen_ai_cli-2.4.0 → aizen_ai_cli-2.4.2}/tests/test_session.py +0 -0
- {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
|
+
[](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
|
+
[](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
|
|
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
|
-
|
|
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 =
|
|
610
|
-
if
|
|
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
|
-
|
|
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
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
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
|
|
653
|
+
if action == "Yes, commit this":
|
|
645
654
|
final_msg = commit_msg
|
|
646
|
-
elif action
|
|
647
|
-
final_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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
|