gigacrew 0.5.0__tar.gz

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.
Files changed (68) hide show
  1. gigacrew-0.5.0/LICENSE +9 -0
  2. gigacrew-0.5.0/PKG-INFO +60 -0
  3. gigacrew-0.5.0/README.md +16 -0
  4. gigacrew-0.5.0/gigacrew/__init__.py +3 -0
  5. gigacrew-0.5.0/gigacrew/__main__.py +4 -0
  6. gigacrew-0.5.0/gigacrew/agent/__init__.py +7 -0
  7. gigacrew-0.5.0/gigacrew/agent/chat_store.py +293 -0
  8. gigacrew-0.5.0/gigacrew/agent/compaction.py +268 -0
  9. gigacrew-0.5.0/gigacrew/agent/context_window.py +107 -0
  10. gigacrew-0.5.0/gigacrew/agent/conversation.py +169 -0
  11. gigacrew-0.5.0/gigacrew/agent/events.py +185 -0
  12. gigacrew-0.5.0/gigacrew/agent/guard.py +281 -0
  13. gigacrew-0.5.0/gigacrew/agent/loop.py +928 -0
  14. gigacrew-0.5.0/gigacrew/agent/prompts.py +299 -0
  15. gigacrew-0.5.0/gigacrew/agent/subagents.py +236 -0
  16. gigacrew-0.5.0/gigacrew/cli/__init__.py +8 -0
  17. gigacrew-0.5.0/gigacrew/cli/app.py +254 -0
  18. gigacrew-0.5.0/gigacrew/cli/commands.py +290 -0
  19. gigacrew-0.5.0/gigacrew/cli/indexing.py +122 -0
  20. gigacrew-0.5.0/gigacrew/cli/interrupt.py +329 -0
  21. gigacrew-0.5.0/gigacrew/cli/models.py +187 -0
  22. gigacrew-0.5.0/gigacrew/cli/prompt.py +291 -0
  23. gigacrew-0.5.0/gigacrew/cli/repl.py +184 -0
  24. gigacrew-0.5.0/gigacrew/config.py +347 -0
  25. gigacrew-0.5.0/gigacrew/errors.py +15 -0
  26. gigacrew-0.5.0/gigacrew/llm/__init__.py +23 -0
  27. gigacrew-0.5.0/gigacrew/llm/api.py +536 -0
  28. gigacrew-0.5.0/gigacrew/llm/base.py +99 -0
  29. gigacrew-0.5.0/gigacrew/llm/context_probe.py +193 -0
  30. gigacrew-0.5.0/gigacrew/llm/model_windows.py +131 -0
  31. gigacrew-0.5.0/gigacrew/llm/types.py +87 -0
  32. gigacrew-0.5.0/gigacrew/permissions.py +119 -0
  33. gigacrew-0.5.0/gigacrew/project/__init__.py +40 -0
  34. gigacrew-0.5.0/gigacrew/project/files.py +277 -0
  35. gigacrew-0.5.0/gigacrew/project/gitignore.py +198 -0
  36. gigacrew-0.5.0/gigacrew/project/index.py +830 -0
  37. gigacrew-0.5.0/gigacrew/project/instructions.py +149 -0
  38. gigacrew-0.5.0/gigacrew/project/lsp.py +285 -0
  39. gigacrew-0.5.0/gigacrew/project/repomap.py +253 -0
  40. gigacrew-0.5.0/gigacrew/project/semantic.py +425 -0
  41. gigacrew-0.5.0/gigacrew/project/symbols.py +728 -0
  42. gigacrew-0.5.0/gigacrew/project/treesitter.py +532 -0
  43. gigacrew-0.5.0/gigacrew/project/workspace.py +67 -0
  44. gigacrew-0.5.0/gigacrew/security.py +77 -0
  45. gigacrew-0.5.0/gigacrew/tools/__init__.py +17 -0
  46. gigacrew-0.5.0/gigacrew/tools/ask_user.py +105 -0
  47. gigacrew-0.5.0/gigacrew/tools/base.py +288 -0
  48. gigacrew-0.5.0/gigacrew/tools/fs.py +836 -0
  49. gigacrew-0.5.0/gigacrew/tools/git.py +165 -0
  50. gigacrew-0.5.0/gigacrew/tools/index.py +345 -0
  51. gigacrew-0.5.0/gigacrew/tools/navigation.py +615 -0
  52. gigacrew-0.5.0/gigacrew/tools/plan.py +112 -0
  53. gigacrew-0.5.0/gigacrew/tools/process.py +218 -0
  54. gigacrew-0.5.0/gigacrew/tools/questions.py +51 -0
  55. gigacrew-0.5.0/gigacrew/tools/search.py +104 -0
  56. gigacrew-0.5.0/gigacrew/tools/shell.py +77 -0
  57. gigacrew-0.5.0/gigacrew/tools/task.py +136 -0
  58. gigacrew-0.5.0/gigacrew/ui/__init__.py +5 -0
  59. gigacrew-0.5.0/gigacrew/ui/banner.py +55 -0
  60. gigacrew-0.5.0/gigacrew/ui/chat_picker.py +245 -0
  61. gigacrew-0.5.0/gigacrew/ui/choice.py +210 -0
  62. gigacrew-0.5.0/gigacrew/ui/console.py +845 -0
  63. gigacrew-0.5.0/gigacrew/ui/format.py +84 -0
  64. gigacrew-0.5.0/gigacrew/ui/markdown.py +31 -0
  65. gigacrew-0.5.0/gigacrew/ui/markdown_stream.py +150 -0
  66. gigacrew-0.5.0/gigacrew/ui/spinner.py +63 -0
  67. gigacrew-0.5.0/gigacrew/ui/theme.py +76 -0
  68. gigacrew-0.5.0/pyproject.toml +93 -0
gigacrew-0.5.0/LICENSE ADDED
@@ -0,0 +1,9 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 FolkiDevv
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,60 @@
1
+ Metadata-Version: 2.4
2
+ Name: gigacrew
3
+ Version: 0.5.0
4
+ Summary: Консольный помощник для работы с кодом проекта
5
+ Keywords: cli,agent,llm,code,assistant,tree-sitter,lsp
6
+ Author: FolkiDevv
7
+ Author-email: FolkiDevv <hi@folki.dev>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3.14
17
+ Classifier: Topic :: Software Development
18
+ Requires-Dist: httpx>=0.27
19
+ Requires-Dist: pydantic>=2.5
20
+ Requires-Dist: rich>=13.0
21
+ Requires-Dist: prompt-toolkit>=3.0.43
22
+ Requires-Dist: ty==0.0.86
23
+ Requires-Dist: tree-sitter~=0.26.0
24
+ Requires-Dist: tree-sitter-python>=0.25
25
+ Requires-Dist: tree-sitter-javascript>=0.25
26
+ Requires-Dist: tree-sitter-typescript>=0.23.2
27
+ Requires-Dist: tree-sitter-go>=0.25
28
+ Requires-Dist: tree-sitter-rust>=0.24
29
+ Requires-Dist: tree-sitter-java>=0.23.5
30
+ Requires-Dist: tree-sitter-kotlin>=1.1
31
+ Requires-Dist: tree-sitter-c-sharp>=0.23.5
32
+ Requires-Dist: tree-sitter-ruby>=0.23.1
33
+ Requires-Dist: tree-sitter-php>=0.25
34
+ Requires-Dist: tree-sitter-c>=0.24
35
+ Requires-Dist: tree-sitter-cpp>=0.23.4
36
+ Requires-Dist: tree-sitter-bash>=0.25
37
+ Requires-Dist: tree-sitter-scala>=0.26
38
+ Requires-Dist: tree-sitter-swift>=0.7
39
+ Requires-Python: >=3.13
40
+ Project-URL: Homepage, https://github.com/FolkiDevv/gigacrew
41
+ Project-URL: Repository, https://github.com/FolkiDevv/gigacrew
42
+ Project-URL: Issues, https://github.com/FolkiDevv/gigacrew/issues
43
+ Description-Content-Type: text/markdown
44
+
45
+ # gigacrew
46
+
47
+ Консольная утилита для работы с кодом локального проекта: навигация по исходникам, поиск, правка файлов и запуск команд в терминале.
48
+ Перед запуском скопируйте `.env.example` в `.env` и укажите в нём адрес API, модель и реквизиты доступа — значений по умолчанию нет.
49
+
50
+ ## Установка
51
+
52
+ ```sh
53
+ uv tool install gigacrew
54
+ # или
55
+ pipx install gigacrew
56
+ ```
57
+
58
+ ## Лицензия
59
+
60
+ MIT, см. [LICENSE](LICENSE).
@@ -0,0 +1,16 @@
1
+ # gigacrew
2
+
3
+ Консольная утилита для работы с кодом локального проекта: навигация по исходникам, поиск, правка файлов и запуск команд в терминале.
4
+ Перед запуском скопируйте `.env.example` в `.env` и укажите в нём адрес API, модель и реквизиты доступа — значений по умолчанию нет.
5
+
6
+ ## Установка
7
+
8
+ ```sh
9
+ uv tool install gigacrew
10
+ # или
11
+ pipx install gigacrew
12
+ ```
13
+
14
+ ## Лицензия
15
+
16
+ MIT, см. [LICENSE](LICENSE).
@@ -0,0 +1,3 @@
1
+ """gigacrew — консольный помощник для работы с кодом проекта."""
2
+
3
+ __version__ = "0.5.0"
@@ -0,0 +1,4 @@
1
+ from gigacrew.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -0,0 +1,7 @@
1
+ """Агентное ядро: цикл, история диалога, ограничители, события, промпты."""
2
+
3
+ from gigacrew.agent.conversation import Conversation
4
+ from gigacrew.agent.events import AgentEvents, ToolCallInfo, TurnStats
5
+ from gigacrew.agent.loop import Agent
6
+
7
+ __all__ = ["Agent", "AgentEvents", "Conversation", "ToolCallInfo", "TurnStats"]
@@ -0,0 +1,293 @@
1
+ """Сохранённые чаты: ``.gigacrew/chats/<id>.json``.
2
+
3
+ Один файл на чат: метаданные (заголовок, время, модель) и диалог в формате
4
+ :meth:`Conversation.to_dict`. Запись атомарная (временный файл + ``os.replace``),
5
+ файл доступен только владельцу — в диалоге бывают секреты из вывода команд.
6
+
7
+ :class:`ChatStore` — хранилище, :class:`ChatRecorder` — текущий чат сессии
8
+ (автосохранение после хода, переключение по ``/resume``, новый чат по ``/clear``).
9
+ Модуль не знает о UI: ошибки возвращаются текстом или исключением :class:`ChatStoreError`.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+ import re
17
+ import secrets
18
+ import tempfile
19
+ from collections.abc import Iterable
20
+ from dataclasses import dataclass
21
+ from datetime import datetime
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ from gigacrew.agent.conversation import Conversation
26
+ from gigacrew.llm.types import Message
27
+ from gigacrew.project.workspace import Workspace
28
+
29
+ TITLE_LIMIT = 60
30
+ PREVIEW_LIMIT = 160
31
+ _ID_RE = re.compile(r"^[0-9A-Za-z][0-9A-Za-z_-]*$")
32
+
33
+
34
+ class ChatStoreError(Exception):
35
+ """Чат не найден, ссылка неоднозначна или файл повреждён."""
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class ChatInfo:
40
+ """Метаданные сохранённого чата (без самого диалога)."""
41
+
42
+ id: str
43
+ title: str
44
+ created_at: datetime
45
+ updated_at: datetime
46
+ model: str = ""
47
+ requests: int = 0 # запросов пользователя
48
+ preview: str = "" # начало последнего ответа модели
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class SavedChat:
53
+ info: ChatInfo
54
+ conversation: Conversation
55
+
56
+
57
+ def _now() -> datetime:
58
+ return datetime.now().astimezone().replace(microsecond=0)
59
+
60
+
61
+ def _parse_time(value: str) -> datetime:
62
+ """Время из файла; без часового пояса (файл правили вручную) — считается местным.
63
+
64
+ Иначе наивное и aware-время нельзя было бы сравнить при сортировке списка чатов.
65
+ """
66
+ when = datetime.fromisoformat(value)
67
+ return when if when.tzinfo is not None else when.astimezone()
68
+
69
+
70
+ def _one_line(text: str, limit: int) -> str:
71
+ line = " ".join(text.split())
72
+ return line if len(line) <= limit else line[: limit - 1].rstrip() + "…"
73
+
74
+
75
+ def chat_title(messages: Iterable[Message]) -> str:
76
+ """Заголовок чата — первый запрос пользователя в одну строку."""
77
+ for m in messages:
78
+ if m.role == "user" and m.content.strip():
79
+ return _one_line(m.content, TITLE_LIMIT)
80
+ return "(без названия)"
81
+
82
+
83
+ def _preview(messages: tuple[Message, ...]) -> str:
84
+ for m in reversed(messages):
85
+ if m.role == "assistant" and m.function_call is None and m.content.strip():
86
+ return _one_line(m.content, PREVIEW_LIMIT)
87
+ return ""
88
+
89
+
90
+ class ChatStore:
91
+ FORMAT_VERSION = 1
92
+
93
+ def __init__(self, workspace: Workspace):
94
+ """Конструктор не трогает файловую систему."""
95
+ self._workspace = workspace
96
+
97
+ @property
98
+ def directory(self) -> Path:
99
+ return self._workspace.chats_dir
100
+
101
+ # ------------------------------------------------------------------ #
102
+ @staticmethod
103
+ def new_id(now: datetime | None = None) -> str:
104
+ """Id сортируется по времени создания; суффикс — от коллизий чатов одной секунды."""
105
+ return f"{(now or _now()):%Y%m%d-%H%M%S}-{secrets.token_hex(4)}"
106
+
107
+ def exists(self, chat_id: str) -> bool:
108
+ return self._path(chat_id).exists()
109
+
110
+ def _path(self, chat_id: str) -> Path:
111
+ if not _ID_RE.match(chat_id):
112
+ raise ChatStoreError(f"некорректный id чата: {chat_id!r}")
113
+ return self.directory / f"{chat_id}.json"
114
+
115
+ def save(
116
+ self,
117
+ chat_id: str,
118
+ conversation: Conversation,
119
+ *,
120
+ model: str = "",
121
+ created_at: datetime | None = None,
122
+ ) -> ChatInfo:
123
+ """Записывает чат (атомарно). Ошибки записи — ``OSError``."""
124
+ messages = conversation.messages
125
+ now = _now()
126
+ info = ChatInfo(
127
+ id=chat_id,
128
+ title=chat_title(messages),
129
+ created_at=created_at or now,
130
+ updated_at=now,
131
+ model=model,
132
+ requests=sum(1 for m in messages if m.role == "user"),
133
+ preview=_preview(messages),
134
+ )
135
+ data = {
136
+ "version": self.FORMAT_VERSION,
137
+ "id": info.id,
138
+ "title": info.title,
139
+ "created_at": info.created_at.isoformat(),
140
+ "updated_at": info.updated_at.isoformat(),
141
+ "model": info.model,
142
+ "requests": info.requests,
143
+ "preview": info.preview,
144
+ "conversation": conversation.to_dict(),
145
+ }
146
+ path = self._path(chat_id)
147
+ self._workspace.ensure_data_dir()
148
+ self.directory.mkdir(exist_ok=True)
149
+ # mkstemp создаёт файл с правами 0600 в том же каталоге — os.replace атомарен.
150
+ fd, tmp = tempfile.mkstemp(prefix=f".{chat_id}.", suffix=".tmp", dir=self.directory)
151
+ try:
152
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
153
+ json.dump(data, f, ensure_ascii=False)
154
+ os.replace(tmp, path)
155
+ except BaseException:
156
+ Path(tmp).unlink(missing_ok=True)
157
+ raise
158
+ return info
159
+
160
+ # ------------------------------------------------------------------ #
161
+ def load(self, chat_id: str) -> SavedChat:
162
+ path = self._path(chat_id)
163
+ try:
164
+ data = json.loads(path.read_text(encoding="utf-8"))
165
+ except FileNotFoundError:
166
+ raise ChatStoreError(f"чат {chat_id} не найден") from None
167
+ except (OSError, ValueError) as e:
168
+ raise ChatStoreError(f"не удалось прочитать чат {chat_id}: {e}") from None
169
+ try:
170
+ info = self._info(data, chat_id)
171
+ raw = data["conversation"]
172
+ if not isinstance(raw, dict):
173
+ raise ValueError("поле conversation — не объект")
174
+ conversation = Conversation.from_dict(raw)
175
+ except (KeyError, TypeError, ValueError) as e:
176
+ raise ChatStoreError(f"файл чата {chat_id} повреждён: {e}") from None
177
+ conversation.repair() # на случай файла, записанного посреди вызова инструмента
178
+ return SavedChat(info, conversation)
179
+
180
+ def _info(self, data: Any, chat_id: str) -> ChatInfo:
181
+ if not isinstance(data, dict) or data.get("version") != self.FORMAT_VERSION:
182
+ raise ValueError("неподдерживаемая версия формата")
183
+ return ChatInfo(
184
+ id=chat_id,
185
+ title=str(data.get("title") or "(без названия)"),
186
+ created_at=_parse_time(data["created_at"]),
187
+ updated_at=_parse_time(data["updated_at"]),
188
+ model=str(data.get("model") or ""),
189
+ requests=int(data.get("requests") or 0),
190
+ preview=str(data.get("preview") or ""),
191
+ )
192
+
193
+ def _files(self) -> list[Path]:
194
+ """Файлы чатов, свежие первыми (по времени изменения)."""
195
+ try:
196
+ files = [p for p in self.directory.glob("*.json") if _ID_RE.match(p.stem)]
197
+ except OSError:
198
+ return []
199
+ stamped = []
200
+ for p in files:
201
+ try:
202
+ stamped.append((p.stat().st_mtime, p))
203
+ except OSError:
204
+ continue
205
+ return [p for _, p in sorted(stamped, key=lambda x: (x[0], x[1].name), reverse=True)]
206
+
207
+ def recent(self, limit: int | None = 20) -> list[ChatInfo]:
208
+ """Последние чаты, свежие первыми. Битые файлы и чужие версии пропускаются."""
209
+ result: list[ChatInfo] = []
210
+ for path in self._files():
211
+ if limit is not None and len(result) >= limit:
212
+ break
213
+ try:
214
+ data = json.loads(path.read_text(encoding="utf-8"))
215
+ result.append(self._info(data, path.stem))
216
+ except (OSError, ValueError, KeyError, TypeError):
217
+ continue
218
+ result.sort(key=lambda info: info.updated_at, reverse=True)
219
+ return result
220
+
221
+ def latest(self) -> SavedChat | None:
222
+ """Последний чат, который удаётся прочитать (битые файлы пропускаются)."""
223
+ for path in self._files():
224
+ try:
225
+ return self.load(path.stem)
226
+ except ChatStoreError:
227
+ continue
228
+ return None
229
+
230
+ def find(self, ref: str) -> ChatInfo:
231
+ """Чат по точному id или однозначному началу id."""
232
+ ref = ref.strip()
233
+ if not ref:
234
+ raise ChatStoreError("не указан id чата")
235
+ ids = [p.stem for p in self._files()]
236
+ if ref in ids:
237
+ return self.load(ref).info
238
+ matches = [i for i in ids if i.startswith(ref)]
239
+ if not matches:
240
+ raise ChatStoreError(f"чат {ref} не найден")
241
+ if len(matches) > 1:
242
+ shown = ", ".join(matches[:5])
243
+ raise ChatStoreError(f"под «{ref}» подходит несколько чатов: {shown}")
244
+ return self.load(matches[0]).info
245
+
246
+
247
+ class ChatRecorder:
248
+ """Текущий чат сессии: куда сохранять диалог после каждого хода.
249
+
250
+ Если записать не удалось (проект только для чтения), сохранение отключается до
251
+ конца сессии, а :meth:`save` один раз возвращает текст предупреждения.
252
+ """
253
+
254
+ def __init__(self, store: ChatStore, *, enabled: bool = True):
255
+ self.store = store
256
+ self.enabled = enabled
257
+ self._chat_id = store.new_id()
258
+ self._created_at: datetime | None = None
259
+ self.title = "" # заголовок продолженного чата (пусто — новый)
260
+
261
+ @property
262
+ def chat_id(self) -> str:
263
+ return self._chat_id
264
+
265
+ def start_new(self) -> None:
266
+ self._chat_id = self.store.new_id()
267
+ self._created_at = None
268
+ self.title = ""
269
+
270
+ def switch_to(self, info: ChatInfo) -> None:
271
+ """Дальнейшие сохранения идут в этот (продолженный) чат."""
272
+ self._chat_id = info.id
273
+ self._created_at = info.created_at
274
+ self.title = info.title
275
+
276
+ def save(self, conversation: Conversation, *, model: str = "") -> str | None:
277
+ """Сохраняет диалог; возвращает предупреждение, если запись не удалась."""
278
+ if not self.enabled or not any(m.role == "user" for m in conversation.messages):
279
+ return None
280
+ try:
281
+ if self._created_at is None:
282
+ # Первая запись нового чата: id не должен затереть чужой чат
283
+ # (другое окно gigacrew, начатое в ту же секунду).
284
+ while self.store.exists(self._chat_id):
285
+ self._chat_id = self.store.new_id()
286
+ info = self.store.save(
287
+ self._chat_id, conversation, model=model, created_at=self._created_at
288
+ )
289
+ except OSError as e:
290
+ self.enabled = False
291
+ return f"чат не сохранён, автосохранение отключено: {e}"
292
+ self._created_at = info.created_at
293
+ return None
@@ -0,0 +1,268 @@
1
+ """Сжатие контекста: начало диалога заменяется кратким содержанием.
2
+
3
+ Модель пересказывает старую часть истории (вызов без инструментов), резюме
4
+ становится :class:`~gigacrew.agent.conversation.Summary` диалога и уходит в
5
+ системном сообщении вместо самих сообщений. Журнал при этом не переписывается.
6
+
7
+ * :func:`plan_compaction` — что сворачивать: граница проходит по блокам «сообщение +
8
+ результаты его вызова», свежие блоки в пределах ``keep_tokens`` остаются как есть;
9
+ * :func:`render_transcript` / :func:`chunk_entries` — переписка плоским текстом,
10
+ нарезанная так, чтобы каждый запрос суммаризации уместился в окно;
11
+ * :func:`summarize` — итеративное резюме: прежнее краткое содержание + очередной
12
+ кусок → новое краткое содержание.
13
+
14
+ Порог автозапуска и объёмы выбирает агент (:mod:`gigacrew.agent.loop`).
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ from collections.abc import Callable, Sequence
21
+ from dataclasses import dataclass
22
+
23
+ from gigacrew.agent.context_window import CHARS_PER_TOKEN, estimate_text_tokens, estimate_tokens
24
+ from gigacrew.agent.conversation import Conversation
25
+ from gigacrew.agent.prompts import COMPACT_PROMPT
26
+ from gigacrew.llm.base import LLMError, LLMProvider
27
+ from gigacrew.llm.types import Message, Usage
28
+
29
+ # Сколько текста каждого сообщения попадает к суммаризатору (символов).
30
+ USER_CLIP = 6_000
31
+ ASSISTANT_CLIP = 4_000
32
+ RESULT_CLIP = 2_000
33
+ ARGS_CLIP = 300
34
+ # Суммаризатор читает не больше стольких кусков (самых свежих): более старое модель
35
+ # всё равно уже не видела — его отбрасывало окно истории.
36
+ MAX_INPUT_CHUNKS = 3
37
+ MIN_CHUNK_TOKENS = 500
38
+ REQUEST_OVERHEAD_TOKENS = 200 # обвязка запроса суммаризации
39
+ MAX_INSTRUCTIONS_CHARS = 2_000 # пожелания пользователя (/compact …)
40
+
41
+
42
+ class CompactionError(LLMError):
43
+ """Сжать не удалось: модель не дала пригодного краткого содержания."""
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class CompactionPlan:
48
+ """Что сворачивать: сообщения вида до новой границы ``upto`` (индекс в журнале)."""
49
+
50
+ upto: int
51
+ messages: list[Message]
52
+
53
+
54
+ def plan_compaction(
55
+ conversation: Conversation, *, keep_tokens: int, min_tokens: int = 1
56
+ ) -> CompactionPlan | None:
57
+ """План сжатия или None, если сворачивать нечего.
58
+
59
+ Блоки после прежней границы перебираются с конца: остаются свежие блоки в
60
+ пределах ``keep_tokens`` (при ``keep_tokens > 0`` последний — всегда). ``0`` —
61
+ свернуть всё (``/compact``). Если сворачивать меньше ``min_tokens`` (по оценке),
62
+ сжатие не стоит запроса к модели.
63
+ """
64
+ messages = conversation.messages
65
+ start = conversation.summary.upto if conversation.summary is not None else 0
66
+ starts = [
67
+ i for i in range(start, len(messages)) if i == start or messages[i].role != "function"
68
+ ]
69
+
70
+ upto = len(messages)
71
+ if keep_tokens > 0:
72
+ kept = 0
73
+ for k in range(len(starts) - 1, -1, -1):
74
+ end = starts[k + 1] if k + 1 < len(starts) else len(messages)
75
+ size = estimate_tokens(messages[starts[k] : end])
76
+ if upto < len(messages) and kept + size > keep_tokens:
77
+ break
78
+ kept += size
79
+ upto = starts[k]
80
+ if upto <= start:
81
+ return None
82
+
83
+ view = conversation.context_messages()
84
+ # Вид = [закреплённый запрос] + messages[start:]; сворачивается всё до upto.
85
+ head = view[: len(view) - (len(messages) - upto)]
86
+ if not head or estimate_tokens(head) < min_tokens:
87
+ return None
88
+ return CompactionPlan(upto=upto, messages=head)
89
+
90
+
91
+ def clip_middle(text: str, limit: int) -> str:
92
+ """Обрезка с вырезом середины: в начале — суть, в конце — итог (вывод тестов)."""
93
+ if len(text) <= limit:
94
+ return text
95
+ head = limit * 2 // 3
96
+ tail = limit - head
97
+ cut = len(text) - head - tail
98
+ return f"{text[:head]}\n…[вырезано {cut} симв.]…\n{text[len(text) - tail :]}"
99
+
100
+
101
+ def render_message(message: Message) -> str:
102
+ """Сообщение диалога плоским текстом (вызовы инструментов — тоже текстом)."""
103
+ if message.role == "user":
104
+ return f"## Пользователь\n{clip_middle(message.content, USER_CLIP)}"
105
+ if message.role == "function":
106
+ return f"## Результат {message.name or ''}\n{clip_middle(message.content, RESULT_CLIP)}"
107
+ parts = []
108
+ if message.content.strip():
109
+ parts.append(clip_middle(message.content, ASSISTANT_CLIP))
110
+ call = message.function_call
111
+ if call is not None:
112
+ args = json.dumps(call.arguments, ensure_ascii=False, default=str)
113
+ parts.append(f"→ вызов {call.name}({clip_middle(args, ARGS_CLIP)})")
114
+ title = "Ассистент" if message.role == "assistant" else message.role
115
+ return f"## {title}\n" + "\n".join(parts)
116
+
117
+
118
+ def render_transcript(messages: Sequence[Message]) -> list[str]:
119
+ return [render_message(m) for m in messages]
120
+
121
+
122
+ def chunk_entries(entries: Sequence[str], budget_tokens: int) -> list[list[str]]:
123
+ """Делит записи на куски не больше ``budget_tokens`` (по оценке).
124
+
125
+ В каждом куске минимум одна запись; запись крупнее бюджета обрезается.
126
+ """
127
+ budget_tokens = max(budget_tokens, MIN_CHUNK_TOKENS)
128
+ chunks: list[list[str]] = []
129
+ current: list[str] = []
130
+ size = 0
131
+ for entry in entries:
132
+ tokens = estimate_text_tokens(entry)
133
+ if tokens > budget_tokens:
134
+ entry = clip_middle(entry, budget_tokens * CHARS_PER_TOKEN - 100)
135
+ tokens = estimate_text_tokens(entry)
136
+ if current and size + tokens > budget_tokens:
137
+ chunks.append(current)
138
+ current, size = [], 0
139
+ current.append(entry)
140
+ size += tokens
141
+ if current:
142
+ chunks.append(current)
143
+ return chunks
144
+
145
+
146
+ def _recent_chunks(messages: Sequence[Message], budget_tokens: int) -> list[list[str]]:
147
+ """Последние :data:`MAX_INPUT_CHUNKS` кусков переписки для суммаризатора.
148
+
149
+ Последний запрос пользователя (текущая задача) попадает на вход всегда, даже
150
+ если его кусок старше последних: окно истории его тоже никогда не отбрасывало.
151
+ """
152
+ entries = render_transcript(messages)
153
+ chunks = chunk_entries(entries, budget_tokens)
154
+ kept = chunks[-MAX_INPUT_CHUNKS:]
155
+ last_user = max((i for i, m in enumerate(messages) if m.role == "user"), default=None)
156
+ if len(chunks) <= MAX_INPUT_CHUNKS or last_user is None:
157
+ return kept
158
+ first_kept = len(entries) - sum(len(c) for c in kept)
159
+ if last_user >= first_kept:
160
+ return kept
161
+ # Запрос — в отброшенном куске: он идёт первым, переписка после него — следом,
162
+ # в пределах тех же кусков.
163
+ budget_tokens = max(budget_tokens, MIN_CHUNK_TOKENS)
164
+ task = clip_middle(entries[last_user], budget_tokens * CHARS_PER_TOKEN // 2)
165
+ after = entries[last_user + 1 :]
166
+ kept = chunk_entries(after, budget_tokens - estimate_text_tokens(task))[-MAX_INPUT_CHUNKS:]
167
+ kept[0] = [task, *kept[0]]
168
+ return kept
169
+
170
+
171
+ def _request_text(
172
+ previous: str | None, chunk: str, instructions: str, omitted: bool, max_chars: int
173
+ ) -> str:
174
+ parts = []
175
+ if previous:
176
+ parts.append(
177
+ "Краткое содержание более ранней части диалога (обнови его, ничего важного "
178
+ f"не теряя):\n{previous}"
179
+ )
180
+ if omitted:
181
+ parts.append("(Самая ранняя часть диалога не поместилась и опущена.)")
182
+ parts.append(f"Фрагмент диалога для сжатия:\n{chunk}")
183
+ if instructions.strip():
184
+ parts.append(f"Пожелания пользователя к краткому содержанию: {instructions.strip()}")
185
+ parts.append(
186
+ f"Составь краткое содержание по правилам. Объём — не больше {max_chars} символов "
187
+ f"(~{max(max_chars // CHARS_PER_WORD, 1)} слов)."
188
+ )
189
+ return "\n\n".join(parts)
190
+
191
+
192
+ CHARS_PER_WORD = 7 # русский текст с пробелами — для подсказки модели об объёме
193
+ CLIPPED_MARK = "\n…(краткое содержание обрезано)"
194
+
195
+
196
+ def _clip_summary(text: str, max_chars: int) -> str:
197
+ """Резюме не длиннее ``max_chars``: режется с конца, по границе строки.
198
+
199
+ Модель кладёт важное первым (текущее состояние, следующий шаг), поэтому
200
+ теряется хвост, а не начало, и строка не обрывается на полуслове.
201
+ """
202
+ if len(text) <= max_chars:
203
+ return text
204
+ head = text[:max_chars]
205
+ cut = head.rfind("\n")
206
+ if cut >= max_chars // 2:
207
+ head = head[:cut]
208
+ return head.rstrip() + CLIPPED_MARK
209
+
210
+
211
+ def summarize(
212
+ provider: LLMProvider,
213
+ messages: Sequence[Message],
214
+ *,
215
+ model: str,
216
+ temperature: float,
217
+ input_budget: int,
218
+ max_chars: int,
219
+ previous: str | None = None,
220
+ instructions: str = "",
221
+ on_usage: Callable[[Usage], None] | None = None,
222
+ ) -> str:
223
+ """Краткое содержание ``messages`` (с учётом прежнего ``previous``).
224
+
225
+ ``input_budget`` — бюджет одного запроса (оценка в токенах); ``max_chars`` —
226
+ предел длины резюме. ``on_usage`` получает расход каждого обращения — и при
227
+ сбое на следующем куске (оплаченное не теряется). Ошибки — :class:`LLMError`.
228
+
229
+ Пожелания и прежнее резюме обрезаются, чтобы запрос с ними и куском переписки
230
+ укладывался в ``input_budget``.
231
+ """
232
+ instructions = clip_middle(instructions.strip(), MAX_INSTRUCTIONS_CHARS)
233
+ if previous is not None:
234
+ previous = _clip_summary(previous, max_chars) # могло остаться от модели с большим окном
235
+ reserve = (
236
+ estimate_text_tokens(COMPACT_PROMPT)
237
+ + max_chars // CHARS_PER_TOKEN # прежнее резюме
238
+ + estimate_text_tokens(instructions)
239
+ + REQUEST_OVERHEAD_TOKENS
240
+ )
241
+ chunks = _recent_chunks(messages, input_budget - reserve)
242
+ omitted = sum(len(c) for c in chunks) < len(messages)
243
+
244
+ summary = previous
245
+ for i, chunk in enumerate(chunks):
246
+ request = [
247
+ Message(role="system", content=COMPACT_PROMPT),
248
+ Message(
249
+ role="user",
250
+ content=_request_text(
251
+ summary, "\n\n".join(chunk), instructions, omitted and i == 0, max_chars
252
+ ),
253
+ ),
254
+ ]
255
+ turn = provider.complete(request, tools=None, model=model, temperature=temperature)
256
+ if on_usage is not None:
257
+ on_usage(turn.usage)
258
+ if turn.finish_reason != "stop":
259
+ raise CompactionError(
260
+ f"модель не закончила краткое содержание (finish_reason={turn.finish_reason})"
261
+ )
262
+ text = turn.message.content.strip()
263
+ if not text:
264
+ raise CompactionError("модель вернула пустое краткое содержание")
265
+ summary = _clip_summary(text, max_chars)
266
+ if summary is None:
267
+ raise CompactionError("нечего сжимать")
268
+ return summary