opencode-db 0.3.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.
- cli.py +70 -0
- cmd_costs.py +197 -0
- cmd_delete.py +58 -0
- cmd_export.py +189 -0
- cmd_help.py +210 -0
- cmd_info.py +152 -0
- cmd_list.py +124 -0
- cmd_projects.py +82 -0
- cmd_prune.py +127 -0
- cmd_search.py +111 -0
- cmd_stats.py +115 -0
- cmd_todos.py +90 -0
- cmd_tree.py +95 -0
- cmd_vacuum.py +67 -0
- commands/__init__.py +51 -0
- config.py +14 -0
- db.py +374 -0
- formatters.py +208 -0
- i18n.py +532 -0
- opencode_db-0.3.0.dist-info/METADATA +7 -0
- opencode_db-0.3.0.dist-info/RECORD +26 -0
- opencode_db-0.3.0.dist-info/WHEEL +5 -0
- opencode_db-0.3.0.dist-info/entry_points.txt +2 -0
- opencode_db-0.3.0.dist-info/licenses/LICENSE +25 -0
- opencode_db-0.3.0.dist-info/top_level.txt +20 -0
- utils.py +115 -0
db.py
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
"""Работа с базой данных opencode.
|
|
2
|
+
|
|
3
|
+
Предоставляет:
|
|
4
|
+
- get_db() — открыть соединение с БД
|
|
5
|
+
- get_session_info() — метаданные сессии
|
|
6
|
+
- get_messages() — сообщения сессии с разбивкой на части
|
|
7
|
+
- Вспомогательные функции для запросов
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import sqlite3
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
from config import OPENCODE_DB
|
|
16
|
+
from i18n import _
|
|
17
|
+
|
|
18
|
+
# Ожидаемая схема БД: таблица → список обязательных колонок
|
|
19
|
+
_EXPECTED_SCHEMA = {
|
|
20
|
+
"session": [
|
|
21
|
+
"id",
|
|
22
|
+
"title",
|
|
23
|
+
"model",
|
|
24
|
+
"cost",
|
|
25
|
+
"tokens_input",
|
|
26
|
+
"tokens_output",
|
|
27
|
+
"tokens_reasoning",
|
|
28
|
+
"time_created",
|
|
29
|
+
"time_updated",
|
|
30
|
+
"project_id",
|
|
31
|
+
"parent_id",
|
|
32
|
+
],
|
|
33
|
+
"message": ["id", "session_id", "time_created", "data"],
|
|
34
|
+
"part": ["id", "message_id", "session_id", "time_created", "data"],
|
|
35
|
+
"todo": ["session_id", "content", "status", "priority"],
|
|
36
|
+
"project": ["id", "worktree", "name"],
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def verify_schema(conn) -> bool:
|
|
41
|
+
"""Проверяет, что БД содержит ожидаемые таблицы и колонки."""
|
|
42
|
+
tables = conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()
|
|
43
|
+
existing_tables = {r["name"] for r in tables}
|
|
44
|
+
|
|
45
|
+
ok = True
|
|
46
|
+
|
|
47
|
+
for table, expected_cols in _EXPECTED_SCHEMA.items():
|
|
48
|
+
if table not in existing_tables:
|
|
49
|
+
print(_("schema.table_missing", table=table))
|
|
50
|
+
ok = False
|
|
51
|
+
continue
|
|
52
|
+
|
|
53
|
+
columns = conn.execute(f"PRAGMA table_info('{table}')").fetchall()
|
|
54
|
+
existing_cols = {c["name"] for c in columns}
|
|
55
|
+
|
|
56
|
+
missing = [c for c in expected_cols if c not in existing_cols]
|
|
57
|
+
if missing:
|
|
58
|
+
print(_("schema.column_missing", table=table, columns=", ".join(missing)))
|
|
59
|
+
ok = False
|
|
60
|
+
|
|
61
|
+
if not ok:
|
|
62
|
+
print(_("schema.check_upgrade"))
|
|
63
|
+
|
|
64
|
+
return ok
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def get_db() -> sqlite3.Connection:
|
|
68
|
+
"""Открывает соединение с БД opencode."""
|
|
69
|
+
if not os.path.isfile(OPENCODE_DB):
|
|
70
|
+
print(_("db.not_found", path=OPENCODE_DB))
|
|
71
|
+
print(_("db.not_found_hint"))
|
|
72
|
+
sys.exit(1)
|
|
73
|
+
conn = sqlite3.connect(OPENCODE_DB)
|
|
74
|
+
conn.row_factory = sqlite3.Row
|
|
75
|
+
verify_schema(conn)
|
|
76
|
+
return conn
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ======================================================================
|
|
80
|
+
# ЗАПРОСЫ К ТАБЛИЦЕ session
|
|
81
|
+
# ======================================================================
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def resolve_session_id(db, session_id):
|
|
85
|
+
"""Разрешает ID сессии: точное совпадение или префикс."""
|
|
86
|
+
if not session_id:
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
row = db.execute("SELECT id FROM session WHERE id = ?", (session_id,)).fetchone()
|
|
90
|
+
if row:
|
|
91
|
+
return row["id"]
|
|
92
|
+
|
|
93
|
+
rows = db.execute(
|
|
94
|
+
"SELECT id FROM session WHERE id LIKE ? COLLATE NOCASE", (f"{session_id}%",)
|
|
95
|
+
).fetchall()
|
|
96
|
+
|
|
97
|
+
if not rows:
|
|
98
|
+
print(_("session.not_found", session_id=session_id))
|
|
99
|
+
sys.exit(1)
|
|
100
|
+
|
|
101
|
+
if len(rows) > 1:
|
|
102
|
+
print(_("session.multiple_match", session_id=session_id))
|
|
103
|
+
for r in rows:
|
|
104
|
+
print(f" {r['id']}")
|
|
105
|
+
print(_("session.specify_id"))
|
|
106
|
+
sys.exit(1)
|
|
107
|
+
|
|
108
|
+
return rows[0]["id"]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def get_session_info(db, session_id):
|
|
112
|
+
"""Достаёт метаданные сессии."""
|
|
113
|
+
full_id = resolve_session_id(db, session_id)
|
|
114
|
+
|
|
115
|
+
row = db.execute(
|
|
116
|
+
"""
|
|
117
|
+
SELECT s.id, s.title, s.model, s.agent,
|
|
118
|
+
s.time_created, s.time_updated,
|
|
119
|
+
s.cost, s.tokens_input, s.tokens_output, s.tokens_reasoning,
|
|
120
|
+
s.tokens_cache_read, s.tokens_cache_write,
|
|
121
|
+
s.project_id, s.parent_id, s.directory, s.version,
|
|
122
|
+
s.summary_additions, s.summary_deletions, s.summary_files
|
|
123
|
+
FROM session s
|
|
124
|
+
WHERE s.id = ?
|
|
125
|
+
""",
|
|
126
|
+
(full_id,),
|
|
127
|
+
).fetchone()
|
|
128
|
+
|
|
129
|
+
if not row:
|
|
130
|
+
print(_("session.not_found", session_id=session_id))
|
|
131
|
+
sys.exit(1)
|
|
132
|
+
|
|
133
|
+
return row
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def get_session_title(row):
|
|
137
|
+
"""Извлекает название сессии, подставляя 'Безымянная' если None."""
|
|
138
|
+
return row["title"] or _("info.unnamed")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def parse_model(row):
|
|
142
|
+
"""Парсит JSON модели из строки БД."""
|
|
143
|
+
model_json = row["model"]
|
|
144
|
+
if not model_json:
|
|
145
|
+
return "—"
|
|
146
|
+
try:
|
|
147
|
+
return json.loads(model_json).get("id", "—")
|
|
148
|
+
except (json.JSONDecodeError, TypeError):
|
|
149
|
+
return str(model_json)[:40]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# ======================================================================
|
|
153
|
+
# ЗАПРОСЫ К ТАБЛИЦАМ message + part
|
|
154
|
+
# ======================================================================
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def get_messages(db, session_id):
|
|
158
|
+
"""Достаёт все сообщения сессии с разбивкой на части.
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
list[dict]: {"id", "role", "agent", "time", "parts": [...]}
|
|
162
|
+
"""
|
|
163
|
+
rows = db.execute(
|
|
164
|
+
"""
|
|
165
|
+
SELECT m.id,
|
|
166
|
+
json_extract(m.data, '$.role') as role,
|
|
167
|
+
json_extract(m.data, '$.agent') as agent,
|
|
168
|
+
m.time_created,
|
|
169
|
+
p.data as part_data
|
|
170
|
+
FROM message m
|
|
171
|
+
LEFT JOIN part p ON p.message_id = m.id
|
|
172
|
+
WHERE m.session_id = ?
|
|
173
|
+
ORDER BY m.time_created, p.id
|
|
174
|
+
""",
|
|
175
|
+
(session_id,),
|
|
176
|
+
).fetchall()
|
|
177
|
+
|
|
178
|
+
messages = []
|
|
179
|
+
current = None
|
|
180
|
+
|
|
181
|
+
for row in rows:
|
|
182
|
+
msg_id = row["id"]
|
|
183
|
+
part_data = row["part_data"]
|
|
184
|
+
|
|
185
|
+
if not part_data:
|
|
186
|
+
continue
|
|
187
|
+
|
|
188
|
+
try:
|
|
189
|
+
data = json.loads(part_data)
|
|
190
|
+
except json.JSONDecodeError:
|
|
191
|
+
continue
|
|
192
|
+
|
|
193
|
+
part_type = data.get("type", "")
|
|
194
|
+
|
|
195
|
+
if current and current["id"] == msg_id:
|
|
196
|
+
parts = current["parts"]
|
|
197
|
+
else:
|
|
198
|
+
if current:
|
|
199
|
+
messages.append(current)
|
|
200
|
+
current = {
|
|
201
|
+
"id": msg_id,
|
|
202
|
+
"role": row["role"],
|
|
203
|
+
"agent": row["agent"],
|
|
204
|
+
"time": row["time_created"],
|
|
205
|
+
"parts": [],
|
|
206
|
+
}
|
|
207
|
+
parts = current["parts"]
|
|
208
|
+
|
|
209
|
+
if part_type == "text":
|
|
210
|
+
text = data.get("text", "")
|
|
211
|
+
if text.strip():
|
|
212
|
+
parts.append({"type": "text", "text": text})
|
|
213
|
+
|
|
214
|
+
elif part_type in ("reasoning", "thought"):
|
|
215
|
+
text = data.get("text", "")
|
|
216
|
+
if text.strip():
|
|
217
|
+
parts.append({"type": "reasoning", "text": text})
|
|
218
|
+
|
|
219
|
+
elif part_type in ("tool", "result"):
|
|
220
|
+
state = data.get("state", {})
|
|
221
|
+
parts.append(
|
|
222
|
+
{
|
|
223
|
+
"type": "tool",
|
|
224
|
+
"tool": data.get("tool", state.get("tool", "")),
|
|
225
|
+
"input": state.get("input", {}),
|
|
226
|
+
"output": state.get("output", ""),
|
|
227
|
+
"status": state.get("status", ""),
|
|
228
|
+
}
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
elif part_type == "sound":
|
|
232
|
+
parts.append({"type": "sound"})
|
|
233
|
+
|
|
234
|
+
if current:
|
|
235
|
+
messages.append(current)
|
|
236
|
+
|
|
237
|
+
return messages
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# ======================================================================
|
|
241
|
+
# ПОДСЧЁТЫ
|
|
242
|
+
# ======================================================================
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def get_session_count(db, project_id=None):
|
|
246
|
+
"""Количество сессий. Опционально — по проекту."""
|
|
247
|
+
if project_id:
|
|
248
|
+
return db.execute(
|
|
249
|
+
"SELECT COUNT(*) FROM session WHERE project_id = ?", (project_id,)
|
|
250
|
+
).fetchone()[0]
|
|
251
|
+
return db.execute("SELECT COUNT(*) FROM session").fetchone()[0]
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def get_message_count(db, session_id=None):
|
|
255
|
+
"""Количество сообщений. Опционально — по сессии."""
|
|
256
|
+
if session_id:
|
|
257
|
+
return db.execute(
|
|
258
|
+
"SELECT COUNT(*) FROM message WHERE session_id = ?", (session_id,)
|
|
259
|
+
).fetchone()[0]
|
|
260
|
+
return db.execute("SELECT COUNT(*) FROM message").fetchone()[0]
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def get_part_count(db, session_id=None):
|
|
264
|
+
"""Количество частей сообщений. Опционально — по сессии."""
|
|
265
|
+
if session_id:
|
|
266
|
+
return db.execute(
|
|
267
|
+
"SELECT COUNT(*) FROM part WHERE session_id = ?", (session_id,)
|
|
268
|
+
).fetchone()[0]
|
|
269
|
+
return db.execute("SELECT COUNT(*) FROM part").fetchone()[0]
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def get_project_count(db):
|
|
273
|
+
"""Количество проектов в БД."""
|
|
274
|
+
return db.execute("SELECT COUNT(*) FROM project").fetchone()[0]
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
# ======================================================================
|
|
278
|
+
# СВЯЗАННЫЕ ДАННЫЕ
|
|
279
|
+
# ======================================================================
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def get_children_sessions(db, session_id):
|
|
283
|
+
"""Дочерние сессии (ответвившиеся от данной)."""
|
|
284
|
+
return db.execute(
|
|
285
|
+
"""
|
|
286
|
+
SELECT id, title, time_created
|
|
287
|
+
FROM session
|
|
288
|
+
WHERE parent_id = ?
|
|
289
|
+
ORDER BY time_created
|
|
290
|
+
""",
|
|
291
|
+
(session_id,),
|
|
292
|
+
).fetchall()
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def get_parent_session(db, session_id):
|
|
296
|
+
"""Родительская сессия (от которой ответвилась данная)."""
|
|
297
|
+
row = db.execute(
|
|
298
|
+
"""
|
|
299
|
+
SELECT s2.id, s2.title, s2.time_created
|
|
300
|
+
FROM session s1
|
|
301
|
+
JOIN session s2 ON s2.id = s1.parent_id
|
|
302
|
+
WHERE s1.id = ?
|
|
303
|
+
""",
|
|
304
|
+
(session_id,),
|
|
305
|
+
).fetchone()
|
|
306
|
+
return row
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def get_session_todos(db, session_id):
|
|
310
|
+
"""Todo-записи для сессии."""
|
|
311
|
+
return db.execute(
|
|
312
|
+
"""
|
|
313
|
+
SELECT content, status, priority, position, time_created
|
|
314
|
+
FROM todo
|
|
315
|
+
WHERE session_id = ?
|
|
316
|
+
ORDER BY position
|
|
317
|
+
""",
|
|
318
|
+
(session_id,),
|
|
319
|
+
).fetchall()
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def get_project_name(db, project_id):
|
|
323
|
+
"""Возвращает название проекта по ID."""
|
|
324
|
+
row = db.execute("SELECT name, worktree FROM project WHERE id = ?", (project_id,)).fetchone()
|
|
325
|
+
if row:
|
|
326
|
+
return row["name"] or os.path.basename(row["worktree"]) or project_id[:12]
|
|
327
|
+
return project_id[:12]
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def get_latest_session(db):
|
|
331
|
+
"""Находит ID самой свежей сессии (по последнему сообщению или времени создания)."""
|
|
332
|
+
row = db.execute("""
|
|
333
|
+
SELECT s.id FROM session s
|
|
334
|
+
LEFT JOIN message m ON m.session_id = s.id
|
|
335
|
+
GROUP BY s.id
|
|
336
|
+
ORDER BY COALESCE(MAX(m.time_created), s.time_created) DESC
|
|
337
|
+
LIMIT 1
|
|
338
|
+
""").fetchone()
|
|
339
|
+
|
|
340
|
+
if not row:
|
|
341
|
+
print(_("session.no_sessions"))
|
|
342
|
+
sys.exit(1)
|
|
343
|
+
|
|
344
|
+
return row["id"]
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def get_recent_sessions(db, limit=15, project_id=None):
|
|
348
|
+
"""Возвращает список последних сессий для интерактивного выбора."""
|
|
349
|
+
if project_id:
|
|
350
|
+
return db.execute(
|
|
351
|
+
"""
|
|
352
|
+
SELECT s.id, s.title, s.model, s.time_created, s.cost,
|
|
353
|
+
COUNT(DISTINCT m.id) as msg_count
|
|
354
|
+
FROM session s
|
|
355
|
+
LEFT JOIN message m ON m.session_id = s.id
|
|
356
|
+
WHERE s.project_id = ?
|
|
357
|
+
GROUP BY s.id
|
|
358
|
+
ORDER BY COALESCE(MAX(m.time_created), s.time_created) DESC
|
|
359
|
+
LIMIT ?
|
|
360
|
+
""",
|
|
361
|
+
(project_id, limit),
|
|
362
|
+
).fetchall()
|
|
363
|
+
return db.execute(
|
|
364
|
+
"""
|
|
365
|
+
SELECT s.id, s.title, s.model, s.time_created, s.cost,
|
|
366
|
+
COUNT(DISTINCT m.id) as msg_count
|
|
367
|
+
FROM session s
|
|
368
|
+
LEFT JOIN message m ON m.session_id = s.id
|
|
369
|
+
GROUP BY s.id
|
|
370
|
+
ORDER BY COALESCE(MAX(m.time_created), s.time_created) DESC
|
|
371
|
+
LIMIT ?
|
|
372
|
+
""",
|
|
373
|
+
(limit,),
|
|
374
|
+
).fetchall()
|
formatters.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Форматирование вывода: таблицы, JSON, Markdown."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
from config import IGNORE_DIRS
|
|
7
|
+
from i18n import _
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def print_json(data) -> None:
|
|
11
|
+
"""Выводит данные в JSON-формате."""
|
|
12
|
+
print(json.dumps(data, ensure_ascii=False, indent=2, default=str))
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def print_table_simple(headers, rows, sep=" ") -> None:
|
|
16
|
+
"""Выводит таблицу с авто-шириной колонок."""
|
|
17
|
+
if not rows:
|
|
18
|
+
print(_("empty"))
|
|
19
|
+
return
|
|
20
|
+
|
|
21
|
+
widths = []
|
|
22
|
+
for ci in range(len(headers)):
|
|
23
|
+
max_w = len(headers[ci])
|
|
24
|
+
for row in rows:
|
|
25
|
+
max_w = max(max_w, len(str(row[ci])))
|
|
26
|
+
widths.append(min(max_w + 1, 80))
|
|
27
|
+
|
|
28
|
+
line = sep.join(h.ljust(widths[i])[: widths[i]] for i, h in enumerate(headers))
|
|
29
|
+
sep_line = "─" * len(line)
|
|
30
|
+
|
|
31
|
+
print()
|
|
32
|
+
print(line)
|
|
33
|
+
print(sep_line)
|
|
34
|
+
for row in rows:
|
|
35
|
+
line = sep.join(str(row[i]).ljust(widths[i])[: widths[i]] for i in range(len(headers)))
|
|
36
|
+
print(line)
|
|
37
|
+
print(_("fmt.rows_count", n=len(rows)))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def summarize_val(v, max_len=120) -> str:
|
|
41
|
+
"""Обрезает длинное значение."""
|
|
42
|
+
s = str(v)
|
|
43
|
+
if len(s) > max_len:
|
|
44
|
+
s = s[:max_len] + "..."
|
|
45
|
+
return s
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def format_part_to_md(part, full=False):
|
|
49
|
+
"""Преобразует часть сообщения в строку Markdown.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
part: dict из get_messages()
|
|
53
|
+
full: если True — не обрезать длинные output'ы и input'ы
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
str — Markdown
|
|
57
|
+
"""
|
|
58
|
+
t = part["type"]
|
|
59
|
+
|
|
60
|
+
if t == "text":
|
|
61
|
+
return part.get("text", "").strip()
|
|
62
|
+
|
|
63
|
+
if t == "reasoning":
|
|
64
|
+
text = part.get("text", "").strip()
|
|
65
|
+
title = _("md.reasoning")
|
|
66
|
+
return f"**💭 {title}:**\n{text}" if text else ""
|
|
67
|
+
|
|
68
|
+
if t == "sound":
|
|
69
|
+
return _("*🔊 {msg}*", msg=_("md.sound"))
|
|
70
|
+
|
|
71
|
+
if t == "tool":
|
|
72
|
+
tool_name = part.get("tool", "?")
|
|
73
|
+
t_input = part.get("input", {})
|
|
74
|
+
t_output = part.get("output", "")
|
|
75
|
+
t_status = part.get("status", "")
|
|
76
|
+
|
|
77
|
+
icon = {"completed": "✅", "error": "❌", "running": "⏳"}.get(t_status, "🔧")
|
|
78
|
+
|
|
79
|
+
if tool_name in ("read", "write", "edit", "glob", "grep"):
|
|
80
|
+
if tool_name == "read":
|
|
81
|
+
fp = (
|
|
82
|
+
t_input.get("filePath", "")
|
|
83
|
+
if full
|
|
84
|
+
else summarize_val(t_input.get("filePath", ""))
|
|
85
|
+
)
|
|
86
|
+
return f"**{icon} read:** `{fp}`"
|
|
87
|
+
elif tool_name == "write":
|
|
88
|
+
fp = (
|
|
89
|
+
t_input.get("filePath", "")
|
|
90
|
+
if full
|
|
91
|
+
else summarize_val(t_input.get("filePath", ""), 80)
|
|
92
|
+
)
|
|
93
|
+
return f"**{icon} write:** `{fp}`"
|
|
94
|
+
elif tool_name == "edit":
|
|
95
|
+
fp = (
|
|
96
|
+
t_input.get("filePath", "")
|
|
97
|
+
if full
|
|
98
|
+
else summarize_val(t_input.get("filePath", ""), 80)
|
|
99
|
+
)
|
|
100
|
+
return f"**{icon} edit:** `{fp}`"
|
|
101
|
+
elif tool_name == "glob":
|
|
102
|
+
pat = (
|
|
103
|
+
t_input.get("pattern", "")
|
|
104
|
+
if full
|
|
105
|
+
else summarize_val(t_input.get("pattern", ""), 80)
|
|
106
|
+
)
|
|
107
|
+
pth = (
|
|
108
|
+
t_input.get("path", "") if full else summarize_val(t_input.get("path", ""), 60)
|
|
109
|
+
)
|
|
110
|
+
return f"**{icon} glob:** `{pat}` в `{pth}`"
|
|
111
|
+
elif tool_name == "grep":
|
|
112
|
+
pat = (
|
|
113
|
+
t_input.get("pattern", "")
|
|
114
|
+
if full
|
|
115
|
+
else summarize_val(t_input.get("pattern", ""), 80)
|
|
116
|
+
)
|
|
117
|
+
return f"**{icon} grep:** `{pat}`"
|
|
118
|
+
|
|
119
|
+
lines = [f"**{icon} {tool_name}**"]
|
|
120
|
+
|
|
121
|
+
if t_input:
|
|
122
|
+
for k, v in t_input.items():
|
|
123
|
+
if full:
|
|
124
|
+
s = str(v)
|
|
125
|
+
if len(s) > 500:
|
|
126
|
+
lines.append(f" {k}:\n\n```\n{s}\n```")
|
|
127
|
+
else:
|
|
128
|
+
lines.append(f" {k}: {s}")
|
|
129
|
+
else:
|
|
130
|
+
lines.append(f" {k}: {summarize_val(v, 200)}")
|
|
131
|
+
|
|
132
|
+
if t_output:
|
|
133
|
+
out = str(t_output).strip()
|
|
134
|
+
if not out:
|
|
135
|
+
pass
|
|
136
|
+
elif full:
|
|
137
|
+
if len(out) > 500:
|
|
138
|
+
lines.append(f"\n → {_('md.result')}:\n\n```\n{out}\n```")
|
|
139
|
+
else:
|
|
140
|
+
lines.append(f" → {out}")
|
|
141
|
+
else:
|
|
142
|
+
if len(out) > 300:
|
|
143
|
+
lines.append(f" → {summarize_val(out, 280)}")
|
|
144
|
+
else:
|
|
145
|
+
lines.append(f" → {out}")
|
|
146
|
+
|
|
147
|
+
return "\n".join(lines)
|
|
148
|
+
|
|
149
|
+
return ""
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def collect_snapshot(project_dir):
|
|
153
|
+
"""Собирает статистику по проекту для log.md.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
dict: {"agents_md": str, "file_count": int, "dir_count": int}
|
|
157
|
+
"""
|
|
158
|
+
snapshot = {}
|
|
159
|
+
|
|
160
|
+
agents_path = os.path.join(project_dir, "AGENTS.md")
|
|
161
|
+
if os.path.isfile(agents_path):
|
|
162
|
+
with open(agents_path, encoding="utf-8") as f:
|
|
163
|
+
content = f.read()
|
|
164
|
+
lines = content.count("\n") + 1
|
|
165
|
+
sections = content.count("\n## ")
|
|
166
|
+
snapshot["agents_md"] = _("md.log_lines", lines=lines, sections=sections)
|
|
167
|
+
else:
|
|
168
|
+
snapshot["agents_md"] = _("md.log_not_found")
|
|
169
|
+
|
|
170
|
+
all_files = 0
|
|
171
|
+
all_dirs = 0
|
|
172
|
+
for root, dirs, files in os.walk(project_dir):
|
|
173
|
+
dirs[:] = [d for d in dirs if d not in IGNORE_DIRS]
|
|
174
|
+
if os.path.relpath(root, project_dir) == ".":
|
|
175
|
+
all_files += len(files)
|
|
176
|
+
else:
|
|
177
|
+
all_dirs += 1
|
|
178
|
+
all_files += len(files)
|
|
179
|
+
|
|
180
|
+
snapshot["file_count"] = all_files
|
|
181
|
+
snapshot["dir_count"] = all_dirs
|
|
182
|
+
return snapshot
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def update_log(output_dir, title, note, dialog_filename, snapshot, now_str) -> None:
|
|
186
|
+
"""Создаёт или дополняет log.md в директории проекта."""
|
|
187
|
+
log_path = os.path.join(output_dir, "log.md")
|
|
188
|
+
heading = f"{title}: {note}" if note else title
|
|
189
|
+
dialog_link = os.path.basename(dialog_filename) if dialog_filename else "—"
|
|
190
|
+
|
|
191
|
+
agents_line = _("md.log_agents", v=snapshot["agents_md"])
|
|
192
|
+
files_line = _("md.log_files", files=snapshot["file_count"], dirs=snapshot["dir_count"])
|
|
193
|
+
|
|
194
|
+
entry = f"""## [{now_str}] {heading}
|
|
195
|
+
|
|
196
|
+
- **{_("md.header.title")}:** [[{dialog_link}]]
|
|
197
|
+
- **Snapshot:**
|
|
198
|
+
- {agents_line}
|
|
199
|
+
- {files_line}
|
|
200
|
+
|
|
201
|
+
---
|
|
202
|
+
|
|
203
|
+
"""
|
|
204
|
+
|
|
205
|
+
with open(log_path, "a", encoding="utf-8") as f:
|
|
206
|
+
f.write(entry)
|
|
207
|
+
|
|
208
|
+
print(_("log_updated", path=log_path))
|