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.
- gigacrew-0.5.0/LICENSE +9 -0
- gigacrew-0.5.0/PKG-INFO +60 -0
- gigacrew-0.5.0/README.md +16 -0
- gigacrew-0.5.0/gigacrew/__init__.py +3 -0
- gigacrew-0.5.0/gigacrew/__main__.py +4 -0
- gigacrew-0.5.0/gigacrew/agent/__init__.py +7 -0
- gigacrew-0.5.0/gigacrew/agent/chat_store.py +293 -0
- gigacrew-0.5.0/gigacrew/agent/compaction.py +268 -0
- gigacrew-0.5.0/gigacrew/agent/context_window.py +107 -0
- gigacrew-0.5.0/gigacrew/agent/conversation.py +169 -0
- gigacrew-0.5.0/gigacrew/agent/events.py +185 -0
- gigacrew-0.5.0/gigacrew/agent/guard.py +281 -0
- gigacrew-0.5.0/gigacrew/agent/loop.py +928 -0
- gigacrew-0.5.0/gigacrew/agent/prompts.py +299 -0
- gigacrew-0.5.0/gigacrew/agent/subagents.py +236 -0
- gigacrew-0.5.0/gigacrew/cli/__init__.py +8 -0
- gigacrew-0.5.0/gigacrew/cli/app.py +254 -0
- gigacrew-0.5.0/gigacrew/cli/commands.py +290 -0
- gigacrew-0.5.0/gigacrew/cli/indexing.py +122 -0
- gigacrew-0.5.0/gigacrew/cli/interrupt.py +329 -0
- gigacrew-0.5.0/gigacrew/cli/models.py +187 -0
- gigacrew-0.5.0/gigacrew/cli/prompt.py +291 -0
- gigacrew-0.5.0/gigacrew/cli/repl.py +184 -0
- gigacrew-0.5.0/gigacrew/config.py +347 -0
- gigacrew-0.5.0/gigacrew/errors.py +15 -0
- gigacrew-0.5.0/gigacrew/llm/__init__.py +23 -0
- gigacrew-0.5.0/gigacrew/llm/api.py +536 -0
- gigacrew-0.5.0/gigacrew/llm/base.py +99 -0
- gigacrew-0.5.0/gigacrew/llm/context_probe.py +193 -0
- gigacrew-0.5.0/gigacrew/llm/model_windows.py +131 -0
- gigacrew-0.5.0/gigacrew/llm/types.py +87 -0
- gigacrew-0.5.0/gigacrew/permissions.py +119 -0
- gigacrew-0.5.0/gigacrew/project/__init__.py +40 -0
- gigacrew-0.5.0/gigacrew/project/files.py +277 -0
- gigacrew-0.5.0/gigacrew/project/gitignore.py +198 -0
- gigacrew-0.5.0/gigacrew/project/index.py +830 -0
- gigacrew-0.5.0/gigacrew/project/instructions.py +149 -0
- gigacrew-0.5.0/gigacrew/project/lsp.py +285 -0
- gigacrew-0.5.0/gigacrew/project/repomap.py +253 -0
- gigacrew-0.5.0/gigacrew/project/semantic.py +425 -0
- gigacrew-0.5.0/gigacrew/project/symbols.py +728 -0
- gigacrew-0.5.0/gigacrew/project/treesitter.py +532 -0
- gigacrew-0.5.0/gigacrew/project/workspace.py +67 -0
- gigacrew-0.5.0/gigacrew/security.py +77 -0
- gigacrew-0.5.0/gigacrew/tools/__init__.py +17 -0
- gigacrew-0.5.0/gigacrew/tools/ask_user.py +105 -0
- gigacrew-0.5.0/gigacrew/tools/base.py +288 -0
- gigacrew-0.5.0/gigacrew/tools/fs.py +836 -0
- gigacrew-0.5.0/gigacrew/tools/git.py +165 -0
- gigacrew-0.5.0/gigacrew/tools/index.py +345 -0
- gigacrew-0.5.0/gigacrew/tools/navigation.py +615 -0
- gigacrew-0.5.0/gigacrew/tools/plan.py +112 -0
- gigacrew-0.5.0/gigacrew/tools/process.py +218 -0
- gigacrew-0.5.0/gigacrew/tools/questions.py +51 -0
- gigacrew-0.5.0/gigacrew/tools/search.py +104 -0
- gigacrew-0.5.0/gigacrew/tools/shell.py +77 -0
- gigacrew-0.5.0/gigacrew/tools/task.py +136 -0
- gigacrew-0.5.0/gigacrew/ui/__init__.py +5 -0
- gigacrew-0.5.0/gigacrew/ui/banner.py +55 -0
- gigacrew-0.5.0/gigacrew/ui/chat_picker.py +245 -0
- gigacrew-0.5.0/gigacrew/ui/choice.py +210 -0
- gigacrew-0.5.0/gigacrew/ui/console.py +845 -0
- gigacrew-0.5.0/gigacrew/ui/format.py +84 -0
- gigacrew-0.5.0/gigacrew/ui/markdown.py +31 -0
- gigacrew-0.5.0/gigacrew/ui/markdown_stream.py +150 -0
- gigacrew-0.5.0/gigacrew/ui/spinner.py +63 -0
- gigacrew-0.5.0/gigacrew/ui/theme.py +76 -0
- 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.
|
gigacrew-0.5.0/PKG-INFO
ADDED
|
@@ -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).
|
gigacrew-0.5.0/README.md
ADDED
|
@@ -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,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
|