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/commands.py
ADDED
|
@@ -0,0 +1,694 @@
|
|
|
1
|
+
import copy
|
|
2
|
+
import os
|
|
3
|
+
import platform
|
|
4
|
+
import re
|
|
5
|
+
import subprocess
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
|
|
8
|
+
from prompt_toolkit.completion import Completer, Completion
|
|
9
|
+
from prompt_toolkit.shortcuts import prompt
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
|
|
12
|
+
from .config import (
|
|
13
|
+
BACKUPS_DIR,
|
|
14
|
+
CONFIG_PATH,
|
|
15
|
+
SESSIONS_DIR,
|
|
16
|
+
console,
|
|
17
|
+
get_active_model,
|
|
18
|
+
get_cached_models,
|
|
19
|
+
load_config,
|
|
20
|
+
set_active_model,
|
|
21
|
+
)
|
|
22
|
+
from .logging_config import logger
|
|
23
|
+
from .session import list_sessions, load_session, save_session
|
|
24
|
+
from .tools import backup_manager
|
|
25
|
+
from .utils import TokenTracker, load_gitignore_patterns, should_ignore
|
|
26
|
+
|
|
27
|
+
SLASH_COMMANDS = [
|
|
28
|
+
("/help", "Show all available commands"),
|
|
29
|
+
("/model", "View or switch the active model"),
|
|
30
|
+
("/clear", "Clear conversation history"),
|
|
31
|
+
("/drop", "Drop attached files/URLs from history"),
|
|
32
|
+
("/save", "Save current conversation"),
|
|
33
|
+
("/load", "Load a saved conversation"),
|
|
34
|
+
("/usage", "Show token usage statistics"),
|
|
35
|
+
("/compact", "Summarize conversation to save tokens"),
|
|
36
|
+
("/undo", "Undo the last file modification"),
|
|
37
|
+
("/retry", "Retry the last user message"),
|
|
38
|
+
("/copy", "Copy last AI response to clipboard"),
|
|
39
|
+
("/export", "Export conversation to Markdown"),
|
|
40
|
+
("/checkpoint", "Save a named snapshot of the conversation"),
|
|
41
|
+
("/restore", "Restore a previously saved checkpoint"),
|
|
42
|
+
("/config", "View current configuration"),
|
|
43
|
+
("/mcp", "View configured MCP servers and their status"),
|
|
44
|
+
("/commit", "Auto-generate and commit changes"),
|
|
45
|
+
("/diff", "Show all uncommitted changes"),
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
# In-memory checkpoint storage for conversation branching
|
|
49
|
+
_checkpoints: dict[str, list] = {}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class AizenCompleter(Completer):
|
|
53
|
+
"""Autocomplete for both slash commands (/) and file mentions (@)."""
|
|
54
|
+
|
|
55
|
+
def __init__(self):
|
|
56
|
+
super().__init__()
|
|
57
|
+
self.ignore_patterns = load_gitignore_patterns()
|
|
58
|
+
|
|
59
|
+
def get_completions(self, document, complete_event):
|
|
60
|
+
text = document.text_before_cursor
|
|
61
|
+
stripped = text.lstrip()
|
|
62
|
+
|
|
63
|
+
# ── Slash command completion ──
|
|
64
|
+
# Only complete if '/' is the very first character typed (start of input)
|
|
65
|
+
if stripped.startswith("/"):
|
|
66
|
+
if " " not in stripped:
|
|
67
|
+
query = stripped.lower()
|
|
68
|
+
cmds_with_args = {"/model", "/save", "/load", "/export", "/checkpoint", "/restore"}
|
|
69
|
+
for cmd, description in SLASH_COMMANDS:
|
|
70
|
+
if cmd.startswith(query):
|
|
71
|
+
completion_text = cmd + " " if cmd in cmds_with_args else cmd
|
|
72
|
+
yield Completion(
|
|
73
|
+
completion_text,
|
|
74
|
+
start_position=-len(stripped),
|
|
75
|
+
display=cmd,
|
|
76
|
+
display_meta=description,
|
|
77
|
+
)
|
|
78
|
+
elif stripped.startswith("/model "):
|
|
79
|
+
query = stripped[7:].lower()
|
|
80
|
+
models = get_cached_models()
|
|
81
|
+
for m in models:
|
|
82
|
+
if m["id"].lower().startswith(query) or query in m["id"].lower() or query in m["name"].lower():
|
|
83
|
+
yield Completion(
|
|
84
|
+
m["id"],
|
|
85
|
+
start_position=-len(query),
|
|
86
|
+
display=m["id"],
|
|
87
|
+
display_meta=m["name"]
|
|
88
|
+
)
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
# ── File mention completion (@) ──
|
|
92
|
+
words = text.split()
|
|
93
|
+
if not words:
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
current = words[-1]
|
|
97
|
+
if not current.startswith("@"):
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
query = current[1:]
|
|
101
|
+
|
|
102
|
+
# Support directory traversal
|
|
103
|
+
if "/" in query:
|
|
104
|
+
dir_part = os.path.dirname(query)
|
|
105
|
+
base_part = os.path.basename(query)
|
|
106
|
+
search_dir = dir_part if dir_part else "."
|
|
107
|
+
if os.path.isdir(search_dir):
|
|
108
|
+
try:
|
|
109
|
+
for item in sorted(os.listdir(search_dir)):
|
|
110
|
+
item_path = os.path.join(search_dir, item)
|
|
111
|
+
if item.lower().startswith(base_part.lower()):
|
|
112
|
+
if not should_ignore(item_path, self.ignore_patterns):
|
|
113
|
+
display = os.path.join(dir_part, item)
|
|
114
|
+
if os.path.isdir(item_path):
|
|
115
|
+
display += "/"
|
|
116
|
+
yield Completion(
|
|
117
|
+
display, start_position=-len(query)
|
|
118
|
+
)
|
|
119
|
+
except Exception as e:
|
|
120
|
+
logger.debug("Failed to list directory contents for autocomplete: %s", e)
|
|
121
|
+
else:
|
|
122
|
+
try:
|
|
123
|
+
for item in sorted(os.listdir(".")):
|
|
124
|
+
if item.lower().startswith(query.lower()):
|
|
125
|
+
item_path = item
|
|
126
|
+
if not should_ignore(item_path, self.ignore_patterns):
|
|
127
|
+
if os.path.isdir(item):
|
|
128
|
+
yield Completion(
|
|
129
|
+
item + "/", start_position=-len(query)
|
|
130
|
+
)
|
|
131
|
+
elif os.path.isfile(item):
|
|
132
|
+
yield Completion(
|
|
133
|
+
item, start_position=-len(query)
|
|
134
|
+
)
|
|
135
|
+
except Exception as e:
|
|
136
|
+
logger.debug("Failed to list current directory for autocomplete: %s", e)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
async def handle_slash_command(
|
|
140
|
+
command_str: str, messages: list, token_tracker: TokenTracker, mcp_manager=None, client=None
|
|
141
|
+
) -> bool:
|
|
142
|
+
"""Handle slash commands. Returns True if the agent loop should re-process (e.g. /retry)."""
|
|
143
|
+
parts = command_str.split(maxsplit=1)
|
|
144
|
+
cmd = parts[0].lower()
|
|
145
|
+
arg = parts[1].strip() if len(parts) > 1 else ""
|
|
146
|
+
|
|
147
|
+
current_model = get_active_model()
|
|
148
|
+
|
|
149
|
+
if cmd == "/clear":
|
|
150
|
+
if len(messages) > 1:
|
|
151
|
+
messages[:] = [messages[0]]
|
|
152
|
+
console.print("[green]✓ Conversation cleared.[/green]\n")
|
|
153
|
+
|
|
154
|
+
elif cmd == "/drop":
|
|
155
|
+
dropped_count = 0
|
|
156
|
+
for msg in messages:
|
|
157
|
+
if msg["role"] == "user" and msg.get("content"):
|
|
158
|
+
old_content = msg["content"]
|
|
159
|
+
new_content = re.sub(
|
|
160
|
+
r'<file_context path="[^"]+">.*?</file_context>',
|
|
161
|
+
'[File context dropped to save tokens]',
|
|
162
|
+
old_content,
|
|
163
|
+
flags=re.DOTALL
|
|
164
|
+
)
|
|
165
|
+
new_content = re.sub(
|
|
166
|
+
r'<url_context url="[^"]+">.*?</url_context>',
|
|
167
|
+
'[URL context dropped to save tokens]',
|
|
168
|
+
new_content,
|
|
169
|
+
flags=re.DOTALL
|
|
170
|
+
)
|
|
171
|
+
new_content = re.sub(
|
|
172
|
+
r'<directory_context path="[^"]+">.*?</directory_context>',
|
|
173
|
+
'[Directory context dropped to save tokens]',
|
|
174
|
+
new_content,
|
|
175
|
+
flags=re.DOTALL
|
|
176
|
+
)
|
|
177
|
+
new_content = re.sub(
|
|
178
|
+
r'<command_context cmd="[^"]+">.*?</command_context>',
|
|
179
|
+
'[Command context dropped to save tokens]',
|
|
180
|
+
new_content,
|
|
181
|
+
flags=re.DOTALL
|
|
182
|
+
)
|
|
183
|
+
if old_content != new_content:
|
|
184
|
+
msg["content"] = new_content
|
|
185
|
+
dropped_count += 1
|
|
186
|
+
if dropped_count > 0:
|
|
187
|
+
console.print(f"[green]✓ Dropped attached contexts from {dropped_count} past messages.[/green]\n")
|
|
188
|
+
else:
|
|
189
|
+
console.print("[yellow]No attached contexts found to drop.[/yellow]\n")
|
|
190
|
+
|
|
191
|
+
elif cmd == "/model":
|
|
192
|
+
if arg:
|
|
193
|
+
if arg.startswith("search ") or arg == "list" or arg == "search":
|
|
194
|
+
models = get_cached_models()
|
|
195
|
+
if not models:
|
|
196
|
+
console.print("[yellow]Model list is still fetching or unavailable. Try again in a moment.[/yellow]\n")
|
|
197
|
+
return False
|
|
198
|
+
|
|
199
|
+
search_query = arg[7:].lower().strip() if arg.startswith("search ") else ""
|
|
200
|
+
|
|
201
|
+
table = Table(title=f"🧠 OpenRouter Models{' (Search: ' + search_query + ')' if search_query else ''}")
|
|
202
|
+
table.add_column("Model ID", style="cyan")
|
|
203
|
+
table.add_column("Name", style="white")
|
|
204
|
+
table.add_column("Context", style="dim")
|
|
205
|
+
table.add_column("Pricing", style="green")
|
|
206
|
+
|
|
207
|
+
count = 0
|
|
208
|
+
for m in models:
|
|
209
|
+
if not search_query or search_query in m["id"].lower() or search_query in m["name"].lower():
|
|
210
|
+
price_prompt = m.get("pricing", {}).get("prompt", "?")
|
|
211
|
+
price_comp = m.get("pricing", {}).get("completion", "?")
|
|
212
|
+
pricing_str = f"P: {price_prompt} C: {price_comp}"
|
|
213
|
+
table.add_row(m["id"], m["name"], str(m.get("context_length")), pricing_str)
|
|
214
|
+
count += 1
|
|
215
|
+
if count >= 30: # limit output
|
|
216
|
+
break
|
|
217
|
+
|
|
218
|
+
console.print(table)
|
|
219
|
+
if count >= 30:
|
|
220
|
+
console.print("[dim]... and more (showing top 30). Use `/model search <query>` to narrow down.[/dim]\n")
|
|
221
|
+
else:
|
|
222
|
+
console.print()
|
|
223
|
+
else:
|
|
224
|
+
models = get_cached_models()
|
|
225
|
+
found = any(m["id"] == arg for m in models)
|
|
226
|
+
|
|
227
|
+
if models and not found:
|
|
228
|
+
console.print(f"[yellow]⚠️ Warning: Model '{arg}' not found in OpenRouter API list.[/yellow]")
|
|
229
|
+
|
|
230
|
+
set_active_model(arg, save=True)
|
|
231
|
+
console.print(f"[green]✓ Model switched to:[/green] [bold cyan]{arg}[/bold cyan]\n")
|
|
232
|
+
else:
|
|
233
|
+
console.print(f"[bold]Current model:[/bold] [cyan]{current_model}[/cyan]")
|
|
234
|
+
console.print("[dim]Usage: /model <model_name>[/dim]")
|
|
235
|
+
console.print("[dim] /model search <query> (or `/model list`)[/dim]\n")
|
|
236
|
+
|
|
237
|
+
elif cmd == "/help":
|
|
238
|
+
help_table = Table(
|
|
239
|
+
title="⚡ Aizen Commands",
|
|
240
|
+
border_style="magenta",
|
|
241
|
+
show_header=True,
|
|
242
|
+
header_style="bold magenta",
|
|
243
|
+
)
|
|
244
|
+
help_table.add_column("Command", style="cyan bold", min_width=22)
|
|
245
|
+
help_table.add_column("Description", style="white")
|
|
246
|
+
help_table.add_row("/help", "Show this help message")
|
|
247
|
+
help_table.add_row("/model [name]", "View or switch the active model")
|
|
248
|
+
help_table.add_row("/clear", "Clear conversation history")
|
|
249
|
+
help_table.add_row("/drop", "Drop attached files/URLs from history")
|
|
250
|
+
help_table.add_row("/save [name]", "Save current conversation")
|
|
251
|
+
help_table.add_row("/load [name]", "Load a saved conversation")
|
|
252
|
+
help_table.add_row("/usage", "Show token usage statistics")
|
|
253
|
+
help_table.add_row("/compact", "Summarize conversation to save tokens")
|
|
254
|
+
help_table.add_row("/undo", "Undo the last file modification")
|
|
255
|
+
help_table.add_row("/retry", "Retry the last user message")
|
|
256
|
+
help_table.add_row("/copy", "Copy last AI response to clipboard")
|
|
257
|
+
help_table.add_row("/export [file]", "Export conversation to Markdown")
|
|
258
|
+
help_table.add_row("/checkpoint [name]", "Save a conversation snapshot")
|
|
259
|
+
help_table.add_row("/restore [name]", "Restore a saved checkpoint")
|
|
260
|
+
help_table.add_row("/config", "View current configuration")
|
|
261
|
+
help_table.add_row("/mcp", "View configured MCP servers and their status")
|
|
262
|
+
help_table.add_row("/commit", "Auto-generate and commit changes")
|
|
263
|
+
help_table.add_row("/diff", "Show all uncommitted changes")
|
|
264
|
+
help_table.add_row("", "")
|
|
265
|
+
help_table.add_row("@filename / @url", "Attach file context or web URL")
|
|
266
|
+
help_table.add_row("exit / quit", "Exit Aizen")
|
|
267
|
+
help_table.add_row("", "")
|
|
268
|
+
help_table.add_row("[dim]Tip[/dim]", "[dim]End a line with \\\\ for multi-line input[/dim]")
|
|
269
|
+
console.print(help_table)
|
|
270
|
+
console.print()
|
|
271
|
+
|
|
272
|
+
elif cmd == "/usage":
|
|
273
|
+
console.print(token_tracker.get_summary_table(get_active_model()))
|
|
274
|
+
console.print()
|
|
275
|
+
|
|
276
|
+
elif cmd == "/save":
|
|
277
|
+
try:
|
|
278
|
+
path = save_session(messages, arg if arg else None, token_tracker)
|
|
279
|
+
console.print(f"[green]✓ Session saved to {path}[/green]\n")
|
|
280
|
+
except Exception as e:
|
|
281
|
+
console.print(f"[red]Error saving session: {e}[/red]\n")
|
|
282
|
+
|
|
283
|
+
elif cmd == "/load":
|
|
284
|
+
if not arg:
|
|
285
|
+
sessions = list_sessions()
|
|
286
|
+
if not sessions:
|
|
287
|
+
console.print("[yellow]No saved sessions found.[/yellow]\n")
|
|
288
|
+
else:
|
|
289
|
+
table = Table(
|
|
290
|
+
title="📂 Saved Sessions",
|
|
291
|
+
border_style="magenta",
|
|
292
|
+
header_style="bold magenta",
|
|
293
|
+
)
|
|
294
|
+
table.add_column("Name", style="cyan")
|
|
295
|
+
table.add_column("Saved At", style="dim")
|
|
296
|
+
table.add_column("Messages", style="white", justify="right")
|
|
297
|
+
for s in sessions[:10]:
|
|
298
|
+
table.add_row(s["name"], s["saved_at"][:19], str(s["messages"]))
|
|
299
|
+
console.print(table)
|
|
300
|
+
console.print("[dim]Usage: /load <session_name>[/dim]\n")
|
|
301
|
+
else:
|
|
302
|
+
loaded = load_session(arg)
|
|
303
|
+
if loaded:
|
|
304
|
+
messages[:] = loaded
|
|
305
|
+
console.print(
|
|
306
|
+
f"[green]✓ Loaded session '{arg}' ({len(loaded)} messages)[/green]\n"
|
|
307
|
+
)
|
|
308
|
+
else:
|
|
309
|
+
console.print(f"[red]Session '{arg}' not found.[/red]\n")
|
|
310
|
+
|
|
311
|
+
elif cmd == "/undo":
|
|
312
|
+
result = backup_manager.undo()
|
|
313
|
+
console.print(f"[green]{result}[/green]\n")
|
|
314
|
+
|
|
315
|
+
elif cmd == "/retry":
|
|
316
|
+
# Remove last assistant + tool messages, then re-process the last user message
|
|
317
|
+
while messages and messages[-1]["role"] in ("assistant", "tool"):
|
|
318
|
+
messages.pop()
|
|
319
|
+
if messages and messages[-1]["role"] == "user":
|
|
320
|
+
console.print("[green]✓ Retrying last message...[/green]\n")
|
|
321
|
+
return True # Signal to re-process
|
|
322
|
+
else:
|
|
323
|
+
console.print("[yellow]Nothing to retry.[/yellow]\n")
|
|
324
|
+
|
|
325
|
+
elif cmd == "/copy":
|
|
326
|
+
last_response = None
|
|
327
|
+
for msg in reversed(messages):
|
|
328
|
+
if msg["role"] == "assistant" and msg.get("content"):
|
|
329
|
+
last_response = msg["content"]
|
|
330
|
+
break
|
|
331
|
+
|
|
332
|
+
if last_response:
|
|
333
|
+
try:
|
|
334
|
+
if platform.system() == "Darwin":
|
|
335
|
+
subprocess.run(
|
|
336
|
+
["pbcopy"],
|
|
337
|
+
input=last_response,
|
|
338
|
+
text=True,
|
|
339
|
+
check=True,
|
|
340
|
+
)
|
|
341
|
+
elif platform.system() == "Linux":
|
|
342
|
+
subprocess.run(
|
|
343
|
+
["xclip", "-selection", "clipboard"],
|
|
344
|
+
input=last_response,
|
|
345
|
+
text=True,
|
|
346
|
+
check=True,
|
|
347
|
+
)
|
|
348
|
+
else:
|
|
349
|
+
subprocess.run(
|
|
350
|
+
["clip"], input=last_response, text=True, check=True
|
|
351
|
+
)
|
|
352
|
+
console.print("[green]✓ Copied to clipboard.[/green]\n")
|
|
353
|
+
except Exception:
|
|
354
|
+
console.print(
|
|
355
|
+
"[yellow]⚠️ Could not copy to clipboard.[/yellow]\n"
|
|
356
|
+
)
|
|
357
|
+
else:
|
|
358
|
+
console.print("[yellow]No response to copy.[/yellow]\n")
|
|
359
|
+
|
|
360
|
+
elif cmd == "/export":
|
|
361
|
+
filename = (
|
|
362
|
+
arg
|
|
363
|
+
if arg
|
|
364
|
+
else f"aizen_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.md"
|
|
365
|
+
)
|
|
366
|
+
try:
|
|
367
|
+
with open(filename, "w") as f:
|
|
368
|
+
f.write("# Aizen Conversation Export\n\n")
|
|
369
|
+
f.write(
|
|
370
|
+
f"**Date:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n"
|
|
371
|
+
)
|
|
372
|
+
f.write(f"**Model:** {current_model}\n\n---\n\n")
|
|
373
|
+
for msg in messages:
|
|
374
|
+
if msg["role"] == "system":
|
|
375
|
+
continue
|
|
376
|
+
elif msg["role"] == "user":
|
|
377
|
+
f.write(f"## 👤 You\n\n{msg['content']}\n\n")
|
|
378
|
+
elif msg["role"] == "assistant" and msg.get("content"):
|
|
379
|
+
f.write(f"## ✦ Aizen\n\n{msg['content']}\n\n")
|
|
380
|
+
console.print(f"[green]✓ Exported to {filename}[/green]\n")
|
|
381
|
+
except Exception as e:
|
|
382
|
+
console.print(f"[red]Error exporting: {e}[/red]\n")
|
|
383
|
+
|
|
384
|
+
elif cmd == "/compact":
|
|
385
|
+
if len(messages) <= 4:
|
|
386
|
+
console.print("[yellow]Conversation is already compact.[/yellow]\n")
|
|
387
|
+
else:
|
|
388
|
+
system_msg = messages[0]
|
|
389
|
+
recent = messages[-4:]
|
|
390
|
+
middle = messages[1:-4]
|
|
391
|
+
|
|
392
|
+
if middle:
|
|
393
|
+
# Attempt LLM-based summarization for much better context retention
|
|
394
|
+
console.print("[dim]Summarizing conversation with AI...[/dim]")
|
|
395
|
+
try:
|
|
396
|
+
from openai import AsyncOpenAI as _AsyncOpenAI
|
|
397
|
+
|
|
398
|
+
_config = load_config()
|
|
399
|
+
_api_key = _config.get("OPENROUTER_API_KEY", "")
|
|
400
|
+
_api_base = _config.get("API_BASE_URL", "https://openrouter.ai/api/v1")
|
|
401
|
+
_client = _AsyncOpenAI(base_url=_api_base, api_key=_api_key)
|
|
402
|
+
|
|
403
|
+
# Build a summarization request from the middle messages
|
|
404
|
+
summary_messages = [
|
|
405
|
+
{
|
|
406
|
+
"role": "system",
|
|
407
|
+
"content": (
|
|
408
|
+
"Summarize the following conversation between a user and an AI coding assistant. "
|
|
409
|
+
"Focus on: what files were discussed/modified, what tasks were completed, "
|
|
410
|
+
"what decisions were made, and any important context for continuing the work. "
|
|
411
|
+
"Be concise but thorough. Output only the summary."
|
|
412
|
+
),
|
|
413
|
+
},
|
|
414
|
+
{
|
|
415
|
+
"role": "user",
|
|
416
|
+
"content": "\n".join(
|
|
417
|
+
f"[{m['role']}]: {(m.get('content') or '')[:500]}"
|
|
418
|
+
for m in middle
|
|
419
|
+
if m.get("content")
|
|
420
|
+
),
|
|
421
|
+
},
|
|
422
|
+
]
|
|
423
|
+
|
|
424
|
+
response = await _client.chat.completions.create(
|
|
425
|
+
model=get_active_model(),
|
|
426
|
+
messages=summary_messages, # type: ignore
|
|
427
|
+
max_tokens=1000,
|
|
428
|
+
)
|
|
429
|
+
summary = response.choices[0].message.content or ""
|
|
430
|
+
except Exception:
|
|
431
|
+
# Fallback to naive summarization if API call fails
|
|
432
|
+
user_topics = [
|
|
433
|
+
m["content"][:100]
|
|
434
|
+
for m in middle
|
|
435
|
+
if m["role"] == "user" and m.get("content")
|
|
436
|
+
]
|
|
437
|
+
summary = (
|
|
438
|
+
"Previous conversation summary: The user and assistant discussed "
|
|
439
|
+
+ "; ".join(user_topics[:5])
|
|
440
|
+
+ ". The assistant helped with these requests using code analysis and editing tools."
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
messages[:] = [
|
|
444
|
+
system_msg,
|
|
445
|
+
{"role": "user", "content": f"Previous conversation summary:\n{summary}"},
|
|
446
|
+
{
|
|
447
|
+
"role": "assistant",
|
|
448
|
+
"content": "Understood. I have the context from our previous discussion. How can I continue helping?",
|
|
449
|
+
},
|
|
450
|
+
] + recent
|
|
451
|
+
console.print(
|
|
452
|
+
f"[green]✓ Compacted {len(middle)} messages into an AI-generated summary.[/green]\n"
|
|
453
|
+
)
|
|
454
|
+
else:
|
|
455
|
+
console.print("[yellow]Not enough messages to compact.[/yellow]\n")
|
|
456
|
+
|
|
457
|
+
elif cmd == "/config":
|
|
458
|
+
config = load_config()
|
|
459
|
+
table = Table(
|
|
460
|
+
title="⚙️ Configuration",
|
|
461
|
+
border_style="magenta",
|
|
462
|
+
header_style="bold magenta",
|
|
463
|
+
)
|
|
464
|
+
table.add_column("Key", style="cyan")
|
|
465
|
+
table.add_column("Value", style="white")
|
|
466
|
+
table.add_row("Model", current_model)
|
|
467
|
+
table.add_row(
|
|
468
|
+
"API Base URL",
|
|
469
|
+
config.get("API_BASE_URL", "https://openrouter.ai/api/v1"),
|
|
470
|
+
)
|
|
471
|
+
api_key = config.get("OPENROUTER_API_KEY", "")
|
|
472
|
+
table.add_row("API Key", f"***{api_key[-4:]}" if api_key else "Not set")
|
|
473
|
+
table.add_row("Config File", CONFIG_PATH)
|
|
474
|
+
table.add_row("Sessions Dir", SESSIONS_DIR)
|
|
475
|
+
table.add_row("Backups Dir", BACKUPS_DIR)
|
|
476
|
+
console.print(table)
|
|
477
|
+
console.print()
|
|
478
|
+
|
|
479
|
+
elif cmd == "/mcp":
|
|
480
|
+
if not mcp_manager:
|
|
481
|
+
console.print("[yellow]MCP Manager is not available.[/yellow]\n")
|
|
482
|
+
return False
|
|
483
|
+
|
|
484
|
+
if not mcp_manager.config:
|
|
485
|
+
console.print("[yellow]No MCP servers configured in ~/.aizen_config.json[/yellow]\n")
|
|
486
|
+
console.print("[dim]Add an 'mcp_servers' block to your config to enable MCP plugins.[/dim]\n")
|
|
487
|
+
return False
|
|
488
|
+
|
|
489
|
+
table = Table(
|
|
490
|
+
title="🔌 Configured MCP Servers",
|
|
491
|
+
border_style="magenta",
|
|
492
|
+
header_style="bold magenta",
|
|
493
|
+
)
|
|
494
|
+
table.add_column("Server Name", style="cyan bold")
|
|
495
|
+
table.add_column("Status", style="white")
|
|
496
|
+
table.add_column("Tools Available", style="dim")
|
|
497
|
+
|
|
498
|
+
tools = mcp_manager.get_tools()
|
|
499
|
+
server_tools: dict[str, list[str]] = {srv: [] for srv in mcp_manager.config.keys()}
|
|
500
|
+
|
|
501
|
+
for t in tools:
|
|
502
|
+
name = t["function"]["name"]
|
|
503
|
+
for server_name in mcp_manager.config.keys():
|
|
504
|
+
prefix = f"mcp_{server_name}_"
|
|
505
|
+
if name.startswith(prefix):
|
|
506
|
+
server_tools[server_name].append(name[len(prefix):])
|
|
507
|
+
break
|
|
508
|
+
|
|
509
|
+
for server_name in mcp_manager.config.keys():
|
|
510
|
+
if server_name in mcp_manager.sessions:
|
|
511
|
+
status = "[green]Connected[/green]"
|
|
512
|
+
else:
|
|
513
|
+
status = "[red]Disconnected / Failed[/red]"
|
|
514
|
+
|
|
515
|
+
tool_count = len(server_tools[server_name])
|
|
516
|
+
if tool_count > 0:
|
|
517
|
+
tool_list = ", ".join(server_tools[server_name])
|
|
518
|
+
# Truncate if too long
|
|
519
|
+
if len(tool_list) > 50:
|
|
520
|
+
tool_list = tool_list[:47] + "..."
|
|
521
|
+
tools_display = f"{tool_count} tools: {tool_list}"
|
|
522
|
+
else:
|
|
523
|
+
tools_display = "0 tools"
|
|
524
|
+
|
|
525
|
+
table.add_row(server_name, status, tools_display)
|
|
526
|
+
|
|
527
|
+
console.print(table)
|
|
528
|
+
console.print()
|
|
529
|
+
|
|
530
|
+
elif cmd == "/checkpoint":
|
|
531
|
+
name = arg or f"cp_{datetime.now().strftime('%H%M%S')}"
|
|
532
|
+
_checkpoints[name] = copy.deepcopy(messages)
|
|
533
|
+
console.print(
|
|
534
|
+
f"[green]✓ Checkpoint '{name}' saved ({len(messages)} messages)[/green]\n"
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
elif cmd == "/restore":
|
|
538
|
+
if not arg:
|
|
539
|
+
if not _checkpoints:
|
|
540
|
+
console.print("[yellow]No checkpoints saved. Use /checkpoint [name] first.[/yellow]\n")
|
|
541
|
+
else:
|
|
542
|
+
table = Table(
|
|
543
|
+
title="📌 Checkpoints",
|
|
544
|
+
border_style="magenta",
|
|
545
|
+
header_style="bold magenta",
|
|
546
|
+
)
|
|
547
|
+
table.add_column("Name", style="cyan")
|
|
548
|
+
table.add_column("Messages", style="white", justify="right")
|
|
549
|
+
for cp_name, cp_msgs in _checkpoints.items():
|
|
550
|
+
table.add_row(cp_name, str(len(cp_msgs)))
|
|
551
|
+
console.print(table)
|
|
552
|
+
console.print("[dim]Usage: /restore <name>[/dim]\n")
|
|
553
|
+
else:
|
|
554
|
+
if arg in _checkpoints:
|
|
555
|
+
messages[:] = copy.deepcopy(_checkpoints[arg])
|
|
556
|
+
console.print(
|
|
557
|
+
f"[green]✓ Restored checkpoint '{arg}' ({len(messages)} messages)[/green]\n"
|
|
558
|
+
)
|
|
559
|
+
else:
|
|
560
|
+
console.print(f"[red]Checkpoint '{arg}' not found.[/red]\n")
|
|
561
|
+
|
|
562
|
+
elif cmd == "/commit":
|
|
563
|
+
if not client:
|
|
564
|
+
console.print("[red]API client is not available for /commit.[/red]\n")
|
|
565
|
+
return False
|
|
566
|
+
|
|
567
|
+
try:
|
|
568
|
+
# Check staged changes
|
|
569
|
+
result = subprocess.run(["git", "diff", "--cached"], capture_output=True, text=True, check=True)
|
|
570
|
+
diff = result.stdout.strip()
|
|
571
|
+
|
|
572
|
+
if not diff:
|
|
573
|
+
# Check unstaged
|
|
574
|
+
result_unstaged = subprocess.run(["git", "diff"], capture_output=True, text=True, check=True)
|
|
575
|
+
unstaged_diff = result_unstaged.stdout.strip()
|
|
576
|
+
|
|
577
|
+
if not unstaged_diff:
|
|
578
|
+
console.print("[yellow]No changes found to commit.[/yellow]\n")
|
|
579
|
+
return False
|
|
580
|
+
|
|
581
|
+
answer = prompt("No staged changes. Stage all current changes? [Y/n] ")
|
|
582
|
+
if answer.lower() not in ("y", "yes", ""):
|
|
583
|
+
console.print("[yellow]Commit aborted.[/yellow]\n")
|
|
584
|
+
return False
|
|
585
|
+
|
|
586
|
+
subprocess.run(["git", "add", "-u"], check=True)
|
|
587
|
+
result = subprocess.run(["git", "diff", "--cached"], capture_output=True, text=True, check=True)
|
|
588
|
+
diff = result.stdout.strip()
|
|
589
|
+
|
|
590
|
+
if not diff:
|
|
591
|
+
console.print("[yellow]No changes staged to commit.[/yellow]\n")
|
|
592
|
+
return False
|
|
593
|
+
|
|
594
|
+
console.print("[dim]Generating commit message...[/dim]")
|
|
595
|
+
|
|
596
|
+
commit_messages = [
|
|
597
|
+
{"role": "system", "content": "You are a senior developer. Write a concise, conventional commit message for the following diff. Output ONLY the commit message, no explanation, no markdown blocks."},
|
|
598
|
+
{"role": "user", "content": f"Diff:\n{diff[:10000]}"}
|
|
599
|
+
]
|
|
600
|
+
|
|
601
|
+
response = await client.chat.completions.create(
|
|
602
|
+
model=get_active_model(),
|
|
603
|
+
messages=commit_messages,
|
|
604
|
+
max_tokens=200,
|
|
605
|
+
)
|
|
606
|
+
commit_msg = response.choices[0].message.content.strip()
|
|
607
|
+
# Remove any markdown codeblocks if model didn't listen
|
|
608
|
+
commit_msg = commit_msg.replace("```text", "").replace("```", "").strip()
|
|
609
|
+
|
|
610
|
+
console.print("\n[bold]Generated Commit Message:[/bold]")
|
|
611
|
+
console.print(f"[cyan]{commit_msg}[/cyan]\n")
|
|
612
|
+
|
|
613
|
+
action = prompt("Commit with this message? [Y/n/e(dit)] ")
|
|
614
|
+
action = action.lower().strip()
|
|
615
|
+
|
|
616
|
+
if action in ("y", "yes", ""):
|
|
617
|
+
final_msg = commit_msg
|
|
618
|
+
elif action in ("e", "edit"):
|
|
619
|
+
final_msg = prompt("Edit message: ", default=commit_msg)
|
|
620
|
+
else:
|
|
621
|
+
console.print("[yellow]Commit aborted.[/yellow]\n")
|
|
622
|
+
return False
|
|
623
|
+
|
|
624
|
+
subprocess.run(["git", "commit", "-m", final_msg], check=True)
|
|
625
|
+
console.print("[green]✓ Committed successfully.[/green]\n")
|
|
626
|
+
|
|
627
|
+
except subprocess.CalledProcessError:
|
|
628
|
+
console.print("[red]Error: Not a git repository or git command failed.[/red]\n")
|
|
629
|
+
except Exception as e:
|
|
630
|
+
console.print(f"[red]Error during auto-commit: {e}[/red]\n")
|
|
631
|
+
|
|
632
|
+
elif cmd == "/diff":
|
|
633
|
+
try:
|
|
634
|
+
# Show staged + unstaged changes
|
|
635
|
+
result_staged = subprocess.run(
|
|
636
|
+
["git", "diff", "--cached", "--stat"],
|
|
637
|
+
capture_output=True, text=True, check=True
|
|
638
|
+
)
|
|
639
|
+
result_unstaged = subprocess.run(
|
|
640
|
+
["git", "diff", "--stat"],
|
|
641
|
+
capture_output=True, text=True, check=True
|
|
642
|
+
)
|
|
643
|
+
result_untracked = subprocess.run(
|
|
644
|
+
["git", "ls-files", "--others", "--exclude-standard"],
|
|
645
|
+
capture_output=True, text=True, check=True
|
|
646
|
+
)
|
|
647
|
+
|
|
648
|
+
has_output = False
|
|
649
|
+
|
|
650
|
+
if result_staged.stdout.strip():
|
|
651
|
+
console.print("[bold green]Staged changes:[/bold green]")
|
|
652
|
+
console.print(f"[dim]{result_staged.stdout.strip()}[/dim]")
|
|
653
|
+
has_output = True
|
|
654
|
+
|
|
655
|
+
if result_unstaged.stdout.strip():
|
|
656
|
+
console.print("[bold yellow]Unstaged changes:[/bold yellow]")
|
|
657
|
+
console.print(f"[dim]{result_unstaged.stdout.strip()}[/dim]")
|
|
658
|
+
has_output = True
|
|
659
|
+
|
|
660
|
+
if result_untracked.stdout.strip():
|
|
661
|
+
untracked = result_untracked.stdout.strip().split("\n")
|
|
662
|
+
console.print(f"[bold cyan]Untracked files ({len(untracked)}):[/bold cyan]")
|
|
663
|
+
for f in untracked[:20]:
|
|
664
|
+
console.print(f" [dim]+ {f}[/dim]")
|
|
665
|
+
if len(untracked) > 20:
|
|
666
|
+
console.print(f" [dim]... and {len(untracked) - 20} more[/dim]")
|
|
667
|
+
has_output = True
|
|
668
|
+
|
|
669
|
+
if not has_output:
|
|
670
|
+
console.print("[green]✓ Working tree is clean.[/green]")
|
|
671
|
+
|
|
672
|
+
# Show full diff if requested
|
|
673
|
+
if arg == "--full" or arg == "-f":
|
|
674
|
+
result_full = subprocess.run(
|
|
675
|
+
["git", "diff"],
|
|
676
|
+
capture_output=True, text=True, check=True
|
|
677
|
+
)
|
|
678
|
+
if result_full.stdout.strip():
|
|
679
|
+
from rich.syntax import Syntax
|
|
680
|
+
syntax = Syntax(result_full.stdout, "diff", theme="monokai")
|
|
681
|
+
console.print(syntax)
|
|
682
|
+
|
|
683
|
+
console.print()
|
|
684
|
+
except subprocess.CalledProcessError:
|
|
685
|
+
console.print("[red]Error: Not a git repository or git command failed.[/red]\n")
|
|
686
|
+
except Exception as e:
|
|
687
|
+
console.print(f"[red]Error showing diff: {e}[/red]\n")
|
|
688
|
+
|
|
689
|
+
else:
|
|
690
|
+
console.print(
|
|
691
|
+
f"[red]Unknown command: {cmd}[/red] — type [bold]/help[/bold] for commands.\n"
|
|
692
|
+
)
|
|
693
|
+
|
|
694
|
+
return False
|