aizen-ai-cli 2.2.2__py3-none-any.whl
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/__init__.py +4 -0
- aizen/commands.py +694 -0
- aizen/config.py +363 -0
- aizen/context.py +171 -0
- aizen/exceptions.py +46 -0
- aizen/logging_config.py +65 -0
- aizen/main.py +616 -0
- aizen/mcp.py +110 -0
- aizen/plugins.py +63 -0
- aizen/retry.py +133 -0
- aizen/session.py +137 -0
- aizen/tools.py +1035 -0
- aizen/utils.py +339 -0
- aizen_ai_cli-2.2.2.dist-info/METADATA +267 -0
- aizen_ai_cli-2.2.2.dist-info/RECORD +18 -0
- aizen_ai_cli-2.2.2.dist-info/WHEEL +5 -0
- aizen_ai_cli-2.2.2.dist-info/entry_points.txt +2 -0
- aizen_ai_cli-2.2.2.dist-info/top_level.txt +1 -0
aizen/config.py
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import getpass
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
import sys
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
import urllib.request
|
|
10
|
+
import urllib.error
|
|
11
|
+
import ssl
|
|
12
|
+
from importlib.metadata import PackageNotFoundError
|
|
13
|
+
from importlib.metadata import version as _pkg_version
|
|
14
|
+
|
|
15
|
+
from dotenv import load_dotenv
|
|
16
|
+
from rich.console import Console
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger("aizen")
|
|
19
|
+
|
|
20
|
+
# ─── Constants ──────────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
# Read version from installed package metadata (stays in sync with pyproject.toml).
|
|
23
|
+
# Falls back to a hardcoded value only when running from source without installing.
|
|
24
|
+
_FALLBACK_VERSION = "2.2.2"
|
|
25
|
+
try:
|
|
26
|
+
VERSION = _pkg_version("aizen-ai-cli")
|
|
27
|
+
except PackageNotFoundError:
|
|
28
|
+
VERSION = _FALLBACK_VERSION
|
|
29
|
+
CONFIG_PATH = os.path.expanduser("~/.aizen_config.json")
|
|
30
|
+
SESSIONS_DIR = os.path.expanduser("~/.aizen_sessions")
|
|
31
|
+
BACKUPS_DIR = os.path.expanduser("~/.aizen_backups")
|
|
32
|
+
DEFAULT_MODEL = "nvidia/nemotron-3-super-120b-a12b:free"
|
|
33
|
+
|
|
34
|
+
AIZEN_ASCII = r"""[bold magenta]
|
|
35
|
+
_ _
|
|
36
|
+
/ \ (_)_______ _ __
|
|
37
|
+
/ _ \ | |_ / _ \ '_ \
|
|
38
|
+
/ ___ \| |/ / __/ | | |
|
|
39
|
+
/_/ \_\_/___\___|_| |_|
|
|
40
|
+
[/bold magenta]
|
|
41
|
+
[dim]by Irtaza Malik[/dim]
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
# Safe commands that auto-execute without confirmation
|
|
45
|
+
SAFE_COMMAND_PREFIXES = [
|
|
46
|
+
"ls", "cat", "head", "tail", "wc", "file",
|
|
47
|
+
"git status", "git log", "git diff", "git branch", "git show", "git rev-parse",
|
|
48
|
+
"pwd", "echo", "which", "type", "tree", "du", "df",
|
|
49
|
+
"python --version", "python3 --version", "node --version",
|
|
50
|
+
"npm --version", "pip --version", "pip list", "pip show",
|
|
51
|
+
"cargo --version", "rustc --version", "go version",
|
|
52
|
+
"date", "whoami", "uname", "printenv",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
# Dangerous patterns that always require confirmation
|
|
56
|
+
DANGEROUS_PATTERNS = [
|
|
57
|
+
r"\brm\s", r"\bsudo\b", r"\bchmod\b", r"\bchown\b", r"\bmkfs\b",
|
|
58
|
+
r"\bdd\b", r":\(\)\{", r"\bkill\b", r"\bpkill\b", r"\bshutdown\b",
|
|
59
|
+
r"\breboot\b", r">\s*/dev/", r"\bcurl\b.*\|\s*(ba)?sh",
|
|
60
|
+
# Shell injection patterns
|
|
61
|
+
r"`[^`]+`", # Backtick command substitution
|
|
62
|
+
r"\$\([^)]+\)", # $() command substitution
|
|
63
|
+
r"\beval\b", # eval execution
|
|
64
|
+
r"\bexec\b", # exec execution
|
|
65
|
+
r"\bsource\b", # source execution
|
|
66
|
+
r"\|\s*(ba)?sh\b", # Pipe to shell
|
|
67
|
+
r"\|\s*zsh\b", # Pipe to zsh
|
|
68
|
+
r"\|\s*python", # Pipe to python
|
|
69
|
+
r"\bwget\b.*\|\s*", # wget piped to anything
|
|
70
|
+
r"\bnohup\b", # Background with nohup
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
SYSTEM_PROMPT = """\
|
|
74
|
+
You are Aizen, an expert AI coding assistant running in a user's terminal. \
|
|
75
|
+
You help users write, debug, understand, and refactor code with precision and care.
|
|
76
|
+
|
|
77
|
+
## Your Workflow
|
|
78
|
+
1. **Understand**: Always read relevant files first. Don't guess at file contents or structure.
|
|
79
|
+
2. **Plan**: Briefly explain your approach before making changes.
|
|
80
|
+
3. **Implement**: Make precise, targeted changes. Use `edit_file` for modifying existing files \
|
|
81
|
+
(surgical edits). Use `write_file` only for creating new files.
|
|
82
|
+
4. **Verify**: After making changes, read the modified file or run tests to confirm correctness.
|
|
83
|
+
|
|
84
|
+
## Guidelines
|
|
85
|
+
- Be concise but thorough in explanations.
|
|
86
|
+
- Use tools iteratively to explore and understand the codebase.
|
|
87
|
+
- Prefer small, focused changes over large rewrites.
|
|
88
|
+
- When modifying existing files, ALWAYS use `edit_file` with the exact `old_content` to replace. \
|
|
89
|
+
Never use `write_file` to modify existing files unless a full rewrite is truly needed.
|
|
90
|
+
- Run tests or linting commands after changes when applicable.
|
|
91
|
+
- If unsure about something, ask the user rather than guessing.
|
|
92
|
+
- Use fenced code blocks with language identifiers when showing code.
|
|
93
|
+
|
|
94
|
+
## Tool Preferences
|
|
95
|
+
- `edit_file` > `write_file` for modifications (surgical precision)
|
|
96
|
+
- `grep_search` for finding patterns across the codebase
|
|
97
|
+
- `find_files` for locating files by name
|
|
98
|
+
- `list_directory` for understanding project structure
|
|
99
|
+
- `run_command` for running tests, builds, and verification"""
|
|
100
|
+
|
|
101
|
+
console = Console()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# Project-level rules files (checked in order, first found wins)
|
|
105
|
+
_PROJECT_RULES_FILES = [".aizen_rules", ".cursorrules"]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def build_system_prompt(config: dict | None = None) -> str:
|
|
109
|
+
"""
|
|
110
|
+
Build the final system prompt by merging:
|
|
111
|
+
1. Default SYSTEM_PROMPT
|
|
112
|
+
2. User override from ~/.aizen_config.json ("system_prompt" key)
|
|
113
|
+
3. Project-specific rules from .aizen_rules or .cursorrules in CWD
|
|
114
|
+
|
|
115
|
+
This allows per-project customization without modifying source code.
|
|
116
|
+
"""
|
|
117
|
+
parts = []
|
|
118
|
+
|
|
119
|
+
# Start with default or config override
|
|
120
|
+
if config and config.get("system_prompt"):
|
|
121
|
+
parts.append(config["system_prompt"])
|
|
122
|
+
else:
|
|
123
|
+
parts.append(SYSTEM_PROMPT)
|
|
124
|
+
|
|
125
|
+
# Append project-specific rules if present
|
|
126
|
+
for rules_file in _PROJECT_RULES_FILES:
|
|
127
|
+
if os.path.isfile(rules_file):
|
|
128
|
+
try:
|
|
129
|
+
with open(rules_file, encoding="utf-8", errors="ignore") as f:
|
|
130
|
+
project_rules = f.read().strip()
|
|
131
|
+
if project_rules:
|
|
132
|
+
parts.append(
|
|
133
|
+
f"\n\n## Project-Specific Rules\n"
|
|
134
|
+
f"The following rules are defined by the project maintainers "
|
|
135
|
+
f"(from {rules_file}):\n\n{project_rules}"
|
|
136
|
+
)
|
|
137
|
+
console.print(f" [dim]📋 Loaded project rules from {rules_file}[/dim]")
|
|
138
|
+
break # Only use the first rules file found
|
|
139
|
+
except Exception as e:
|
|
140
|
+
logger.debug("Failed to load project rules from %s: %s", rules_file, e)
|
|
141
|
+
|
|
142
|
+
return "\n".join(parts)
|
|
143
|
+
|
|
144
|
+
# Global state for active model
|
|
145
|
+
active_model = DEFAULT_MODEL
|
|
146
|
+
|
|
147
|
+
def set_active_model(model_name: str, save: bool = False):
|
|
148
|
+
global active_model
|
|
149
|
+
active_model = model_name
|
|
150
|
+
if save:
|
|
151
|
+
try:
|
|
152
|
+
config = load_config()
|
|
153
|
+
config["DEFAULT_MODEL"] = model_name
|
|
154
|
+
with open(CONFIG_PATH, "w") as f:
|
|
155
|
+
json.dump(config, f, indent=4)
|
|
156
|
+
except Exception as e:
|
|
157
|
+
logger.error("Failed to save default model: %s", e)
|
|
158
|
+
|
|
159
|
+
def get_active_model() -> str:
|
|
160
|
+
return active_model
|
|
161
|
+
|
|
162
|
+
# ─── Configuration ──────────────────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def migrate_legacy_data():
|
|
166
|
+
"""Migrate legacy Aether config/sessions to Aizen."""
|
|
167
|
+
legacy_config = os.path.expanduser("~/.aether_config.json")
|
|
168
|
+
if os.path.exists(legacy_config) and not os.path.exists(CONFIG_PATH):
|
|
169
|
+
try:
|
|
170
|
+
shutil.copy2(legacy_config, CONFIG_PATH)
|
|
171
|
+
console.print("[dim]Migrated legacy config to ~/.aizen_config.json[/dim]")
|
|
172
|
+
except Exception as e:
|
|
173
|
+
logger.debug(f"Failed to migrate config: {e}")
|
|
174
|
+
|
|
175
|
+
legacy_sessions = os.path.expanduser("~/.aether_sessions")
|
|
176
|
+
if os.path.exists(legacy_sessions) and not os.path.exists(SESSIONS_DIR):
|
|
177
|
+
try:
|
|
178
|
+
shutil.copytree(legacy_sessions, SESSIONS_DIR)
|
|
179
|
+
console.print("[dim]Migrated legacy sessions to ~/.aizen_sessions[/dim]")
|
|
180
|
+
except Exception as e:
|
|
181
|
+
logger.debug(f"Failed to migrate sessions: {e}")
|
|
182
|
+
|
|
183
|
+
def load_config() -> dict:
|
|
184
|
+
migrate_legacy_data()
|
|
185
|
+
if os.path.exists(CONFIG_PATH):
|
|
186
|
+
try:
|
|
187
|
+
with open(CONFIG_PATH) as f:
|
|
188
|
+
return json.load(f)
|
|
189
|
+
except Exception as e:
|
|
190
|
+
logger.debug("Failed to load config file: %s", e)
|
|
191
|
+
return {}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def get_mcp_servers(config: dict) -> dict:
|
|
195
|
+
"""Returns the configured MCP servers."""
|
|
196
|
+
return config.get("mcp_servers", {})
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def save_config(config: dict):
|
|
200
|
+
try:
|
|
201
|
+
with open(CONFIG_PATH, 'w') as f:
|
|
202
|
+
json.dump(config, f, indent=2)
|
|
203
|
+
except Exception as e:
|
|
204
|
+
console.print(f"[yellow]⚠️ Could not save config: {e}[/yellow]\n")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def get_api_key(config: dict, reset: bool = False) -> str:
|
|
208
|
+
if reset:
|
|
209
|
+
config.pop("OPENROUTER_API_KEY", None)
|
|
210
|
+
save_config(config)
|
|
211
|
+
|
|
212
|
+
key = config.get("OPENROUTER_API_KEY")
|
|
213
|
+
if key:
|
|
214
|
+
return key
|
|
215
|
+
|
|
216
|
+
load_dotenv()
|
|
217
|
+
env_key = os.getenv("OPENROUTER_API_KEY")
|
|
218
|
+
if env_key and env_key != "your_api_key_here":
|
|
219
|
+
return env_key
|
|
220
|
+
|
|
221
|
+
console.print(AIZEN_ASCII)
|
|
222
|
+
console.print("[bold]Welcome to Aizen![/bold]\n")
|
|
223
|
+
console.print("To get started, enter your OpenRouter API key.")
|
|
224
|
+
console.print("[dim](Get one free at https://openrouter.ai/keys)[/dim]\n")
|
|
225
|
+
|
|
226
|
+
key = getpass.getpass("API Key: ").strip()
|
|
227
|
+
if not key:
|
|
228
|
+
console.print("[bold red]Error:[/bold red] API Key cannot be empty.")
|
|
229
|
+
sys.exit(1)
|
|
230
|
+
|
|
231
|
+
config["OPENROUTER_API_KEY"] = key
|
|
232
|
+
save_config(config)
|
|
233
|
+
console.print(f"[green]✓ API key saved to {CONFIG_PATH}[/green]\n")
|
|
234
|
+
return key
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# ─── Update Checker (Truly Non-Blocking) ────────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
# Cache TTL: only check PyPI once every 24 hours
|
|
240
|
+
_UPDATE_CHECK_INTERVAL = 86400 # 24 hours in seconds
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _should_check_updates(config: dict) -> bool:
|
|
244
|
+
"""Determine if enough time has passed since the last update check."""
|
|
245
|
+
last_check = config.get("_last_update_check", 0)
|
|
246
|
+
return (time.time() - last_check) > _UPDATE_CHECK_INTERVAL
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _do_update_check(config: dict):
|
|
250
|
+
"""
|
|
251
|
+
Background thread target: fetch latest version from PyPI
|
|
252
|
+
and print a notice if an update is available.
|
|
253
|
+
"""
|
|
254
|
+
try:
|
|
255
|
+
ctx = ssl.create_default_context()
|
|
256
|
+
try:
|
|
257
|
+
import certifi
|
|
258
|
+
ctx.load_verify_locations(cafile=certifi.where())
|
|
259
|
+
except ImportError:
|
|
260
|
+
ctx = ssl._create_unverified_context()
|
|
261
|
+
|
|
262
|
+
url = "https://pypi.org/pypi/aizen-ai-cli/json"
|
|
263
|
+
req = urllib.request.Request(url, headers={"User-Agent": "aizen-ai-cli"})
|
|
264
|
+
with urllib.request.urlopen(req, timeout=3, context=ctx) as response:
|
|
265
|
+
data = json.loads(response.read().decode())
|
|
266
|
+
latest = data["info"]["version"]
|
|
267
|
+
|
|
268
|
+
# Update the last-check timestamp
|
|
269
|
+
config["_last_update_check"] = time.time()
|
|
270
|
+
config["_latest_version"] = latest
|
|
271
|
+
try:
|
|
272
|
+
save_config(config)
|
|
273
|
+
except Exception as e:
|
|
274
|
+
logger.debug("Failed to save config after update check: %s", e)
|
|
275
|
+
|
|
276
|
+
if latest != VERSION:
|
|
277
|
+
console.print(
|
|
278
|
+
f"\n[bold magenta]🔔 Update available:[/bold magenta] v{VERSION} → v{latest}"
|
|
279
|
+
)
|
|
280
|
+
console.print("[dim]Run: pip install -U aizen-ai-cli (or brew upgrade aizen)[/dim]")
|
|
281
|
+
console.print("[dim]Then restart Aizen to use the new version![/dim]\n")
|
|
282
|
+
except Exception as e:
|
|
283
|
+
logger.debug("Update check failed (network/parsing): %s", e)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def check_for_updates(config: dict | None = None):
|
|
287
|
+
"""
|
|
288
|
+
Launch a non-blocking background thread to check for updates.
|
|
289
|
+
Respects a 24-hour cache to avoid repeated network calls.
|
|
290
|
+
"""
|
|
291
|
+
if config is None:
|
|
292
|
+
config = load_config()
|
|
293
|
+
|
|
294
|
+
if not _should_check_updates(config):
|
|
295
|
+
# Check if we have a cached latest version that's newer
|
|
296
|
+
cached = config.get("_latest_version")
|
|
297
|
+
if cached and cached != VERSION:
|
|
298
|
+
console.print(
|
|
299
|
+
f"\n[bold magenta]🔔 Update available:[/bold magenta] v{VERSION} → v{cached}"
|
|
300
|
+
)
|
|
301
|
+
console.print("[dim]Run: pip install -U aizen-ai-cli (or brew upgrade aizen)[/dim]")
|
|
302
|
+
console.print("[dim]Then restart Aizen to use the new version![/dim]\n")
|
|
303
|
+
return
|
|
304
|
+
|
|
305
|
+
thread = threading.Thread(target=_do_update_check, args=(config,), daemon=True)
|
|
306
|
+
thread.start()
|
|
307
|
+
|
|
308
|
+
# ─── OpenRouter Models Cache ────────────────────────────────────────────────────
|
|
309
|
+
|
|
310
|
+
MODELS_CACHE_PATH = os.path.expanduser("~/.aizen_models.json")
|
|
311
|
+
_MODELS_CACHE_TTL = 86400 # 24 hours
|
|
312
|
+
|
|
313
|
+
def get_cached_models() -> list[dict]:
|
|
314
|
+
if os.path.exists(MODELS_CACHE_PATH):
|
|
315
|
+
try:
|
|
316
|
+
with open(MODELS_CACHE_PATH, encoding="utf-8") as f:
|
|
317
|
+
data = json.load(f)
|
|
318
|
+
if time.time() - data.get("timestamp", 0) < _MODELS_CACHE_TTL:
|
|
319
|
+
return data.get("models", [])
|
|
320
|
+
except Exception as e:
|
|
321
|
+
logger.debug("Failed to load models cache: %s", e)
|
|
322
|
+
return []
|
|
323
|
+
|
|
324
|
+
def _do_fetch_models():
|
|
325
|
+
try:
|
|
326
|
+
ctx = ssl.create_default_context()
|
|
327
|
+
try:
|
|
328
|
+
import certifi
|
|
329
|
+
ctx.load_verify_locations(cafile=certifi.where())
|
|
330
|
+
except ImportError:
|
|
331
|
+
ctx = ssl._create_unverified_context()
|
|
332
|
+
|
|
333
|
+
req = urllib.request.Request("https://openrouter.ai/api/v1/models")
|
|
334
|
+
with urllib.request.urlopen(req, timeout=5, context=ctx) as response:
|
|
335
|
+
data = json.loads(response.read().decode())
|
|
336
|
+
models = data.get("data", [])
|
|
337
|
+
simplified_models = []
|
|
338
|
+
for m in models:
|
|
339
|
+
simplified_models.append({
|
|
340
|
+
"id": m.get("id"),
|
|
341
|
+
"name": m.get("name"),
|
|
342
|
+
"context_length": m.get("context_length", "Unknown"),
|
|
343
|
+
"pricing": m.get("pricing", {})
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
with open(MODELS_CACHE_PATH, "w", encoding="utf-8") as f:
|
|
347
|
+
json.dump({"timestamp": time.time(), "models": simplified_models}, f)
|
|
348
|
+
except Exception as e:
|
|
349
|
+
logger.debug("Failed to fetch OpenRouter models: %s", e)
|
|
350
|
+
|
|
351
|
+
def fetch_openrouter_models_bg():
|
|
352
|
+
"""Fetches OpenRouter models in the background if the cache is stale."""
|
|
353
|
+
if os.path.exists(MODELS_CACHE_PATH):
|
|
354
|
+
try:
|
|
355
|
+
with open(MODELS_CACHE_PATH, encoding="utf-8") as f:
|
|
356
|
+
data = json.load(f)
|
|
357
|
+
if time.time() - data.get("timestamp", 0) < _MODELS_CACHE_TTL:
|
|
358
|
+
return
|
|
359
|
+
except Exception:
|
|
360
|
+
pass
|
|
361
|
+
|
|
362
|
+
thread = threading.Thread(target=_do_fetch_models, daemon=True)
|
|
363
|
+
thread.start()
|
aizen/context.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Context window management for Aizen.
|
|
3
|
+
|
|
4
|
+
Tracks token usage against model context limits and auto-compacts
|
|
5
|
+
conversations when approaching the boundary.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
|
|
10
|
+
# Known context window sizes for popular models (in tokens).
|
|
11
|
+
# Users can override via config.
|
|
12
|
+
MODEL_CONTEXT_WINDOWS: dict[str, int] = {
|
|
13
|
+
# Anthropic
|
|
14
|
+
"anthropic/claude-sonnet-4": 200_000,
|
|
15
|
+
"anthropic/claude-3.5-sonnet": 200_000,
|
|
16
|
+
"anthropic/claude-3.7-sonnet": 200_000,
|
|
17
|
+
"anthropic/claude-3-opus": 200_000,
|
|
18
|
+
"anthropic/claude-3-haiku": 200_000,
|
|
19
|
+
"anthropic/claude-3.5-haiku": 200_000,
|
|
20
|
+
"anthropic/claude-4-opus": 200_000,
|
|
21
|
+
# OpenAI
|
|
22
|
+
"openai/gpt-4o": 128_000,
|
|
23
|
+
"openai/gpt-4o-mini": 128_000,
|
|
24
|
+
"openai/gpt-4-turbo": 128_000,
|
|
25
|
+
"openai/gpt-4": 8_192,
|
|
26
|
+
"openai/gpt-4.1": 1_047_576,
|
|
27
|
+
"openai/gpt-4.1-mini": 1_047_576,
|
|
28
|
+
"openai/gpt-4.1-nano": 1_047_576,
|
|
29
|
+
"openai/o1": 200_000,
|
|
30
|
+
"openai/o1-mini": 128_000,
|
|
31
|
+
"openai/o3": 200_000,
|
|
32
|
+
"openai/o3-mini": 200_000,
|
|
33
|
+
"openai/o4-mini": 200_000,
|
|
34
|
+
# Google
|
|
35
|
+
"google/gemini-2.5-pro": 1_048_576,
|
|
36
|
+
"google/gemini-2.5-flash": 1_048_576,
|
|
37
|
+
"google/gemini-2.0-flash": 1_048_576,
|
|
38
|
+
"google/gemini-2.0-flash-001": 1_048_576,
|
|
39
|
+
"google/gemini-pro-1.5": 1_048_576,
|
|
40
|
+
# Meta
|
|
41
|
+
"meta-llama/llama-4-maverick": 1_048_576,
|
|
42
|
+
"meta-llama/llama-3.3-70b-instruct": 131_072,
|
|
43
|
+
"meta-llama/llama-3.1-405b-instruct": 131_072,
|
|
44
|
+
"meta-llama/llama-3.1-70b-instruct": 131_072,
|
|
45
|
+
"meta-llama/llama-3.1-8b-instruct": 131_072,
|
|
46
|
+
# Nvidia
|
|
47
|
+
"nvidia/nemotron-3-super-120b-a12b:free": 32_768,
|
|
48
|
+
# DeepSeek
|
|
49
|
+
"deepseek/deepseek-chat-v3": 128_000,
|
|
50
|
+
"deepseek/deepseek-chat": 64_000,
|
|
51
|
+
"deepseek/deepseek-coder": 64_000,
|
|
52
|
+
"deepseek/deepseek-r1": 128_000,
|
|
53
|
+
# Mistral
|
|
54
|
+
"mistralai/mistral-large": 128_000,
|
|
55
|
+
"mistralai/mixtral-8x7b-instruct": 32_768,
|
|
56
|
+
# Qwen
|
|
57
|
+
"qwen/qwen-2.5-72b-instruct": 131_072,
|
|
58
|
+
"qwen/qwen3-235b-a22b": 131_072,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
# Default context window when model is unknown
|
|
62
|
+
DEFAULT_CONTEXT_WINDOW = 32_768
|
|
63
|
+
|
|
64
|
+
# Warn when usage exceeds this fraction of the context window
|
|
65
|
+
WARNING_THRESHOLD = 0.75
|
|
66
|
+
|
|
67
|
+
# Auto-compact when usage exceeds this fraction
|
|
68
|
+
AUTO_COMPACT_THRESHOLD = 0.85
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class ContextManager:
|
|
72
|
+
"""Tracks token usage against model context limits."""
|
|
73
|
+
|
|
74
|
+
def __init__(self, model: str, custom_limit: int | None = None):
|
|
75
|
+
self.model = model
|
|
76
|
+
self._custom_limit = custom_limit
|
|
77
|
+
self._total_tokens = 0
|
|
78
|
+
self._warned = False
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def context_limit(self) -> int:
|
|
82
|
+
"""Get the context window size for the current model."""
|
|
83
|
+
if self._custom_limit:
|
|
84
|
+
return self._custom_limit
|
|
85
|
+
return MODEL_CONTEXT_WINDOWS.get(self.model, DEFAULT_CONTEXT_WINDOW)
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def usage_fraction(self) -> float:
|
|
89
|
+
"""Current usage as a fraction of the context window (0.0 to 1.0+)."""
|
|
90
|
+
if self.context_limit == 0:
|
|
91
|
+
return 0.0
|
|
92
|
+
return self._total_tokens / self.context_limit
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def usage_percent(self) -> int:
|
|
96
|
+
"""Current usage as a percentage."""
|
|
97
|
+
return int(self.usage_fraction * 100)
|
|
98
|
+
|
|
99
|
+
def update(self, total_tokens: int) -> None:
|
|
100
|
+
"""Update the tracked token count."""
|
|
101
|
+
self._total_tokens = total_tokens
|
|
102
|
+
|
|
103
|
+
def estimate_messages_tokens(self, messages: list, estimator) -> int:
|
|
104
|
+
"""Estimate total tokens across all messages using the provided estimator function."""
|
|
105
|
+
total = 0
|
|
106
|
+
for msg in messages:
|
|
107
|
+
content = msg.get("content", "") or ""
|
|
108
|
+
total += estimator(content)
|
|
109
|
+
# Account for tool calls in the message
|
|
110
|
+
if msg.get("tool_calls"):
|
|
111
|
+
total += estimator(json.dumps(msg["tool_calls"]))
|
|
112
|
+
return total
|
|
113
|
+
|
|
114
|
+
def set_model(self, model: str) -> None:
|
|
115
|
+
"""Update the model (resets warning state)."""
|
|
116
|
+
self.model = model
|
|
117
|
+
self._warned = False
|
|
118
|
+
|
|
119
|
+
def check_and_warn(self) -> str | None:
|
|
120
|
+
"""
|
|
121
|
+
Check usage against thresholds.
|
|
122
|
+
Returns a warning message if threshold exceeded, None otherwise.
|
|
123
|
+
"""
|
|
124
|
+
fraction = self.usage_fraction
|
|
125
|
+
|
|
126
|
+
if fraction >= AUTO_COMPACT_THRESHOLD:
|
|
127
|
+
return (
|
|
128
|
+
f"⚠️ Context window is {self.usage_percent}% full "
|
|
129
|
+
f"({self._total_tokens:,}/{self.context_limit:,} tokens). "
|
|
130
|
+
f"Consider using /compact to free up space."
|
|
131
|
+
)
|
|
132
|
+
elif fraction >= WARNING_THRESHOLD and not self._warned:
|
|
133
|
+
self._warned = True
|
|
134
|
+
return (
|
|
135
|
+
f"💡 Context window is {self.usage_percent}% full "
|
|
136
|
+
f"({self._total_tokens:,}/{self.context_limit:,} tokens). "
|
|
137
|
+
f"Use /compact if the conversation gets long."
|
|
138
|
+
)
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
def needs_auto_compact(self) -> bool:
|
|
142
|
+
"""Returns True if the conversation should be auto-compacted."""
|
|
143
|
+
return self.usage_fraction >= AUTO_COMPACT_THRESHOLD
|
|
144
|
+
|
|
145
|
+
def get_usage_bar(self, width: int = 20) -> str:
|
|
146
|
+
"""
|
|
147
|
+
Generate a visual usage bar for the footer.
|
|
148
|
+
|
|
149
|
+
Example: [████████░░░░░░░░░░░░] 42%
|
|
150
|
+
"""
|
|
151
|
+
fraction = min(self.usage_fraction, 1.0)
|
|
152
|
+
filled = int(width * fraction)
|
|
153
|
+
empty = width - filled
|
|
154
|
+
|
|
155
|
+
# Color coding based on usage
|
|
156
|
+
if fraction >= AUTO_COMPACT_THRESHOLD:
|
|
157
|
+
bar_char = "█"
|
|
158
|
+
style = "bold red"
|
|
159
|
+
elif fraction >= WARNING_THRESHOLD:
|
|
160
|
+
bar_char = "█"
|
|
161
|
+
style = "yellow"
|
|
162
|
+
else:
|
|
163
|
+
bar_char = "█"
|
|
164
|
+
style = "green"
|
|
165
|
+
|
|
166
|
+
bar = f"[{style}]{bar_char * filled}[/{style}][dim]{'░' * empty}[/dim]"
|
|
167
|
+
return f"[{bar}] {self.usage_percent}%"
|
|
168
|
+
|
|
169
|
+
def get_footer_text(self) -> str:
|
|
170
|
+
"""Get a compact footer string showing context usage."""
|
|
171
|
+
return f"ctx: {self.get_usage_bar(10)}"
|
aizen/exceptions.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Aizen custom exception hierarchy.
|
|
3
|
+
|
|
4
|
+
All Aizen-specific errors inherit from AizenError, enabling
|
|
5
|
+
typed error handling and user-friendly error messages.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AizenError(Exception):
|
|
10
|
+
"""Base exception for all Aizen errors."""
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class APIKeyError(AizenError):
|
|
15
|
+
"""Raised when the API key is missing, invalid, or rejected (HTTP 401)."""
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class APIConnectionError(AizenError):
|
|
20
|
+
"""Raised when the API is unreachable or the request times out."""
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class RateLimitError(AizenError):
|
|
25
|
+
"""Raised when the API returns HTTP 429 (rate limited)."""
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ToolExecutionError(AizenError):
|
|
30
|
+
"""Raised when a tool fails to execute properly."""
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class FileOperationError(AizenError):
|
|
35
|
+
"""Raised when a file read/write/edit operation fails."""
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class SessionCorruptedError(AizenError):
|
|
40
|
+
"""Raised when a session file cannot be loaded or is malformed."""
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ContextWindowExceededError(AizenError):
|
|
45
|
+
"""Raised when the conversation exceeds the model's context window."""
|
|
46
|
+
pass
|
aizen/logging_config.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Structured logging configuration for Aizen.
|
|
3
|
+
|
|
4
|
+
Provides a rotating file logger at ~/.aizen_logs/aizen.log plus
|
|
5
|
+
an optional console handler controlled by --verbose.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
from logging.handlers import RotatingFileHandler
|
|
11
|
+
|
|
12
|
+
# ─── Constants ──────────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
LOG_DIR = os.path.expanduser("~/.aizen_logs")
|
|
15
|
+
LOG_FILE = os.path.join(LOG_DIR, "aizen.log")
|
|
16
|
+
MAX_LOG_BYTES = 5 * 1024 * 1024 # 5 MB per file
|
|
17
|
+
BACKUP_COUNT = 3 # Keep 3 rotated log files
|
|
18
|
+
|
|
19
|
+
# Module-level logger used throughout the application
|
|
20
|
+
logger = logging.getLogger("aizen")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def setup_logging(verbose: bool = False) -> logging.Logger:
|
|
24
|
+
"""
|
|
25
|
+
Configure logging for the application.
|
|
26
|
+
|
|
27
|
+
- Always logs to ~/.aizen_logs/aizen.log (DEBUG level, rotating).
|
|
28
|
+
- When verbose=True, also logs DEBUG to stderr.
|
|
29
|
+
- When verbose=False, only WARNING+ goes to stderr.
|
|
30
|
+
|
|
31
|
+
Returns the configured root "aizen" logger.
|
|
32
|
+
"""
|
|
33
|
+
os.makedirs(LOG_DIR, exist_ok=True)
|
|
34
|
+
|
|
35
|
+
logger.setLevel(logging.DEBUG)
|
|
36
|
+
|
|
37
|
+
# Clear any existing handlers (e.g. on re-init)
|
|
38
|
+
logger.handlers.clear()
|
|
39
|
+
|
|
40
|
+
# ── File handler (always DEBUG) ──
|
|
41
|
+
file_fmt = logging.Formatter(
|
|
42
|
+
fmt="%(asctime)s │ %(levelname)-8s │ %(name)s │ %(message)s",
|
|
43
|
+
datefmt="%Y-%m-%d %H:%M:%S",
|
|
44
|
+
)
|
|
45
|
+
file_handler = RotatingFileHandler(
|
|
46
|
+
LOG_FILE,
|
|
47
|
+
maxBytes=MAX_LOG_BYTES,
|
|
48
|
+
backupCount=BACKUP_COUNT,
|
|
49
|
+
encoding="utf-8",
|
|
50
|
+
)
|
|
51
|
+
file_handler.setLevel(logging.DEBUG)
|
|
52
|
+
file_handler.setFormatter(file_fmt)
|
|
53
|
+
logger.addHandler(file_handler)
|
|
54
|
+
|
|
55
|
+
# ── Console handler (level depends on --verbose) ──
|
|
56
|
+
console_fmt = logging.Formatter(
|
|
57
|
+
fmt="%(levelname)-8s │ %(message)s",
|
|
58
|
+
)
|
|
59
|
+
console_handler = logging.StreamHandler()
|
|
60
|
+
console_handler.setLevel(logging.DEBUG if verbose else logging.WARNING)
|
|
61
|
+
console_handler.setFormatter(console_fmt)
|
|
62
|
+
logger.addHandler(console_handler)
|
|
63
|
+
|
|
64
|
+
logger.debug("Aizen logging initialized (verbose=%s)", verbose)
|
|
65
|
+
return logger
|