w1x-cli 3.5.0__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.
w1x/app.py ADDED
@@ -0,0 +1,754 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Главный интерфейс приложения W1x, слэш-команды и цикл диалога."""
3
+ import json
4
+ import time
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+ from rich.panel import Panel
8
+
9
+ from .config import (
10
+ APP_VERSION,
11
+ CHAT_DIR,
12
+ CURRENT_CHAT_PATH,
13
+ FACTS_PATH,
14
+ MAX_SAVED_MESSAGES,
15
+ DEFAULT_SYSTEM_PROMPT,
16
+ EXIT_COMMANDS,
17
+ ACTIVE_SESSION_DATA,
18
+ )
19
+ from .terminal import (
20
+ console,
21
+ render_message,
22
+ read_input,
23
+ copy_to_clipboard,
24
+ show_terminal_emoji_hint,
25
+ )
26
+ from .puller import GLOBAL_PULL_QUEUE, first_time_setup_wizard, pull_model
27
+ from .models import (
28
+ choose_model,
29
+ installed_models,
30
+ preload_model,
31
+ start_background_preload,
32
+ wait_for_model_ready,
33
+ reset_all_models,
34
+ unload_all_models,
35
+ interactive_config_menu,
36
+ print_models,
37
+ get_last_model,
38
+ )
39
+ from .memory import (
40
+ load_facts,
41
+ remember_fact,
42
+ recall_facts,
43
+ forget_facts,
44
+ extract_remember_request,
45
+ extract_forget_request,
46
+ looks_like_memory_question,
47
+ migrate_explicit_memory,
48
+ )
49
+ from .tools import (
50
+ set_file_workspace,
51
+ run_agy_for_user,
52
+ should_auto_escalate,
53
+ )
54
+ from .testpy import (
55
+ extract_latest_python_code,
56
+ run_interactive_python_test,
57
+ )
58
+ from .engine import (
59
+ generate_answer,
60
+ direct_tool_stats,
61
+ empty_totals,
62
+ add_to_totals,
63
+ print_stats,
64
+ )
65
+
66
+
67
+ def new_context(system_prompt):
68
+ return [{"role": "system", "content": system_prompt}]
69
+
70
+
71
+ def save_chat(messages, requested_name=""):
72
+ CHAT_DIR.mkdir(parents=True, exist_ok=True)
73
+ if requested_name:
74
+ safe_name = Path(requested_name).name
75
+ if not safe_name.lower().endswith(".md"):
76
+ safe_name += ".md"
77
+ else:
78
+ safe_name = datetime.now().strftime("chat-%Y-%m-%d-%H%M%S.md")
79
+ path = CHAT_DIR / safe_name
80
+
81
+ lines = ["# Ollama chat", ""]
82
+ for message in messages:
83
+ if message["role"] == "system":
84
+ continue
85
+ title = "Вы" if message["role"] == "user" else "Ассистент"
86
+ lines.extend([f"## {title}", "", message["content"], ""])
87
+ path.write_text("\n".join(lines), encoding="utf-8")
88
+ return path
89
+
90
+
91
+ def save_current_chat(system_prompt, messages):
92
+ """Автоматически сохраняет текущий диалог локально."""
93
+ saved_messages = [
94
+ {
95
+ "role": message["role"],
96
+ "content": message["content"],
97
+ }
98
+ for message in messages
99
+ if message.get("role") in {"user", "assistant"}
100
+ and isinstance(message.get("content"), str)
101
+ ][-MAX_SAVED_MESSAGES:]
102
+
103
+ state = {
104
+ "version": 1,
105
+ "system_prompt": system_prompt,
106
+ "custom_system_prompt": system_prompt != DEFAULT_SYSTEM_PROMPT,
107
+ "messages": saved_messages,
108
+ }
109
+
110
+ try:
111
+ CHAT_DIR.mkdir(parents=True, exist_ok=True)
112
+ temporary_path = CURRENT_CHAT_PATH.with_suffix(".tmp")
113
+ temporary_path.write_text(
114
+ json.dumps(state, ensure_ascii=False, indent=2),
115
+ encoding="utf-8",
116
+ )
117
+ temporary_path.replace(CURRENT_CHAT_PATH)
118
+ except OSError as error:
119
+ console.print(f"[yellow]Не удалось сохранить память: {error}[/yellow]")
120
+
121
+
122
+ def load_current_chat():
123
+ """Восстанавливает последний диалог или создаёт новый."""
124
+ if not CURRENT_CHAT_PATH.exists():
125
+ return DEFAULT_SYSTEM_PROMPT, new_context(DEFAULT_SYSTEM_PROMPT)
126
+
127
+ try:
128
+ state = json.loads(CURRENT_CHAT_PATH.read_text(encoding="utf-8"))
129
+ saved_system_prompt = state.get("system_prompt") or ""
130
+ has_custom_prompt = state.get("custom_system_prompt") is True
131
+ system_prompt = (
132
+ saved_system_prompt
133
+ if has_custom_prompt and saved_system_prompt
134
+ else DEFAULT_SYSTEM_PROMPT
135
+ )
136
+ saved_messages = []
137
+
138
+ discard_legacy_style = (
139
+ "custom_system_prompt" not in state
140
+ and saved_system_prompt
141
+ and saved_system_prompt != DEFAULT_SYSTEM_PROMPT
142
+ )
143
+
144
+ for message in ([] if discard_legacy_style else state.get("messages", [])):
145
+ role = message.get("role")
146
+ content = message.get("content")
147
+ if role in {"user", "assistant"} and isinstance(content, str):
148
+ saved_messages.append({"role": role, "content": content})
149
+
150
+ if discard_legacy_style:
151
+ console.print("[dim]Старый экспериментальный стиль сброшен.[/dim]")
152
+
153
+ return system_prompt, [
154
+ {"role": "system", "content": system_prompt},
155
+ *saved_messages[-MAX_SAVED_MESSAGES:],
156
+ ]
157
+ except (OSError, ValueError, TypeError) as error:
158
+ console.print(f"[yellow]Не удалось восстановить память: {error}[/yellow]")
159
+ return DEFAULT_SYSTEM_PROMPT, new_context(DEFAULT_SYSTEM_PROMPT)
160
+
161
+
162
+ def print_status(model, think_mode, stats_enabled, messages):
163
+ from . import config
164
+ think_text = "выключен" if not think_mode else str(think_mode)
165
+ turns = sum(message["role"] == "user" for message in messages)
166
+ console.print(
167
+ Panel(
168
+ f"Модель: [bold]{model}[/bold]\n"
169
+ f"ИИ: [bold]W1x[/bold] • разработчик: [bold]W1bbex[/bold]\n"
170
+ f"Сборка: [bold]{APP_VERSION}[/bold]\n"
171
+ f"Thinking: [bold]{think_text}[/bold]\n"
172
+ "Инструменты: [bold]поиск, время, калькулятор, Python, память, "
173
+ "файлы, Antigravity[/bold]\n"
174
+ f"Папка проекта: [bold]{config.ACTIVE_FILE_ROOT or 'не выбрана'}[/bold]\n"
175
+ f"Статистика: [bold]{'включена' if stats_enabled else 'выключена'}[/bold]\n"
176
+ f"Сообщений пользователя: [bold]{turns}[/bold]",
177
+ title="Состояние",
178
+ border_style="cyan",
179
+ )
180
+ )
181
+
182
+
183
+ def print_header(model):
184
+ console.print(
185
+ Panel(
186
+ f"[bold green]{model}[/bold green]\n"
187
+ f"[dim]W1x CLI • v{APP_VERSION}[/dim]\n"
188
+ "[dim]Enter — отправить • Shift+Enter — новая строка • "
189
+ "/help — команды[/dim]",
190
+ title="W1x",
191
+ border_style="green",
192
+ )
193
+ )
194
+
195
+
196
+ def print_help():
197
+ console.print(
198
+ """
199
+ [bold]Ввод[/bold]
200
+ Enter отправить сообщение
201
+ Shift+Enter новая строка
202
+ Ctrl+Enter новая строка
203
+ Alt+Enter запасной вариант новой строки
204
+ Ctrl+S вернуть прошлый вопрос в строку ввода и стереть из памяти
205
+ Ctrl+Q выгрузить все модели и выйти
206
+ Ctrl+Backspace удалить слово слева
207
+ Ctrl+Delete удалить слово справа
208
+ Ctrl+A выделить весь текст; Delete/Backspace — очистить
209
+ Ctrl+X вырезать выделенный текст (или весь ввод) в буфер
210
+ Ctrl+C копировать выделение / остановить генерацию
211
+ Ctrl+V вставить текст из буфера обмена
212
+ /ком... серое продолжение и список; Tab/Enter — подставить
213
+
214
+ [bold]Команды[/bold]
215
+ [cyan]/model[/cyan] сменить модель (стрелки ↑/↓ или Enter)
216
+ [cyan]/pull [модели...][/cyan] скачать модели из библиотеки Ollama
217
+ [cyan]/config[/cyan] настройки контекста, VRAM и модели
218
+ [cyan]/models[/cyan] список установленных моделей
219
+ [cyan]/new[/cyan], [cyan]/clear[/cyan] начать новый диалог
220
+ [cyan]/undo[/cyan] удалить последний вопрос и ответ
221
+ [cyan]/retry[/cyan] заново ответить на последний вопрос
222
+ [cyan]/think[/cyan] включить/выключить размышления
223
+ [cyan]/think low|medium|high[/cyan] выбрать уровень размышлений
224
+ [cyan]/tools[/cyan] доступные инструменты
225
+ [cyan]/agy задача[/cyan] передать задачу Antigravity напрямую
226
+ [cyan]/workspace путь[/cyan] выбрать папку проекта для безопасного чтения
227
+ [cyan]/stats[/cyan] статистика прошлого и следующих ответов
228
+ [cyan]/status[/cyan] текущие настройки
229
+ [cyan]/system[/cyan] показать системную инструкцию
230
+ [cyan]/system текст[/cyan] заменить системную инструкцию
231
+ [cyan]/copy[/cyan] скопировать последний ответ
232
+ [cyan]/testpy[/cyan] протестировать последний Python-код в отдельном окне
233
+ [cyan]/save[/cyan] [имя] сохранить диалог в Markdown
234
+ [cyan]/memory[/cyan] показать состояние постоянной памяти
235
+ [cyan]/forget текст[/cyan] удалить подходящий факт из памяти
236
+ [cyan]/cls[/cyan] очистить экран
237
+ [cyan]/help[/cyan] эта справка
238
+ [cyan]q[/cyan], [cyan]/exit[/cyan] выгрузить модели и выйти
239
+ """
240
+ )
241
+
242
+
243
+ def shutdown():
244
+ console.print()
245
+ reset_all_models()
246
+ console.print("[dim]Все модели выгружены.[/dim]")
247
+
248
+
249
+ def looks_like_launch_command(text):
250
+ normalized = text.strip().lower().replace('"', "")
251
+ return (
252
+ ".py" in normalized
253
+ and (
254
+ "python.exe" in normalized
255
+ or normalized.startswith("python ")
256
+ or normalized.startswith("py ")
257
+ or normalized.startswith("& ")
258
+ )
259
+ )
260
+
261
+
262
+ def main():
263
+ import sys
264
+ from . import config
265
+
266
+ cli_args = sys.argv[1:]
267
+ if cli_args:
268
+ first_arg = cli_args[0].lower()
269
+ if first_arg in ("-q", "--unload", "/unload", "unload"):
270
+ unloaded = unload_all_models()
271
+ msg = ", ".join(unloaded) if unloaded else "нет активных моделей"
272
+ console.print(f"[green]✓ Все модели выгружены из VRAM ({msg}).[/green]")
273
+ return
274
+
275
+ if first_arg in ("pull", "download", "--pull", "скачать"):
276
+ models_to_pull = cli_args[1:]
277
+ if not models_to_pull:
278
+ first_time_setup_wizard()
279
+ return
280
+ GLOBAL_PULL_QUEUE.add(models_to_pull)
281
+ GLOBAL_PULL_QUEUE.run_all()
282
+ return
283
+
284
+ if first_arg in ("--config", "-c", "config", "/config", "/settings"):
285
+ interactive_config_menu()
286
+ return
287
+
288
+ models = installed_models()
289
+ if not models:
290
+ console.print("[yellow]W1x: первый запуск. Выберите стартовую модель для загрузки:[/yellow]")
291
+ downloaded = first_time_setup_wizard()
292
+ if not downloaded:
293
+ return
294
+ models = installed_models()
295
+
296
+ target_model = get_last_model(models)
297
+ question_parts = []
298
+ i = 0
299
+ while i < len(cli_args):
300
+ arg = cli_args[i]
301
+ if arg in ("-m", "--model") and i + 1 < len(cli_args):
302
+ custom_m = cli_args[i + 1]
303
+ if custom_m in models:
304
+ target_model = custom_m
305
+ i += 2
306
+ else:
307
+ question_parts.append(arg)
308
+ i += 1
309
+
310
+ question = " ".join(question_parts).strip()
311
+ if not question:
312
+ question = "Привет!"
313
+
314
+ system_prompt = config.DEFAULT_SYSTEM_PROMPT
315
+ messages = [
316
+ {"role": "system", "content": system_prompt},
317
+ {"role": "user", "content": question},
318
+ ]
319
+ try:
320
+ wait_for_model_ready(target_model)
321
+ generate_answer(target_model, messages, think_mode=False)
322
+ except KeyboardInterrupt:
323
+ console.print("\n[yellow]Прервано пользователем.[/yellow]")
324
+ except Exception as error:
325
+ console.print(f"[red]Ошибка: {error}[/red]")
326
+ return
327
+
328
+ show_terminal_emoji_hint()
329
+ model = choose_model()
330
+ if model is None:
331
+ shutdown()
332
+ return
333
+
334
+ # Запускаем прогрев в фоне: поле ввода открывается мгновенно, не блокируя пользователя!
335
+ start_background_preload(model)
336
+
337
+ system_prompt, messages = load_current_chat()
338
+ ACTIVE_SESSION_DATA["messages"] = messages
339
+ ACTIVE_SESSION_DATA["system_prompt"] = system_prompt
340
+ migrated_facts = migrate_explicit_memory(messages)
341
+ save_current_chat(system_prompt, messages)
342
+ think_mode = False
343
+ stats_enabled = False
344
+ last_stats = None
345
+ totals = empty_totals()
346
+ last_response = next(
347
+ (
348
+ item["content"]
349
+ for item in reversed(messages)
350
+ if item["role"] == "assistant"
351
+ ),
352
+ "",
353
+ )
354
+ print_header(model)
355
+ restored_turns = sum(item["role"] == "user" for item in messages)
356
+ if restored_turns:
357
+ console.print(
358
+ f"[dim]Память восстановлена: {restored_turns} "
359
+ f"сообщений пользователя.[/dim]"
360
+ )
361
+ if migrated_facts:
362
+ console.print(
363
+ f"[dim]В постоянную память перенесено фактов: {migrated_facts}.[/dim]"
364
+ )
365
+
366
+ while True:
367
+ try:
368
+ prompt = read_input().strip()
369
+ except KeyboardInterrupt:
370
+ console.print("\n[dim]Для выхода введи q или /exit.[/dim]")
371
+ continue
372
+ except EOFError:
373
+ shutdown()
374
+ return
375
+
376
+ if not prompt:
377
+ continue
378
+
379
+ command_line = prompt.split(maxsplit=1)
380
+ command = command_line[0].lower()
381
+ argument = command_line[1].strip() if len(command_line) > 1 else ""
382
+
383
+ if prompt.lower() in EXIT_COMMANDS:
384
+ shutdown()
385
+ return
386
+
387
+ if looks_like_launch_command(prompt):
388
+ console.print(
389
+ "[yellow]Это команда запуска Python. Сейчас ты внутри чата: "
390
+ "введи q, а затем выполни команду в CMD или PowerShell.[/yellow]"
391
+ )
392
+ continue
393
+
394
+ if command in {"/help", "/?"}:
395
+ print_help()
396
+ continue
397
+
398
+ if command in {"/config", "/settings", "/настройки"}:
399
+ interactive_config_menu()
400
+ continue
401
+
402
+ if command in {"/unload", "/free"}:
403
+ reset_all_models(show_message=True)
404
+ continue
405
+
406
+ if command == "/models":
407
+ print_models(installed_models())
408
+ continue
409
+
410
+ if command in {"/pull", "/download", "/скачать"}:
411
+ models_to_pull = argument.split()
412
+ if not models_to_pull:
413
+ downloaded = first_time_setup_wizard()
414
+ if downloaded:
415
+ model = downloaded
416
+ start_background_preload(model)
417
+ print_header(model)
418
+ else:
419
+ GLOBAL_PULL_QUEUE.add(models_to_pull)
420
+ GLOBAL_PULL_QUEUE.run_all()
421
+ continue
422
+
423
+ if command == "/model":
424
+ selected = choose_model(current=model)
425
+ if selected is None or selected == model:
426
+ continue
427
+ reset_all_models()
428
+ model = selected
429
+ start_background_preload(model)
430
+ print_header(model)
431
+ continue
432
+
433
+ if command in {"/new", "/clear"}:
434
+ messages = new_context(system_prompt)
435
+ ACTIVE_SESSION_DATA["messages"] = messages
436
+ last_stats = None
437
+ last_response = ""
438
+ totals = empty_totals()
439
+ save_current_chat(system_prompt, messages)
440
+ console.print("[green]Новый диалог начат.[/green]")
441
+ continue
442
+
443
+ if command == "/cls":
444
+ console.clear()
445
+ print_header(model)
446
+ continue
447
+
448
+ if command == "/stats":
449
+ stats_enabled = not stats_enabled
450
+ state = "включена" if stats_enabled else "выключена"
451
+ console.print(f"[green]Статистика {state}.[/green]")
452
+ if stats_enabled:
453
+ print_stats(last_stats, totals)
454
+ continue
455
+
456
+ if command == "/tools":
457
+ console.print(
458
+ Panel(
459
+ "[bold]search_web[/bold] — поиск свежей информации\n"
460
+ "[bold]calculate_datetime[/bold] — дата, время и точные смещения\n"
461
+ "[bold]calculate_expression[/bold] — точная арифметика\n"
462
+ "[bold]run_python_code[/bold] — проверка Python, только после подтверждения\n"
463
+ "[bold]remember/recall/forget[/bold] — постоянная память фактов\n"
464
+ "[bold]list/read_project[/bold] — безопасное чтение выбранной папки\n"
465
+ "[bold]ask_antigravity[/bold] — старшая модель для сложных задач\n\n"
466
+ "[dim]Просто сформулируй задачу: модель сама выберет инструмент.[/dim]",
467
+ title="Инструменты",
468
+ border_style="magenta",
469
+ )
470
+ )
471
+ continue
472
+
473
+ if command == "/workspace":
474
+ if not argument:
475
+ console.print(
476
+ f"[dim]Текущая папка: {config.ACTIVE_FILE_ROOT or 'не выбрана'}[/dim]\n"
477
+ "Используй: [cyan]/workspace C:\\путь\\к\\проекту[/cyan] "
478
+ "или [cyan]/workspace off[/cyan]"
479
+ )
480
+ continue
481
+ if argument.casefold() in {"off", "none", "выкл"}:
482
+ config.ACTIVE_FILE_ROOT = None
483
+ console.print("[green]Доступ к папке проекта отключён.[/green]")
484
+ continue
485
+ try:
486
+ selected_root = set_file_workspace(argument.strip('"'))
487
+ console.print(
488
+ f"[green]Папка проекта выбрана только для чтения:[/green] {selected_root}"
489
+ )
490
+ except Exception as error:
491
+ console.print(f"[red]Не удалось выбрать папку: {error}[/red]")
492
+ continue
493
+
494
+ if command == "/agy":
495
+ if not argument:
496
+ console.print("[dim]Используй: /agy задача для старшей модели[/dim]")
497
+ continue
498
+ started_ns = time.perf_counter_ns()
499
+ try:
500
+ result = run_agy_for_user(argument, messages)
501
+ except KeyboardInterrupt:
502
+ console.print("\n[yellow]Antigravity остановлена пользователем.[/yellow]")
503
+ continue
504
+ messages.append({"role": "user", "content": argument})
505
+ messages.append({"role": "assistant", "content": result})
506
+ save_current_chat(system_prompt, messages)
507
+ last_response = result
508
+ last_stats = direct_tool_stats("Antigravity", started_ns)
509
+ add_to_totals(totals, last_stats)
510
+ if stats_enabled:
511
+ print_stats(last_stats, totals)
512
+ continue
513
+
514
+ if command == "/think":
515
+ value = argument.lower()
516
+ if not value:
517
+ think_mode = False if think_mode else True
518
+ elif value in {"off", "false", "0"}:
519
+ think_mode = False
520
+ elif value in {"on", "true", "1"}:
521
+ think_mode = True
522
+ elif value in {"low", "medium", "high"}:
523
+ think_mode = value
524
+ else:
525
+ console.print("[red]Используй: /think off|on|low|medium|high[/red]")
526
+ continue
527
+ label = "выключен" if not think_mode else str(think_mode)
528
+ console.print(f"[green]Thinking: {label}.[/green]")
529
+ continue
530
+
531
+ if command == "/status":
532
+ print_status(model, think_mode, stats_enabled, messages)
533
+ continue
534
+
535
+ if command == "/memory":
536
+ turns = sum(item["role"] == "user" for item in messages)
537
+ facts = load_facts()
538
+ fact_preview = "\n".join(
539
+ f"• {item['fact']}" for item in facts[-10:]
540
+ ) or "Постоянных фактов пока нет."
541
+ console.print(
542
+ Panel(
543
+ f"Сохранено сообщений пользователя: [bold]{turns}[/bold]\n"
544
+ f"Лимит истории: [bold]{MAX_SAVED_MESSAGES} сообщений[/bold]\n"
545
+ f"Постоянных фактов: [bold]{len(facts)}[/bold]\n"
546
+ f"Файл диалога: {CURRENT_CHAT_PATH}\n"
547
+ f"Файл фактов: {FACTS_PATH}\n\n"
548
+ f"{fact_preview}\n\n"
549
+ "[dim]/new очищает диалог, но не постоянные факты; "
550
+ "/forget текст удаляет факт[/dim]",
551
+ title="Постоянная память",
552
+ border_style="cyan",
553
+ )
554
+ )
555
+ continue
556
+
557
+ if command == "/forget":
558
+ result = forget_facts(argument)
559
+ render_message(result, role="notice" if result.startswith("Ошибка") else "assistant")
560
+ continue
561
+
562
+ if command == "/system":
563
+ if not argument:
564
+ console.print(Panel(system_prompt, title="System prompt"))
565
+ else:
566
+ system_prompt = argument
567
+ messages[0] = {"role": "system", "content": system_prompt}
568
+ ACTIVE_SESSION_DATA["system_prompt"] = system_prompt
569
+ save_current_chat(system_prompt, messages)
570
+ console.print("[green]Системная инструкция обновлена.[/green]")
571
+ continue
572
+
573
+ if command == "/copy":
574
+ if not last_response:
575
+ console.print("[dim]Ответа для копирования пока нет.[/dim]")
576
+ continue
577
+ try:
578
+ copy_to_clipboard(last_response)
579
+ console.print("[green]Последний ответ скопирован.[/green]")
580
+ except Exception as error:
581
+ console.print(f"[red]Не удалось скопировать: {error}[/red]")
582
+ continue
583
+
584
+ if command == "/testpy":
585
+ code_to_test = argument or extract_latest_python_code(messages)
586
+ if not code_to_test:
587
+ console.print("[yellow]В диалоге не найдено исполняемого Python-кода для тестирования.[/yellow]")
588
+ console.print("[dim]Подсказка: вы можете передать код напрямую: /testpy print('hello')[/dim]")
589
+ continue
590
+
591
+ lines_count = len(code_to_test.splitlines())
592
+ first_line = code_to_test.splitlines()[0][:60]
593
+ console.print(f"[dim]Тестирую Python-код ({lines_count} строк: '{first_line}')...[/dim]")
594
+
595
+ exit_code, log_content = run_interactive_python_test(code_to_test)
596
+ if exit_code == 0:
597
+ console.print("[green]✓ Тестирование завершено (код 0). Результат сохранён в диалог.[/green]")
598
+ else:
599
+ console.print(f"[yellow]⚠ Тестирование завершилось с кодом {exit_code}. Результат сохранён в диалог.[/yellow]")
600
+
601
+ session_snippet = log_content[:6000] if log_content else "Скрипт выполнился без вывода в консоль"
602
+ messages.append({
603
+ "role": "user",
604
+ "content": (
605
+ f"[Системный отчёт: пользователь запустил /testpy в отдельной консоли для проверки последнего кода]\n"
606
+ f"Код завершения: {exit_code}\n"
607
+ f"Лог взаимодействия в консоли:\n```\n{session_snippet}\n```\n"
608
+ f"(Если в логе ошибка или неверный результат — разбери причину и предложи исправление; если всё прошло успешно — подтверди)."
609
+ )
610
+ })
611
+ messages.append({
612
+ "role": "assistant",
613
+ "content": f"Лог тестирования получен (код {exit_code}). Если нужно исправить логику или ошибки ввода — напиши!"
614
+ })
615
+ save_current_chat(system_prompt, messages)
616
+ last_response = messages[-1]["content"]
617
+ continue
618
+
619
+ if command == "/save":
620
+ try:
621
+ path = save_chat(messages, argument)
622
+ console.print(f"[green]Диалог сохранён:[/green] {path}")
623
+ except Exception as error:
624
+ console.print(f"[red]Не удалось сохранить: {error}[/red]")
625
+ continue
626
+
627
+ if command == "/undo":
628
+ removed = False
629
+ if messages and messages[-1]["role"] == "assistant":
630
+ messages.pop()
631
+ removed = True
632
+ if messages and messages[-1]["role"] == "user":
633
+ messages.pop()
634
+ removed = True
635
+ last_response = next(
636
+ (
637
+ item["content"]
638
+ for item in reversed(messages)
639
+ if item["role"] == "assistant"
640
+ ),
641
+ "",
642
+ )
643
+ console.print(
644
+ "[green]Последний ход удалён.[/green]"
645
+ if removed
646
+ else "[dim]Удалять нечего.[/dim]"
647
+ )
648
+ if removed:
649
+ save_current_chat(system_prompt, messages)
650
+ continue
651
+
652
+ fact_to_remember = extract_remember_request(prompt)
653
+ fact_to_forget = extract_forget_request(prompt)
654
+ if fact_to_remember or fact_to_forget:
655
+ started_ns = time.perf_counter_ns()
656
+ result = (
657
+ remember_fact(fact_to_remember)
658
+ if fact_to_remember
659
+ else forget_facts(fact_to_forget)
660
+ )
661
+ console.print()
662
+ render_message("Память ›", role="assistant_label")
663
+ render_message(result, role="assistant")
664
+ messages.append({"role": "user", "content": prompt})
665
+ messages.append({"role": "assistant", "content": result})
666
+ save_current_chat(system_prompt, messages)
667
+ last_response = result
668
+ last_stats = direct_tool_stats("memory", started_ns)
669
+ add_to_totals(totals, last_stats)
670
+ if stats_enabled:
671
+ print_stats(last_stats, totals)
672
+ continue
673
+
674
+ if looks_like_memory_question(prompt):
675
+ memory_answer = recall_facts(prompt)
676
+ if "нет подходящего факта" not in memory_answer and not memory_answer.endswith("пуста."):
677
+ started_ns = time.perf_counter_ns()
678
+ console.print()
679
+ render_message("Память ›", role="assistant_label")
680
+ render_message(memory_answer, role="assistant")
681
+ messages.append({"role": "user", "content": prompt})
682
+ messages.append({"role": "assistant", "content": memory_answer})
683
+ save_current_chat(system_prompt, messages)
684
+ last_response = memory_answer
685
+ last_stats = direct_tool_stats("memory", started_ns)
686
+ add_to_totals(totals, last_stats)
687
+ if stats_enabled:
688
+ print_stats(last_stats, totals)
689
+ continue
690
+
691
+ if command != "/retry" and should_auto_escalate(prompt):
692
+ started_ns = time.perf_counter_ns()
693
+ try:
694
+ result = run_agy_for_user(prompt, messages)
695
+ except KeyboardInterrupt:
696
+ console.print("\n[yellow]Antigravity остановлена пользователем.[/yellow]")
697
+ continue
698
+ messages.append({"role": "user", "content": prompt})
699
+ messages.append({"role": "assistant", "content": result})
700
+ save_current_chat(system_prompt, messages)
701
+ last_response = result
702
+ last_stats = direct_tool_stats("Antigravity", started_ns)
703
+ add_to_totals(totals, last_stats)
704
+ if stats_enabled:
705
+ print_stats(last_stats, totals)
706
+ continue
707
+
708
+ retrying = command == "/retry"
709
+ if retrying:
710
+ if messages and messages[-1]["role"] == "assistant":
711
+ messages.pop()
712
+ if not messages or messages[-1]["role"] != "user":
713
+ console.print("[dim]Нет вопроса для повторного ответа.[/dim]")
714
+ continue
715
+ else:
716
+ messages.append({"role": "user", "content": prompt})
717
+
718
+ try:
719
+ wait_for_model_ready(model)
720
+ response, response_stats, interrupted = generate_answer(
721
+ model,
722
+ messages,
723
+ think_mode,
724
+ )
725
+ except Exception as error:
726
+ console.print(
727
+ f"\n[bold red]Ошибка Ollama:[/bold red] "
728
+ f"{type(error).__name__}: {error}"
729
+ )
730
+ continue
731
+
732
+ if interrupted:
733
+ if messages and messages[-1].get("role") == "user":
734
+ messages.pop()
735
+ save_current_chat(system_prompt, messages)
736
+ console.print("[yellow]Генерация остановлена. Запрос удалён из памяти модели (будто не писали).[/yellow]")
737
+ console.print("[dim]💡 Нажми [cyan]Ctrl+S[/cyan], чтобы вернуть отменённый запрос в строку ввода.[/dim]")
738
+ continue
739
+
740
+ if not response:
741
+ console.print("[yellow]Модель не вернула текст.[/yellow]")
742
+ continue
743
+
744
+ messages.append({"role": "assistant", "content": response})
745
+ save_current_chat(system_prompt, messages)
746
+ last_response = response
747
+ last_stats = response_stats
748
+ add_to_totals(totals, response_stats)
749
+
750
+ if stats_enabled:
751
+ print_stats(last_stats, totals)
752
+
753
+ if "```" in response and any(kw in response for kw in ("```python", "```py", "def ", "import ")):
754
+ console.print("[dim]💡 Протестировать код в отдельном окне: [cyan]/testpy[/cyan][/dim]")