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/tools.py ADDED
@@ -0,0 +1,897 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Система встроенных инструментов W1x (поиск, время, арифметика, файлы, Antigravity, Python)."""
3
+ import ast
4
+ import json
5
+ import os
6
+ import operator
7
+ import re
8
+ import shutil
9
+ import subprocess
10
+ import tempfile
11
+ from datetime import datetime, timedelta
12
+ from pathlib import Path
13
+ from urllib.parse import urlparse
14
+
15
+ from ddgs import DDGS
16
+ from rich.panel import Panel
17
+ from rich.syntax import Syntax
18
+ from rich.tree import Tree
19
+
20
+ from .config import (
21
+ ACTIVE_FILE_ROOT,
22
+ ACTIVE_AGY_PROCESS,
23
+ ACTIVE_AGY_LOCK,
24
+ AGY_TIMEOUT_SECONDS,
25
+ AGY_MAX_PROMPT_CHARS,
26
+ AGY_MAX_OUTPUT_CHARS,
27
+ )
28
+ from .terminal import (
29
+ console,
30
+ session,
31
+ render_message,
32
+ run_with_spinner,
33
+ HTML,
34
+ )
35
+ from .memory import memory
36
+
37
+
38
+ def search_web(query: str) -> str:
39
+ """Найти свежую информацию в интернете.
40
+
41
+ Args:
42
+ query: Короткий и точный поисковый запрос.
43
+
44
+ Returns:
45
+ Заголовки, фрагменты текста и адреса найденных страниц.
46
+ """
47
+ query = query.strip()
48
+ if not query:
49
+ return "Ошибка: пустой поисковый запрос."
50
+
51
+ has_cyrillic = any("а" <= char.lower() <= "я" or char.lower() == "ё" for char in query)
52
+ regions = ["ru-ru", "us-en"] if has_cyrillic else ["us-en", "ru-ru"]
53
+ results = []
54
+ seen_urls = set()
55
+ search = DDGS(timeout=12)
56
+ for region in regions:
57
+ try:
58
+ region_results = search.text(
59
+ query,
60
+ region=region,
61
+ safesearch="off",
62
+ max_results=5,
63
+ )
64
+ except Exception:
65
+ continue
66
+ for item in region_results:
67
+ url = str(item.get("href") or item.get("url") or "").strip()
68
+ if url and url not in seen_urls:
69
+ seen_urls.add(url)
70
+ results.append(item)
71
+ if len(results) >= 3:
72
+ break
73
+ if len(results) >= 3:
74
+ break
75
+ if not results:
76
+ return "Поиск не вернул результатов."
77
+
78
+ # Выводим красивое дерево найденных сайтов и сниппетов в консоль пользователя
79
+ try:
80
+ tree = Tree(f"[bold cyan]🔍 Поиск в интернете:[/bold cyan] [white]{query}[/white]")
81
+ for item in results:
82
+ url = str(item.get("href") or item.get("url") or "").strip()
83
+ domain = urlparse(url).netloc.replace("www.", "") or url
84
+ title = str(item.get("title") or domain).strip()
85
+ snippet = " ".join(str(item.get("body") or "").split()).strip()
86
+ if len(snippet) > 130:
87
+ snippet = snippet[:130] + "…"
88
+
89
+ branch = tree.add(f"[bold green]🌐 {domain}[/bold green] — [dim]{title}[/dim]")
90
+ if snippet:
91
+ branch.add(f"[dim italic]«{snippet}»[/dim italic]")
92
+
93
+ console.print()
94
+ console.print(tree)
95
+ console.print()
96
+ except Exception:
97
+ pass
98
+
99
+ lines = []
100
+ for index, item in enumerate(results, 1):
101
+ title = str(item.get("title") or "Без названия").strip()
102
+ url = str(item.get("href") or item.get("url") or "").strip()
103
+ snippet = " ".join(str(item.get("body") or "").split())[:300]
104
+ lines.append(f"{index}. {title}\nURL: {url}\n{snippet}")
105
+ return "\n\n".join(lines)
106
+
107
+
108
+ WEEKDAYS_RU = [
109
+ "понедельник", "вторник", "среда", "четверг", "пятница", "суббота", "воскресенье",
110
+ ]
111
+ MONTHS_RU = [
112
+ "января", "февраля", "марта", "апреля", "мая", "июня",
113
+ "июля", "августа", "сентября", "октября", "ноября", "декабря",
114
+ ]
115
+
116
+
117
+ def _plural_ru(value, one, few, many):
118
+ value = abs(int(value))
119
+ if value % 10 == 1 and value % 100 != 11:
120
+ return one
121
+ if value % 10 in {2, 3, 4} and value % 100 not in {12, 13, 14}:
122
+ return few
123
+ return many
124
+
125
+
126
+ def _format_datetime_ru(value):
127
+ return (
128
+ f"{WEEKDAYS_RU[value.weekday()]}, {value.day} "
129
+ f"{MONTHS_RU[value.month - 1]} {value.year} года, "
130
+ f"{value:%H:%M:%S}"
131
+ )
132
+
133
+
134
+ def _format_offset(days, hours, minutes, seconds):
135
+ parts = []
136
+ if days:
137
+ parts.append(f"{abs(days)} {_plural_ru(days, 'день', 'дня', 'дней')}")
138
+ if hours:
139
+ parts.append(f"{abs(hours)} {_plural_ru(hours, 'час', 'часа', 'часов')}")
140
+ if minutes:
141
+ parts.append(f"{abs(minutes)} {_plural_ru(minutes, 'минута', 'минуты', 'минут')}")
142
+ if seconds:
143
+ parts.append(f"{abs(seconds)} {_plural_ru(seconds, 'секунда', 'секунды', 'секунд')}")
144
+ return " ".join(parts) or "0 минут"
145
+
146
+
147
+ def calculate_datetime(
148
+ days: int = 0,
149
+ hours: int = 0,
150
+ minutes: int = 0,
151
+ seconds: int = 0,
152
+ base_datetime: str = "",
153
+ ) -> str:
154
+ """Точно получить текущую дату/время или вычислить смещение.
155
+
156
+ Args:
157
+ days: Количество дней вперёд (+) или назад (-).
158
+ hours: Количество часов вперёд (+) или назад (-).
159
+ minutes: Количество минут вперёд (+) или назад (-).
160
+ seconds: Количество секунд вперёд (+) или назад (-).
161
+ base_datetime: Необязательная исходная дата ISO 8601. Если пусто — сейчас.
162
+
163
+ Returns:
164
+ JSON с готовым полем answer, которое нельзя пересчитывать или изменять.
165
+ """
166
+ days = int(days)
167
+ hours = int(hours)
168
+ minutes = int(minutes)
169
+ seconds = int(seconds)
170
+ if (
171
+ abs(days) > 365000
172
+ or abs(hours) > 1_000_000
173
+ or abs(minutes) > 10_000_000
174
+ or abs(seconds) > 600_000_000
175
+ ):
176
+ return json.dumps(
177
+ {"error": "Слишком большое смещение даты или времени."},
178
+ ensure_ascii=False,
179
+ )
180
+
181
+ system_now = datetime.now().astimezone().replace(microsecond=0)
182
+ if base_datetime:
183
+ try:
184
+ now = datetime.fromisoformat(str(base_datetime).strip())
185
+ if now.tzinfo is None:
186
+ now = now.replace(tzinfo=system_now.tzinfo)
187
+ now = now.replace(microsecond=0)
188
+ except ValueError:
189
+ return json.dumps(
190
+ {"error": "Неправильная исходная дата. Используй ISO 8601."},
191
+ ensure_ascii=False,
192
+ )
193
+ else:
194
+ now = system_now
195
+ delta = timedelta(
196
+ days=days,
197
+ hours=hours,
198
+ minutes=minutes,
199
+ seconds=seconds,
200
+ )
201
+ result = now + delta
202
+ offset = now.strftime("%z")
203
+ offset = f"{offset[:3]}:{offset[3:]}" if len(offset) == 5 else offset
204
+
205
+ current_text = _format_datetime_ru(now)
206
+ base_label = "Исходный момент" if base_datetime else "Сейчас"
207
+ if delta == timedelta(0):
208
+ answer = f"{base_label}: {current_text}."
209
+ else:
210
+ direction = "назад" if delta.total_seconds() < 0 else "вперёд"
211
+ offset_text = _format_offset(days, hours, minutes, seconds)
212
+ answer = (
213
+ f"{base_label}: {current_text}.\n"
214
+ f"{offset_text.capitalize()} {direction}: "
215
+ f"{_format_datetime_ru(result)}."
216
+ )
217
+
218
+ return json.dumps(
219
+ {
220
+ "current": now.isoformat(),
221
+ "offset": {
222
+ "days": days,
223
+ "hours": hours,
224
+ "minutes": minutes,
225
+ "seconds": seconds,
226
+ },
227
+ "result": result.isoformat(),
228
+ "timezone": "локальный часовой пояс системы",
229
+ "utc_offset": offset,
230
+ "answer": answer,
231
+ "instruction": "Вернуть поле answer дословно без пересчёта.",
232
+ },
233
+ ensure_ascii=False,
234
+ )
235
+
236
+
237
+ _TIME_VALUE_RE = re.compile(
238
+ r"(?P<value>[+-]?\d+)\s*"
239
+ r"(?P<unit>"
240
+ r"д(?:ень|ня|ней)|дн(?:я|ей)?|"
241
+ r"час(?:а|ов)?|ч|"
242
+ r"минут(?:а|ы|у)?|мин|м|"
243
+ r"секунд(?:а|ы|у)?|сек"
244
+ r")(?=\s|$|[,.!?])",
245
+ re.IGNORECASE,
246
+ )
247
+ _MONTH_NUMBER_RU = {name: index + 1 for index, name in enumerate(MONTHS_RU)}
248
+
249
+
250
+ def _extract_base_datetime(text):
251
+ normalized = text.lower().replace("ё", "е")
252
+ time_match = re.search(r"\b([01]?\d|2[0-3]):([0-5]\d)(?::([0-5]\d))?\b", normalized)
253
+ month_pattern = "|".join(_MONTH_NUMBER_RU)
254
+ date_match = re.search(
255
+ rf"\b([0-3]?\d)\s+({month_pattern})(?:\s+(20\d{{2}}))?\b",
256
+ normalized,
257
+ )
258
+ if not time_match and not date_match:
259
+ return ""
260
+ if not date_match and not re.search(r"\b(?:если\s+)?сейчас\b", normalized):
261
+ return ""
262
+ current = datetime.now().astimezone().replace(microsecond=0)
263
+ day = int(date_match.group(1)) if date_match else current.day
264
+ month = _MONTH_NUMBER_RU[date_match.group(2)] if date_match else current.month
265
+ year = int(date_match.group(3)) if date_match and date_match.group(3) else current.year
266
+ hour = int(time_match.group(1)) if time_match else 0
267
+ minute = int(time_match.group(2)) if time_match else 0
268
+ second = int(time_match.group(3) or 0) if time_match else 0
269
+ try:
270
+ base = datetime(year, month, day, hour, minute, second, tzinfo=current.tzinfo)
271
+ except ValueError:
272
+ return ""
273
+ return base.isoformat()
274
+
275
+
276
+ _DIRECT_TIME_WORDS = {
277
+ *_MONTH_NUMBER_RU.keys(),
278
+ "а", "будет", "будут", "будущий", "бы", "был", "была", "было", "были",
279
+ "в", "вперед", "времени", "время", "вычти", "дата", "дату", "день",
280
+ "дней", "дня", "добавь", "если", "и", "какая", "какой", "какое", "какие",
281
+ "который", "год", "месяц", "м", "мин", "минус", "минута", "минут",
282
+ "минуты", "минуту", "на", "назад", "недели", "неделя", "от", "отмотай",
283
+ "отмотать", "плюс", "пожалуйста", "покажи", "посчитай", "прибавь",
284
+ "привет", "скажи", "сколько", "сейчас", "сек", "секунда", "секунд",
285
+ "секунды", "секунду", "сегодня", "тому", "теперь", "точно", "точное",
286
+ "точный", "ровно", "ответь", "назови", "ч", "час", "часа", "часов",
287
+ "через", "число", "что",
288
+ }
289
+
290
+
291
+ def _is_pure_datetime_request(normalized):
292
+ words = re.findall(r"[a-zа-я]+", normalized)
293
+ return bool(words) and all(word in _DIRECT_TIME_WORDS for word in words)
294
+
295
+
296
+ def parse_datetime_request(text, require_pure=True):
297
+ normalized = " ".join(text.lower().replace("ё", "е").split())
298
+ if len(normalized) > 240:
299
+ return None
300
+ if require_pure and not _is_pure_datetime_request(normalized):
301
+ return None
302
+
303
+ matches = list(_TIME_VALUE_RE.finditer(normalized))
304
+ forward = bool(re.search(r"\b(вперед|через|прибав|добав|плюс)\w*", normalized))
305
+ backward = bool(re.search(r"\b(назад|отмот|вычт|минус)\w*", normalized))
306
+
307
+ if matches and (forward or backward or any(
308
+ match.group("value").startswith(("+", "-")) for match in matches
309
+ )):
310
+ direction = 1 if forward else -1 if backward else 1
311
+ values = {"days": 0, "hours": 0, "minutes": 0, "seconds": 0}
312
+ for match in matches:
313
+ raw_value = int(match.group("value"))
314
+ value = abs(raw_value) * direction if (forward or backward) else raw_value
315
+ unit = match.group("unit")
316
+ if unit.startswith("д"):
317
+ values["days"] += value
318
+ elif unit.startswith("ч") or unit.startswith("час"):
319
+ values["hours"] += value
320
+ elif unit.startswith("м"):
321
+ values["minutes"] += value
322
+ else:
323
+ values["seconds"] += value
324
+ base_datetime = _extract_base_datetime(text)
325
+ if base_datetime:
326
+ values["base_datetime"] = base_datetime
327
+ return values
328
+
329
+ bare_offset = re.fullmatch(
330
+ r"(?:а\s+)?(?:если\s+)?(\d+)\s*"
331
+ r"(назад|вперед)[?!.]*",
332
+ normalized,
333
+ )
334
+ if bare_offset:
335
+ hours = int(bare_offset.group(1))
336
+ if bare_offset.group(2) == "назад":
337
+ hours = -hours
338
+ result = {"days": 0, "hours": hours, "minutes": 0, "seconds": 0}
339
+ base_datetime = _extract_base_datetime(text)
340
+ if base_datetime:
341
+ result["base_datetime"] = base_datetime
342
+ return result
343
+
344
+ current_time_patterns = [
345
+ r"\bкоторый\s+час\b",
346
+ r"\bсколько\s+(?:сейчас\s+)?времени\b",
347
+ r"\bсколько\s+сейчас\b",
348
+ r"\bкакая\s+(?:сейчас|сегодня)\s+дата\b",
349
+ r"\bкакой\s+сегодня\s+день\b",
350
+ r"\bскажи\s+(?:сегодняшнюю\s+)?дату\b",
351
+ r"\bсегодняшняя\s+дата\b",
352
+ ]
353
+ if any(re.search(pattern, normalized) for pattern in current_time_patterns):
354
+ return {"days": 0, "hours": 0, "minutes": 0, "seconds": 0}
355
+ return None
356
+
357
+
358
+ def split_datetime_request(text):
359
+ arguments = parse_datetime_request(text, require_pure=False)
360
+ if arguments is None:
361
+ return None, text
362
+ if parse_datetime_request(text, require_pure=True) is not None:
363
+ return arguments, ""
364
+
365
+ remaining = []
366
+ removed_time_clause = False
367
+ previous_was_time = False
368
+ for piece in re.findall(r"[^?!]+[?!]*", text):
369
+ stripped = piece.strip()
370
+ if not stripped:
371
+ continue
372
+ piece_arguments = parse_datetime_request(stripped, require_pure=True)
373
+ normalized_piece = " ".join(
374
+ stripped.lower().replace("ё", "е").split()
375
+ )
376
+ is_time_format_tail = (
377
+ previous_was_time
378
+ and _is_pure_datetime_request(normalized_piece)
379
+ )
380
+ if piece_arguments is not None or is_time_format_tail:
381
+ removed_time_clause = True
382
+ previous_was_time = True
383
+ continue
384
+ remaining.append(stripped)
385
+ previous_was_time = False
386
+
387
+ if not removed_time_clause:
388
+ cleanup_patterns = [
389
+ r"\bкакая\s+сегодня\s+дата\b",
390
+ r"\bкакое\s+сейчас\s+время\b",
391
+ r"\bсколько\s+сейчас\s+времени\b",
392
+ r"\bскажи\s+(?:сегодняшнюю\s+)?дату\b",
393
+ ]
394
+ cleaned = text
395
+ for pattern in cleanup_patterns:
396
+ cleaned = re.sub(pattern, "", cleaned, flags=re.IGNORECASE)
397
+ cleaned = re.sub(r"^\s*[,;:-]?\s*(?:и\s+)?", "", cleaned).strip()
398
+ if cleaned != text.strip():
399
+ return arguments, cleaned
400
+ return None, text
401
+ return arguments, " ".join(remaining).strip()
402
+
403
+
404
+ def datetime_answer(arguments):
405
+ raw_result = calculate_datetime(**arguments)
406
+ try:
407
+ payload = json.loads(raw_result)
408
+ except json.JSONDecodeError:
409
+ return raw_result
410
+ return payload.get("answer") or payload.get("error") or raw_result
411
+
412
+
413
+ _BINARY_OPERATORS = {
414
+ ast.Add: operator.add,
415
+ ast.Sub: operator.sub,
416
+ ast.Mult: operator.mul,
417
+ ast.Div: operator.truediv,
418
+ ast.FloorDiv: operator.floordiv,
419
+ ast.Mod: operator.mod,
420
+ ast.Pow: operator.pow,
421
+ }
422
+ _UNARY_OPERATORS = {ast.UAdd: operator.pos, ast.USub: operator.neg}
423
+
424
+
425
+ def _evaluate_math_node(node):
426
+ if isinstance(node, ast.Expression):
427
+ return _evaluate_math_node(node.body)
428
+ if isinstance(node, ast.Constant) and type(node.value) in {int, float}:
429
+ return node.value
430
+ if isinstance(node, ast.UnaryOp) and type(node.op) in _UNARY_OPERATORS:
431
+ return _UNARY_OPERATORS[type(node.op)](_evaluate_math_node(node.operand))
432
+ if isinstance(node, ast.BinOp) and type(node.op) in _BINARY_OPERATORS:
433
+ left = _evaluate_math_node(node.left)
434
+ right = _evaluate_math_node(node.right)
435
+ if isinstance(node.op, ast.Pow) and abs(right) > 1000:
436
+ raise ValueError("Слишком большая степень")
437
+ return _BINARY_OPERATORS[type(node.op)](left, right)
438
+ raise ValueError("Разрешены только числа, скобки и операции + - * / // % **")
439
+
440
+
441
+ def calculate_expression(expression: str) -> str:
442
+ """Точно вычислить арифметическое выражение без приблизительного счёта.
443
+
444
+ Args:
445
+ expression: Выражение с числами, скобками и операциями + - * / // % **.
446
+
447
+ Returns:
448
+ Точный результат вычисления или описание ошибки.
449
+ """
450
+ expression = expression.strip()
451
+ if not expression or len(expression) > 300:
452
+ return "Ошибка: выражение пустое или слишком длинное."
453
+ try:
454
+ tree = ast.parse(expression, mode="eval")
455
+ return str(_evaluate_math_node(tree))
456
+ except Exception as error:
457
+ return f"Ошибка вычисления: {type(error).__name__}: {error}"
458
+
459
+
460
+ _MATH_ALLOWED_WORDS = {
461
+ "а", "будет", "вычисли", "вычислить", "калькулятором", "математикой",
462
+ "пожалуйста", "посчитай", "проверь", "результат", "сколько", "точно",
463
+ "чему", "равно", "равен", "теперь",
464
+ }
465
+
466
+
467
+ def parse_math_request(text):
468
+ normalized = text.lower().replace("×", "*").replace("÷", "/")
469
+ if re.search(r"не\s+(?:используй|вызывай).*калькулятор", normalized):
470
+ return None
471
+ candidates = re.findall(r"(?<![\w.])[\d\s().,+\-*/%]{3,}(?![\w.])", normalized)
472
+ candidates = [candidate.strip(" ,.") for candidate in candidates]
473
+ candidates = [
474
+ candidate
475
+ for candidate in candidates
476
+ if re.search(r"[+\-*/%]", candidate) and re.search(r"\d", candidate)
477
+ ]
478
+ if not candidates:
479
+ return None
480
+ expression = max(candidates, key=len).replace(",", ".").strip()
481
+ remaining = normalized.replace(max(candidates, key=len), " ")
482
+ words = set(re.findall(r"[a-zа-яё]+", remaining))
483
+ if words and not words <= _MATH_ALLOWED_WORDS:
484
+ return None
485
+ return expression
486
+
487
+
488
+ def previous_math_expression(messages):
489
+ for message in reversed(messages):
490
+ if message.get("role") != "user":
491
+ continue
492
+ expression = parse_math_request(message.get("content", ""))
493
+ if expression:
494
+ return expression
495
+ return None
496
+
497
+
498
+ def needs_clarification(text):
499
+ normalized = " ".join(text.casefold().strip(" .!?,-").split())
500
+ vague_patterns = [
501
+ r"(?:сделай|повтори|реши|запусти|удали)(?:\s+(?:это|там|так))?(?:\s+как\s+(?:вчера|раньше))?",
502
+ r"(?:я\s+же\s+)?(?:сказал|писал)\s+уже",
503
+ r"сделай\s+как\s+(?:вчера|раньше)",
504
+ ]
505
+ return len(normalized) < 80 and any(
506
+ re.fullmatch(pattern, normalized) for pattern in vague_patterns
507
+ )
508
+
509
+
510
+ def run_python_code(code: str, purpose: str, inputs: str = "") -> str:
511
+ """Проверить небольшой фрагмент Python-кода на компьютере пользователя.
512
+
513
+ Args:
514
+ code: Полный Python-код для проверки.
515
+ purpose: Коротко: что проверяет код и зачем это нужно для ответа.
516
+ inputs: Необязательно: текстовые строки для input(), через \n (например: "2\n*\n5\n").
517
+
518
+ Returns:
519
+ Код завершения, stdout и stderr программы.
520
+ """
521
+ if not code.strip():
522
+ return "Ошибка: модель передала пустой код."
523
+ if len(code) > 8000:
524
+ return "Ошибка: код длиннее 8000 символов и не будет запущен."
525
+
526
+ purpose = " ".join(str(purpose or "").split())
527
+ if not purpose:
528
+ return "Ошибка: модель не объяснила, зачем ей запускать Python-код."
529
+
530
+ console.print()
531
+ render_message("Зачем нужен Python ›", role="assistant_label")
532
+ render_message(purpose[:600], role="assistant")
533
+ if inputs:
534
+ formatted_inputs = ", ".join(repr(line) for line in str(inputs).splitlines() if line)
535
+ render_message(f"Входные данные для input() › {formatted_inputs}", role="assistant_label")
536
+ console.print()
537
+ console.print(
538
+ Panel(
539
+ Syntax(code, "python", theme="monokai", word_wrap=True, padding=0),
540
+ title="Python просит разрешение на запуск",
541
+ border_style="yellow",
542
+ )
543
+ )
544
+ console.print(
545
+ "[green]Нажми [bold]Enter[/bold] для запуска. Чтобы отклонить — введи [bold]n[/bold] или [bold]нет[/bold].[/green]"
546
+ )
547
+ try:
548
+ confirmation = session.prompt(
549
+ HTML("<b><ansigreen>Разрешить запуск? [Y/n] › </ansigreen></b>"),
550
+ multiline=False,
551
+ ).strip().lower()
552
+ except (KeyboardInterrupt, EOFError):
553
+ confirmation = "n"
554
+ if confirmation in {"n", "no", "н", "нет", "cancel", "отмена"}:
555
+ return "Пользователь отказался запускать код."
556
+
557
+ try:
558
+ with tempfile.TemporaryDirectory(prefix="ollama-python-") as temp_dir:
559
+ input_bytes = (str(inputs) + "\n").encode("utf-8") if inputs else None
560
+ completed = subprocess.run(
561
+ [sys.executable, "-I", "-X", "utf8", "-c", code],
562
+ input=input_bytes,
563
+ cwd=temp_dir,
564
+ capture_output=True,
565
+ timeout=10,
566
+ check=False,
567
+ creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
568
+ )
569
+ stdout = completed.stdout.decode("utf-8", errors="replace")[-12000:].rstrip()
570
+ stderr = completed.stderr.decode("utf-8", errors="replace")[-12000:].rstrip()
571
+ return_code = completed.returncode
572
+ except subprocess.TimeoutExpired as error:
573
+ stdout_err = (
574
+ (error.stdout.decode("utf-8", errors="replace") if error.stdout else "")
575
+ + (error.stderr.decode("utf-8", errors="replace") if error.stderr else "")
576
+ )[-12000:]
577
+ return f"Выполнение остановлено через 10 секунд.\n{stdout_err}"
578
+ except Exception as error:
579
+ return f"Ошибка запуска: {type(error).__name__}: {error}"
580
+
581
+ parts = [f"Код завершения: {return_code}"]
582
+ if stdout:
583
+ parts.append(f"stdout:\n{stdout}")
584
+ if stderr:
585
+ parts.append(f"stderr:\n{stderr}")
586
+ if not stdout and not stderr:
587
+ if return_code == 0:
588
+ parts.append(
589
+ "Программа завершилась успешно (код 0), но в stdout ничего не выведено "
590
+ "(код выполнился без ошибок, но без явного вызова print())."
591
+ )
592
+ else:
593
+ parts.append("Программа завершилась с ошибкой (код != 0) без вывода в stdout.")
594
+ return "\n\n".join(parts)
595
+
596
+
597
+ def set_file_workspace(path_text):
598
+ global ACTIVE_FILE_ROOT
599
+ from . import config
600
+ path = Path(path_text).expanduser().resolve(strict=True)
601
+ if not path.is_dir():
602
+ raise ValueError("Указанный путь не является папкой.")
603
+ config.ACTIVE_FILE_ROOT = path
604
+ return path
605
+
606
+
607
+ def _safe_workspace_path(relative_path):
608
+ from .config import ACTIVE_FILE_ROOT
609
+ if ACTIVE_FILE_ROOT is None:
610
+ raise ValueError("Папка проекта не выбрана. Используй /workspace ПУТЬ.")
611
+ raw_path = Path(str(relative_path or "."))
612
+ candidate = (
613
+ raw_path.resolve(strict=False)
614
+ if raw_path.is_absolute()
615
+ else (ACTIVE_FILE_ROOT / raw_path).resolve(strict=False)
616
+ )
617
+ try:
618
+ candidate.relative_to(ACTIVE_FILE_ROOT)
619
+ except ValueError as error:
620
+ raise ValueError("Путь выходит за пределы выбранной папки проекта.") from error
621
+ return candidate
622
+
623
+
624
+ def list_project_files(relative_path: str = ".") -> str:
625
+ from .config import ACTIVE_FILE_ROOT
626
+ try:
627
+ directory = _safe_workspace_path(relative_path)
628
+ except Exception as error:
629
+ return f"Ошибка доступа: {error}"
630
+ if not directory.exists() or not directory.is_dir():
631
+ return "Ошибка: папка не существует."
632
+ files = []
633
+ for path in directory.rglob("*"):
634
+ try:
635
+ resolved = path.resolve(strict=True)
636
+ resolved.relative_to(ACTIVE_FILE_ROOT)
637
+ except (OSError, ValueError):
638
+ continue
639
+ if path.is_file():
640
+ files.append(str(path.relative_to(ACTIVE_FILE_ROOT)))
641
+ if len(files) >= 200:
642
+ break
643
+ if not files:
644
+ return "В выбранной папке файлов не найдено."
645
+ suffix = "\n… список ограничен 200 файлами" if len(files) == 200 else ""
646
+ return "Файлы проекта:\n" + "\n".join(f"- {item}" for item in files) + suffix
647
+
648
+
649
+ def read_project_file(relative_path: str) -> str:
650
+ from .config import ACTIVE_FILE_ROOT
651
+ try:
652
+ path = _safe_workspace_path(relative_path)
653
+ except Exception as error:
654
+ return f"Ошибка доступа: {error}"
655
+ if not path.exists() or not path.is_file():
656
+ return "Ошибка: файл не существует."
657
+ if path.stat().st_size > 2_000_000:
658
+ return "Ошибка: файл больше 2 МБ и не будет прочитан."
659
+ try:
660
+ text = path.read_text(encoding="utf-8", errors="replace")
661
+ except OSError as error:
662
+ return f"Ошибка чтения: {error}"
663
+ if "\x00" in text[:2000]:
664
+ return "Ошибка: файл похож на бинарный."
665
+ if len(text) > 16000:
666
+ text = text[:16000] + "\n\n… файл обрезан до 16000 символов"
667
+ return f"Файл: {path.relative_to(ACTIVE_FILE_ROOT)}\n\n{text}"
668
+
669
+
670
+ def project_files(action: str, path: str = ".") -> str:
671
+ action = action.strip().casefold()
672
+ if action == "list":
673
+ return list_project_files(path)
674
+ if action == "read":
675
+ return read_project_file(path)
676
+ return "Ошибка: action должен быть list или read."
677
+
678
+
679
+ _SECRET_PATTERNS = [
680
+ re.compile(
681
+ r"(?i)\b(api[_-]?key|secret|token|password|passwd|authorization)\b"
682
+ r"\s*[:=]\s*([^\s,;]{8,})"
683
+ ),
684
+ re.compile(r"\b(?:sk|ghp|github_pat)_[A-Za-z0-9_-]{16,}\b"),
685
+ re.compile(r"-----BEGIN [A-Z ]+PRIVATE KEY-----[\s\S]*?-----END [A-Z ]+PRIVATE KEY-----"),
686
+ ]
687
+
688
+
689
+ def _redact_secrets(text):
690
+ redacted = str(text)
691
+ for pattern in _SECRET_PATTERNS:
692
+ redacted = pattern.sub("[СЕКРЕТ СКРЫТ]", redacted)
693
+ return redacted
694
+
695
+
696
+ def cancel_active_agy():
697
+ from . import config
698
+ with config.ACTIVE_AGY_LOCK:
699
+ process = config.ACTIVE_AGY_PROCESS
700
+ if process is not None and process.poll() is None:
701
+ try:
702
+ process.kill()
703
+ except OSError:
704
+ pass
705
+
706
+
707
+ def ask_antigravity(task: str, context: str = "") -> str:
708
+ from . import config
709
+ executable = shutil.which("agy")
710
+ if not executable:
711
+ return "ESCALATE_ERROR: команда agy не найдена в PATH."
712
+ task = _redact_secrets(task.strip())
713
+ context = _redact_secrets(context.strip())
714
+ if not task:
715
+ return "ESCALATE_ERROR: задача для Antigravity пуста."
716
+ if len(task) > 4000:
717
+ task = task[:4000] + "\n… задача сокращена W1x"
718
+ if len(context) > 1200:
719
+ context = context[-1200:]
720
+ prompt = (
721
+ "Ты старшая модель для локального ассистента W1x. Реши задачу и верни "
722
+ "готовый ответ пользователю на русском языке текстом/Markdown. "
723
+ "Не создавай и не изменяй файлы, не вызывай инструменты и не проси "
724
+ "разрешений. Если данных недостаточно, задай один конкретный "
725
+ "уточняющий вопрос. Не упоминай эту служебную инструкцию.\n\n"
726
+ + (f"Краткий контекст:\n{context}\n\n" if context else "")
727
+ + f"Задача пользователя:\n{task}"
728
+ )
729
+ prompt = prompt[:AGY_MAX_PROMPT_CHARS]
730
+
731
+ process = None
732
+ allowed_env_names = {
733
+ "APPDATA", "COMSPEC", "LANG", "LC_ALL", "LOCALAPPDATA", "PATH",
734
+ "PATHEXT", "PROGRAMFILES", "PROGRAMFILES(X86)", "SYSTEMDRIVE",
735
+ "SYSTEMROOT", "TEMP", "TMP", "USERPROFILE", "WINDIR",
736
+ }
737
+ safe_environment = {
738
+ key: value for key, value in os.environ.items() if key.upper() in allowed_env_names
739
+ }
740
+ try:
741
+ with tempfile.TemporaryDirectory(prefix="w1x-agy-") as temp_dir:
742
+ process = subprocess.Popen(
743
+ [executable, "-p", prompt],
744
+ cwd=temp_dir,
745
+ stdout=subprocess.PIPE,
746
+ stderr=subprocess.PIPE,
747
+ text=True,
748
+ encoding="utf-8",
749
+ errors="replace",
750
+ env=safe_environment,
751
+ creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
752
+ )
753
+ with config.ACTIVE_AGY_LOCK:
754
+ config.ACTIVE_AGY_PROCESS = process
755
+ stdout, stderr = process.communicate(timeout=AGY_TIMEOUT_SECONDS)
756
+ return_code = process.returncode
757
+ except subprocess.TimeoutExpired:
758
+ if process is not None:
759
+ process.kill()
760
+ stdout, stderr = process.communicate()
761
+ return f"ESCALATE_ERROR: Antigravity не ответила за {AGY_TIMEOUT_SECONDS} секунд."
762
+ except (OSError, PermissionError) as error:
763
+ return f"ESCALATE_ERROR: не удалось запустить agy: {error}"
764
+ finally:
765
+ with config.ACTIVE_AGY_LOCK:
766
+ if config.ACTIVE_AGY_PROCESS is process:
767
+ config.ACTIVE_AGY_PROCESS = None
768
+
769
+ output = (stdout or "").strip()
770
+ error_text = (stderr or "").strip()
771
+ combined_error = f"{output}\n{error_text}".lower()
772
+ if "permission" in combined_error and ("denied" in combined_error or "failed" in combined_error):
773
+ return "ESCALATE_ERROR: Antigravity запросила запрещённое действие; доступ не выдан."
774
+ if return_code != 0:
775
+ details = (error_text or output or "без описания")[-1200:]
776
+ return f"ESCALATE_ERROR: agy завершилась с кодом {return_code}: {details}"
777
+ if not output:
778
+ return "ESCALATE_ERROR: Antigravity не вернула текст."
779
+ ansi_escape = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
780
+ output = ansi_escape.sub("", output)
781
+ if len(output) > AGY_MAX_OUTPUT_CHARS:
782
+ output = output[:AGY_MAX_OUTPUT_CHARS]
783
+ if output.count("```") % 2:
784
+ output += "\n```"
785
+ output += "\n\n> ⚠️ Ответ Antigravity обрезан по лимиту W1x."
786
+ return output
787
+
788
+
789
+ def should_auto_escalate(text):
790
+ normalized = " ".join(text.casefold().split())
791
+ if re.search(r"\b(?:agy|антигравити)\b", normalized):
792
+ return True
793
+ explicit = re.search(
794
+ r"\b(?:передай|эскалируй|спроси|отдай)\w*\b.*"
795
+ r"\b(?:старш|сильн|другой)\w*\s+(?:ии|модел)",
796
+ normalized,
797
+ )
798
+ if explicit:
799
+ return True
800
+ large_code_task = (
801
+ re.search(r"\b(?:проект|кодовая\s+база|репозитор)\w*\b", normalized)
802
+ and re.search(
803
+ r"\b(?:архитектур|рефактор|race\s*condition|гонк|аудит|"
804
+ r"диагност|миграц|безопасност)\w*\b",
805
+ normalized,
806
+ )
807
+ and re.search(r"\b(?:найди|исправ|спроект|проанализ|сделай|проверь)\w*\b", normalized)
808
+ )
809
+ many_files = re.search(r"\b(?:[5-9]\d|\d{3,})\s+файл", normalized)
810
+ return bool(large_code_task or many_files)
811
+
812
+
813
+ def recent_context_for_agy(messages):
814
+ recent = []
815
+ for message in messages:
816
+ if message.get("role") in {"user", "assistant"}:
817
+ recent.append(f"{message['role']}: {message.get('content', '')}")
818
+ return "\n".join(recent[-3:])[-1200:]
819
+
820
+
821
+ def run_agy_for_user(task, messages):
822
+ render_message("Передаю задачу старшей модели Antigravity", role="tool")
823
+ result = run_with_spinner(
824
+ "Antigravity думает...",
825
+ lambda: ask_antigravity(task, recent_context_for_agy(messages)),
826
+ )
827
+ render_message("Antigravity ›", role="assistant_label")
828
+ render_message(result, role="assistant")
829
+ return result
830
+
831
+
832
+ AVAILABLE_TOOLS = {
833
+ "search_web": search_web,
834
+ "calculate_datetime": calculate_datetime,
835
+ "calculate_expression": calculate_expression,
836
+ "run_python_code": run_python_code,
837
+ "memory": memory,
838
+ "project_files": project_files,
839
+ "ask_antigravity": ask_antigravity,
840
+ }
841
+ TOOL_FUNCTIONS = list(AVAILABLE_TOOLS.values())
842
+
843
+ DIRECT_PASSTHROUGH_TOOLS = {
844
+ "calculate_expression",
845
+ "project_files",
846
+ "ask_antigravity",
847
+ }
848
+
849
+
850
+ def format_direct_tool_result(name, result):
851
+ if name == "calculate_datetime":
852
+ try:
853
+ payload = json.loads(result)
854
+ return payload.get("answer") or result
855
+ except json.JSONDecodeError:
856
+ return result
857
+ return result
858
+
859
+
860
+ def execute_tool_call(call):
861
+ name = call.function.name
862
+ arguments = call.function.arguments or {}
863
+ if isinstance(arguments, str):
864
+ try:
865
+ arguments = json.loads(arguments)
866
+ except json.JSONDecodeError:
867
+ return f"Инструмент {name} получил неправильные аргументы: {arguments}"
868
+ if not isinstance(arguments, dict):
869
+ return f"Инструмент {name} получил аргументы неправильного типа."
870
+ function = AVAILABLE_TOOLS.get(name)
871
+ if function is None:
872
+ return f"Неизвестный инструмент: {name}"
873
+
874
+ labels = {
875
+ "search_web": f"Ищу в интернете: {arguments.get('query', '')}",
876
+ "calculate_datetime": "Вычисляю точные дату и время",
877
+ "calculate_expression": f"Считаю: {arguments.get('expression', '')}",
878
+ "run_python_code": "Модель хочет проверить Python-код",
879
+ "memory": f"Постоянная память: {arguments.get('action', '')}",
880
+ "project_files": f"Файлы проекта, только чтение: {arguments.get('action', '')}",
881
+ "ask_antigravity": "Передаю сложную задачу Antigravity",
882
+ }
883
+ console.print()
884
+ render_message(labels.get(name, name), role="tool")
885
+ try:
886
+ if name == "ask_antigravity":
887
+ return str(
888
+ run_with_spinner(
889
+ "Antigravity думает...",
890
+ lambda: function(**arguments),
891
+ )
892
+ )
893
+ return str(function(**arguments))
894
+ except KeyboardInterrupt:
895
+ return "Вызов инструмента остановлен пользователем."
896
+ except Exception as error:
897
+ return f"Ошибка инструмента {name}: {type(error).__name__}: {error}"