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/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
@@ -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