opencode-db 0.5.0__tar.gz → 0.6.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 (38) hide show
  1. {opencode_db-0.5.0/opencode_db.egg-info → opencode_db-0.6.0}/PKG-INFO +1 -1
  2. {opencode_db-0.5.0 → opencode_db-0.6.0}/README.md +25 -24
  3. {opencode_db-0.5.0 → opencode_db-0.6.0}/cmd_costs.py +17 -2
  4. opencode_db-0.6.0/cmd_delete.py +343 -0
  5. {opencode_db-0.5.0 → opencode_db-0.6.0}/cmd_export.py +17 -2
  6. {opencode_db-0.5.0 → opencode_db-0.6.0}/cmd_help.py +28 -2
  7. {opencode_db-0.5.0 → opencode_db-0.6.0}/cmd_info.py +14 -2
  8. {opencode_db-0.5.0 → opencode_db-0.6.0}/cmd_list.py +16 -2
  9. {opencode_db-0.5.0 → opencode_db-0.6.0}/cmd_projects.py +13 -2
  10. opencode_db-0.6.0/cmd_prune.py +32 -0
  11. {opencode_db-0.5.0 → opencode_db-0.6.0}/cmd_search.py +14 -2
  12. {opencode_db-0.5.0 → opencode_db-0.6.0}/cmd_stats.py +13 -2
  13. {opencode_db-0.5.0 → opencode_db-0.6.0}/cmd_todos.py +16 -2
  14. {opencode_db-0.5.0 → opencode_db-0.6.0}/cmd_tree.py +15 -2
  15. {opencode_db-0.5.0 → opencode_db-0.6.0}/cmd_vacuum.py +13 -2
  16. {opencode_db-0.5.0 → opencode_db-0.6.0}/i18n.py +74 -4
  17. {opencode_db-0.5.0 → opencode_db-0.6.0/opencode_db.egg-info}/PKG-INFO +1 -1
  18. {opencode_db-0.5.0 → opencode_db-0.6.0}/pyproject.toml +1 -1
  19. {opencode_db-0.5.0 → opencode_db-0.6.0}/tests/test_commands.py +1 -1
  20. {opencode_db-0.5.0 → opencode_db-0.6.0}/utils.py +34 -0
  21. opencode_db-0.5.0/cmd_delete.py +0 -61
  22. opencode_db-0.5.0/cmd_prune.py +0 -127
  23. {opencode_db-0.5.0 → opencode_db-0.6.0}/LICENSE +0 -0
  24. {opencode_db-0.5.0 → opencode_db-0.6.0}/README_PYPI.md +0 -0
  25. {opencode_db-0.5.0 → opencode_db-0.6.0}/cli.py +0 -0
  26. {opencode_db-0.5.0 → opencode_db-0.6.0}/commands/__init__.py +0 -0
  27. {opencode_db-0.5.0 → opencode_db-0.6.0}/config.py +0 -0
  28. {opencode_db-0.5.0 → opencode_db-0.6.0}/db.py +0 -0
  29. {opencode_db-0.5.0 → opencode_db-0.6.0}/formatters.py +0 -0
  30. {opencode_db-0.5.0 → opencode_db-0.6.0}/opencode_db.egg-info/SOURCES.txt +0 -0
  31. {opencode_db-0.5.0 → opencode_db-0.6.0}/opencode_db.egg-info/dependency_links.txt +0 -0
  32. {opencode_db-0.5.0 → opencode_db-0.6.0}/opencode_db.egg-info/entry_points.txt +0 -0
  33. {opencode_db-0.5.0 → opencode_db-0.6.0}/opencode_db.egg-info/top_level.txt +0 -0
  34. {opencode_db-0.5.0 → opencode_db-0.6.0}/setup.cfg +0 -0
  35. {opencode_db-0.5.0 → opencode_db-0.6.0}/tests/test_db.py +0 -0
  36. {opencode_db-0.5.0 → opencode_db-0.6.0}/tests/test_formatters.py +0 -0
  37. {opencode_db-0.5.0 → opencode_db-0.6.0}/tests/test_i18n.py +0 -0
  38. {opencode_db-0.5.0 → opencode_db-0.6.0}/tests/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: opencode-db
3
- Version: 0.5.0
3
+ Version: 0.6.0
4
4
  Summary: OpenCode database CLI manager — browse, export, analyze, clean up sessions
5
5
  Project-URL: Homepage, https://github.com/VasilevNStas/opencode-db
6
6
  Project-URL: Source, https://github.com/VasilevNStas/opencode-db
@@ -210,20 +210,35 @@ If the project directory contains `.obsidian`, a `log.md` file is automatically
210
210
 
211
211
  ---
212
212
 
213
- ### `delete` — delete a session
213
+ ### `delete` — delete sessions
214
214
 
215
- Removes a session and all related data (messages, parts, todos) thanks to CASCADE constraints in the database schema.
215
+ Removes sessions and all related data (messages, parts, todos). Supports single deletion by ID or bulk deletion with filters.
216
216
 
217
217
  ```bash
218
- opencode-db delete ses_1c9d --dry-run # show what would be deleted
219
- opencode-db delete ses_1c9d # delete with confirmation
220
- opencode-db delete ses_1c9d --force # delete without confirmation
218
+ opencode-db delete ses_1c9d # single session with confirmation
219
+ opencode-db delete ses_1c9d --force # single session, no confirmation
220
+ opencode-db delete ses_1c9d --dry-run # preview what would be deleted
221
+ opencode-db delete --older-than 90d # sessions older than 90 days
222
+ opencode-db delete --older-than 6m --dry-run # preview sessions older than 6 months
223
+ opencode-db delete --keep-last 30 # keep 30 most recent, delete the rest
224
+ opencode-db delete --before 2026-05-20 # sessions before a specific date
225
+ opencode-db delete --after 2026-01-01 # sessions after a specific date
226
+ opencode-db delete --project proj_xxx # sessions in a specific project
227
+ opencode-db delete --older-than 90d --keep-last 20 # combined filters
228
+ opencode-db delete --interactive # choose from a list interactively
221
229
  ```
222
230
 
223
231
  | Flag | Description |
224
232
  |---|---|
233
+ | `session_id` | Session ID (optional with `--interactive`) |
234
+ | `--older-than` | Delete sessions older than (e.g. `30d`, `6m`, `1y`) |
235
+ | `--before` | Delete sessions before date (`YYYY-MM-DD`) |
236
+ | `--after` | Delete sessions after date (`YYYY-MM-DD`) |
237
+ | `--keep-last` | Keep N most recent, delete the rest |
238
+ | `--project` | Limit to a specific project |
225
239
  | `--dry-run` | Only show what would be deleted |
226
240
  | `--force`, `-f` | Skip confirmation prompt |
241
+ | `--interactive` | Pick sessions from a list |
227
242
 
228
243
  ---
229
244
 
@@ -252,31 +267,17 @@ Costs are taken directly from the database (`session.cost`), which stores the ac
252
267
 
253
268
  ---
254
269
 
255
- ### `prune` — bulk cleanup of old sessions
270
+ ### `prune` — alias for `delete` (bulk cleanup)
256
271
 
257
- Deletes sessions by age, project, or keeps a specified number of the most recent ones.
272
+ Alias for `delete` with the same flags: `--older-than`, `--keep-last`, `--project`, `--dry-run`, `--force`.
258
273
 
259
274
  ```bash
260
- opencode-db prune --older-than 30d --dry-run # preview deletions
261
- opencode-db prune --older-than 90d # delete sessions older than 90 days
262
- opencode-db prune --older-than 6m # older than 6 months
263
- opencode-db prune --older-than 1y # older than 1 year
264
- opencode-db prune --keep-last 20 # keep the 20 most recent
265
- opencode-db prune --older-than 30d --keep-last 10 # combined filters
266
- opencode-db prune --project proj_xxx # per project
267
- opencode-db prune --older-than 30d --force # skip confirmation
275
+ opencode-db prune --older-than 90d # same as opencode-db delete --older-than 90d
276
+ opencode-db prune --keep-last 20 # same as opencode-db delete --keep-last 20
268
277
  ```
269
278
 
270
279
  Time spec formats: `30d` (days), `6m` (months), `1y` (years).
271
280
 
272
- | Flag | Description |
273
- |---|---|
274
- | `--older-than SPEC` | Delete sessions older than the given duration |
275
- | `--keep-last N` | Keep N most recent sessions |
276
- | `--project ID` | Limit to a specific project |
277
- | `--dry-run` | Only show what would be deleted |
278
- | `--force`, `-f` | Skip confirmation prompt |
279
-
280
281
  ---
281
282
 
282
283
  ### `search` — search across messages
@@ -409,7 +410,7 @@ Example output:
409
410
 
410
411
  ```
411
412
  ╔══════════════════════════════════════════════════════════╗
412
- ║ opencode-db — справка ║
413
+ ║ opencode-db — справка ║
413
414
  ╚══════════════════════════════════════════════════════════╝
414
415
 
415
416
  Использование: opencode-db <команда> [флаги]
@@ -3,13 +3,28 @@
3
3
  Стоимость берётся напрямую из поля session.cost БД OpenCode.
4
4
  """
5
5
 
6
+ import argparse
7
+
6
8
  from db import SessionError, get_session_info, get_session_title, parse_model
7
9
  from i18n import _
8
- from utils import format_cost, format_tokens
10
+ from utils import build_help_epilog, format_cost, format_tokens
11
+
12
+ _COSTS_EXAMPLES = [
13
+ ("", "help.costs.e0"),
14
+ ("<session_id>", "help.costs.e1"),
15
+ ("--total", "help.costs.e2"),
16
+ ("--project proj_xxx", "help.costs.e3"),
17
+ ("--json", "help.costs.e4"),
18
+ ]
9
19
 
10
20
 
11
21
  def register(subparsers) -> None:
12
- p = subparsers.add_parser("costs", help=_("help.cmd.costs"))
22
+ p = subparsers.add_parser(
23
+ "costs",
24
+ help=_("help.cmd.costs"),
25
+ formatter_class=argparse.RawDescriptionHelpFormatter,
26
+ epilog=build_help_epilog("costs", _COSTS_EXAMPLES),
27
+ )
13
28
  p.add_argument("session_id", nargs="?", help="Session ID")
14
29
  p.add_argument("--project", type=str, help="Filter by project")
15
30
  p.add_argument("--total", action="store_true", help="Total across all sessions")
@@ -0,0 +1,343 @@
1
+ """Команда delete: удаление сессии или массовая очистка."""
2
+
3
+ import argparse
4
+
5
+ from db import (
6
+ SessionError,
7
+ get_message_count,
8
+ get_part_count,
9
+ get_recent_sessions,
10
+ get_session_info,
11
+ get_session_title,
12
+ parse_model,
13
+ )
14
+ from i18n import _
15
+ from utils import build_help_epilog, confirm, format_cost, format_ts, parse_date, parse_time_spec
16
+
17
+ _DELETE_EXAMPLES = [
18
+ ("<session_id>", "help.delete.e0"),
19
+ ("<session_id> --dry-run", "help.delete.e1"),
20
+ ("<session_id> --force", "help.delete.e2"),
21
+ ("--older-than 90d", "help.delete.e3"),
22
+ ("--keep-last 30", "help.delete.e4"),
23
+ ("--before 2026-05-20", "help.delete.e5"),
24
+ ("--interactive", "help.delete.e6"),
25
+ ]
26
+
27
+
28
+ def register(subparsers) -> None:
29
+ p = subparsers.add_parser(
30
+ "delete",
31
+ help=_("help.cmd.delete"),
32
+ formatter_class=argparse.RawDescriptionHelpFormatter,
33
+ epilog=build_help_epilog("delete", _DELETE_EXAMPLES),
34
+ )
35
+ p.add_argument("session_id", nargs="?", help="Session ID (optional with --interactive)")
36
+ p.add_argument("--older-than", type=str, help="Delete sessions older than (30d, 6m, 1y)")
37
+ p.add_argument("--before", type=str, help="Delete sessions before date (YYYY-MM-DD)")
38
+ p.add_argument("--after", type=str, help="Delete sessions after date (YYYY-MM-DD)")
39
+ p.add_argument("--keep-last", type=int, help="Keep N most recent sessions")
40
+ p.add_argument("--project", type=str, help="Limit to project")
41
+ p.add_argument("--dry-run", action="store_true", help="Preview without deleting")
42
+ p.add_argument("--force", "-f", action="store_true", help="Skip confirmation")
43
+ p.add_argument("--interactive", action="store_true", help="Choose sessions from a list")
44
+
45
+
46
+ def _delete_single(db, session_id, dry_run, force) -> int:
47
+ """Удаление одной сессии по ID."""
48
+ try:
49
+ info = get_session_info(db, session_id)
50
+ except SessionError as e:
51
+ print(e.message)
52
+ return 1
53
+ sid = info["id"]
54
+ title = get_session_title(info)
55
+ model = parse_model(info)
56
+ msg_count = get_message_count(db, sid)
57
+ part_count = get_part_count(db, sid)
58
+ created = format_ts(info["time_created"])
59
+
60
+ print(f"\n{'=' * 50}")
61
+ print(f" {_('delete.header')}")
62
+ print(f"{'=' * 50}")
63
+ print(f" {_('delete.id')}: {sid}")
64
+ print(f" {_('delete.title')}: {title}")
65
+ print(f" {_('delete.model')}: {model}")
66
+ print(f" {_('delete.created')}: {created}")
67
+ print(f" {_('delete.messages')}: {msg_count}")
68
+ print(f" {_('delete.parts')}: {part_count}")
69
+ print(f" {_('delete.cost')}: {format_cost(info['cost'])}")
70
+
71
+ if dry_run:
72
+ print(f"\n{_('delete.dry_run')}")
73
+ print(f" {_('delete.dry_run_hint')}")
74
+ return 0
75
+
76
+ if not force and not confirm(_("delete.confirm", id=sid[:24])):
77
+ print(_("canceled"))
78
+ return 0
79
+
80
+ db.execute("DELETE FROM session WHERE id = ?", (sid,))
81
+ db.commit()
82
+
83
+ print(f"\n{_('delete.done', id=sid[:24])}")
84
+ print(_("delete.done_detail", msg=msg_count, parts=part_count))
85
+ return 0
86
+
87
+
88
+ def _build_mass_conditions(args):
89
+ """Строит условия WHERE для массового удаления."""
90
+ conditions = []
91
+ params = []
92
+ project = _a(args, "project")
93
+ older_than = _a(args, "older_than")
94
+ before = _a(args, "before")
95
+ after = _a(args, "after")
96
+
97
+ if project:
98
+ conditions.append("s.project_id = ?")
99
+ params.append(project)
100
+
101
+ if older_than:
102
+ cutoff = parse_time_spec(older_than)
103
+ if cutoff is None:
104
+ return None, None, _("delete.bad_spec", spec=older_than)
105
+ conditions.append("s.time_created < ?")
106
+ params.append(int(cutoff.timestamp() * 1000))
107
+
108
+ if before:
109
+ dt = parse_date(before)
110
+ if dt is None:
111
+ return None, None, _("delete.bad_date", date=before)
112
+ conditions.append("s.time_created < ?")
113
+ params.append(int(dt.timestamp() * 1000))
114
+
115
+ if after:
116
+ dt = parse_date(after)
117
+ if dt is None:
118
+ return None, None, _("delete.bad_date", date=after)
119
+ conditions.append("s.time_created > ?")
120
+ params.append(int(dt.timestamp() * 1000))
121
+
122
+ return conditions, params, None
123
+
124
+
125
+ def _collect_mass_targets(db, conditions, params, keep_last):
126
+ """Собирает список сессий для массового удаления."""
127
+ exclude_ids = set()
128
+ if keep_last:
129
+ inner_where = " AND ".join(conditions) if conditions else "1=1"
130
+ keep_rows = db.execute(
131
+ f"""
132
+ SELECT s.id FROM session s
133
+ WHERE {inner_where}
134
+ ORDER BY s.time_created DESC
135
+ LIMIT ?
136
+ """,
137
+ (*params, keep_last),
138
+ ).fetchall()
139
+ exclude_ids = {r["id"] for r in keep_rows}
140
+
141
+ where = " AND ".join(conditions) if conditions else "1=1"
142
+
143
+ if exclude_ids:
144
+ placeholders = ",".join("?" for _ in exclude_ids)
145
+ rows = db.execute(
146
+ f"""
147
+ SELECT s.id, s.title, s.model, s.time_created, s.cost
148
+ FROM session s
149
+ WHERE {where}
150
+ AND s.id NOT IN ({placeholders})
151
+ ORDER BY s.time_created DESC
152
+ """,
153
+ (*params, *exclude_ids),
154
+ ).fetchall()
155
+ else:
156
+ rows = db.execute(
157
+ f"""
158
+ SELECT s.id, s.title, s.model, s.time_created, s.cost
159
+ FROM session s
160
+ WHERE {where}
161
+ ORDER BY s.time_created DESC
162
+ """,
163
+ params,
164
+ ).fetchall()
165
+
166
+ return rows
167
+
168
+
169
+ def _delete_mass(db, args) -> int:
170
+ """Массовое удаление сессий."""
171
+ keep_last = _a(args, "keep_last")
172
+ dry_run = _a(args, "dry_run", False)
173
+ force = _a(args, "force", False)
174
+
175
+ conditions, params, err = _build_mass_conditions(args)
176
+ if err:
177
+ print(err)
178
+ return 1
179
+
180
+ if not conditions and not keep_last:
181
+ print(_("delete.filter_required"))
182
+ return 1
183
+
184
+ target_rows = _collect_mass_targets(db, conditions, params, keep_last)
185
+
186
+ if not target_rows:
187
+ print(_("delete.none"))
188
+ return 0
189
+
190
+ total_cost = sum(r["cost"] or 0 for r in target_rows)
191
+ total_tokens = sum(
192
+ db.execute(
193
+ "SELECT COALESCE(SUM(tokens_input + tokens_output), 0) FROM session WHERE id = ?",
194
+ (r["id"],),
195
+ ).fetchone()[0]
196
+ for r in target_rows
197
+ )
198
+
199
+ print(f"\n {'=' * 55}")
200
+ print(f" {_('delete.mass_header', n=len(target_rows))}")
201
+ print(f" {'=' * 55}")
202
+
203
+ for r in target_rows[:20]:
204
+ created = format_ts(r["time_created"])
205
+ print(f" • {r['id'][:24]} {created} {r['title'] or '—'}")
206
+
207
+ if len(target_rows) > 20:
208
+ print(f" … и ещё {len(target_rows) - 20}")
209
+
210
+ print(f"\n {_('delete.cost_total', cost=f'${total_cost:.4f}')}")
211
+ print(f" {_('delete.tokens_total', n=str(total_tokens))}")
212
+
213
+ if dry_run:
214
+ print(f"\n{_('delete.dry_run')}")
215
+ return 0
216
+
217
+ if not force and not confirm(_("delete.mass_confirm", n=len(target_rows))):
218
+ print(_("canceled"))
219
+ return 0
220
+
221
+ ids = [r["id"] for r in target_rows]
222
+ placeholders = ",".join("?" for _ in ids)
223
+
224
+ db.execute(f"DELETE FROM part WHERE session_id IN ({placeholders})", ids)
225
+ db.execute(f"DELETE FROM message WHERE session_id IN ({placeholders})", ids)
226
+ db.execute(f"DELETE FROM todo WHERE session_id IN ({placeholders})", ids)
227
+ db.execute(f"DELETE FROM session WHERE id IN ({placeholders})", ids)
228
+ db.commit()
229
+
230
+ print(f"\n{_('delete.done_mass', n=len(ids))}")
231
+ return 0
232
+
233
+
234
+ def _delete_interactive(db, args) -> int:
235
+ """Интерактивное удаление — выбор из списка последних сессий."""
236
+ sessions = get_recent_sessions(db, limit=20)
237
+
238
+ if not sessions:
239
+ print(_("delete.no_sessions"))
240
+ return 1
241
+
242
+ print(f"\n {_('delete.interactive_header')}")
243
+ print(f" {'─' * 55}")
244
+ for i, s in enumerate(sessions, 1):
245
+ title = get_session_title(s)
246
+ project = s.get("project_id", "") or ""
247
+ print(f" {i:2d}) {s['id'][:24]} {title[:40]:40s} {project[:20]}")
248
+
249
+ print()
250
+ choices = input(_("delete.interactive_prompt")).strip()
251
+
252
+ if not choices:
253
+ print(_("canceled"))
254
+ return 0
255
+
256
+ selected = _parse_choices(choices, len(sessions))
257
+ if not selected:
258
+ return 1
259
+
260
+ target_rows = [sessions[i - 1] for i in selected]
261
+ total_cost = sum(r["cost"] or 0 for r in target_rows)
262
+
263
+ print(f"\n {'=' * 55}")
264
+ print(f" {_('delete.mass_header', n=len(target_rows))}")
265
+ print(f" {'=' * 55}")
266
+ for r in target_rows:
267
+ created = format_ts(r["time_created"])
268
+ print(f" • {r['id'][:24]} {created} {r['title'] or '—'}")
269
+
270
+ dry_run = _a(args, "dry_run", False)
271
+ force = _a(args, "force", False)
272
+ print(f"\n {_('delete.cost_total', cost=f'${total_cost:.4f}')}")
273
+
274
+ if dry_run:
275
+ print(f"\n{_('delete.dry_run')}")
276
+ return 0
277
+
278
+ if not force and not confirm(_("delete.mass_confirm", n=len(target_rows))):
279
+ print(_("canceled"))
280
+ return 0
281
+
282
+ ids = [r["id"] for r in target_rows]
283
+ placeholders = ",".join("?" for _ in ids)
284
+
285
+ db.execute(f"DELETE FROM part WHERE session_id IN ({placeholders})", ids)
286
+ db.execute(f"DELETE FROM message WHERE session_id IN ({placeholders})", ids)
287
+ db.execute(f"DELETE FROM todo WHERE session_id IN ({placeholders})", ids)
288
+ db.execute(f"DELETE FROM session WHERE id IN ({placeholders})", ids)
289
+ db.commit()
290
+
291
+ print(f"\n{_('delete.done_mass', n=len(ids))}")
292
+ return 0
293
+
294
+
295
+ def _parse_choices(text: str, max_n: int) -> list[int]:
296
+ """Парсит ввод пользователя вида '1,3-5,7' в список индексов."""
297
+ result = set()
298
+ for part in text.replace(" ", "").split(","):
299
+ if not part:
300
+ continue
301
+ if "-" in part:
302
+ try:
303
+ a, b = part.split("-", 1)
304
+ for i in range(int(a), int(b) + 1):
305
+ if 1 <= i <= max_n:
306
+ result.add(i)
307
+ except ValueError:
308
+ continue
309
+ else:
310
+ try:
311
+ i = int(part)
312
+ if 1 <= i <= max_n:
313
+ result.add(i)
314
+ except ValueError:
315
+ continue
316
+ return sorted(result)
317
+
318
+
319
+ def _a(args, name: str, default=None):
320
+ """getattr с дефолтом — для совместимости Namespace из разных парсеров."""
321
+ return getattr(args, name, default)
322
+
323
+
324
+ def run(args, db) -> int:
325
+ if _a(args, "interactive", False):
326
+ return _delete_interactive(db, args)
327
+
328
+ if _a(args, "session_id"):
329
+ session_id = _a(args, "session_id")
330
+ dry_run = _a(args, "dry_run", False)
331
+ force = _a(args, "force", False)
332
+ return _delete_single(db, session_id, dry_run, force)
333
+
334
+ older_than = _a(args, "older_than")
335
+ before = _a(args, "before")
336
+ after = _a(args, "after")
337
+ keep_last = _a(args, "keep_last")
338
+ project = _a(args, "project")
339
+ if older_than or before or after or keep_last or project:
340
+ return _delete_mass(db, args)
341
+
342
+ print(_("delete.usage"))
343
+ return 1
@@ -6,6 +6,7 @@
6
6
  - export (без аргументов) — интерактивный выбор из списка
7
7
  """
8
8
 
9
+ import argparse
9
10
  import os
10
11
 
11
12
  from db import (
@@ -19,11 +20,25 @@ from db import (
19
20
  )
20
21
  from formatters import collect_snapshot, format_part_to_md, update_log
21
22
  from i18n import _
22
- from utils import format_cost, format_ts, format_ts_short
23
+ from utils import build_help_epilog, format_cost, format_ts, format_ts_short
24
+
25
+ _EXPORT_EXAMPLES = [
26
+ ("<session_id>", "help.export.e0"),
27
+ ("--latest", "help.export.e1"),
28
+ ("", "help.export.e2"),
29
+ ("--full", "help.export.e3"),
30
+ ("--force", "help.export.e4"),
31
+ ('--note "text"', "help.export.e5"),
32
+ ]
23
33
 
24
34
 
25
35
  def register(subparsers) -> None:
26
- p = subparsers.add_parser("export", help=_("help.cmd.export"))
36
+ p = subparsers.add_parser(
37
+ "export",
38
+ help=_("help.cmd.export"),
39
+ formatter_class=argparse.RawDescriptionHelpFormatter,
40
+ epilog=build_help_epilog("export", _EXPORT_EXAMPLES),
41
+ )
27
42
  p.add_argument(
28
43
  "session_id",
29
44
  nargs="?",
@@ -1,17 +1,37 @@
1
1
  """Команда help: подробная справка по opencode-db."""
2
2
 
3
+ import argparse
3
4
  from typing import Literal
4
5
 
5
6
  from i18n import _
7
+ from utils import build_help_epilog
8
+
9
+ _HELP_EXAMPLES = [
10
+ ("", "help.help.e0"),
11
+ ("<command>", "help.help.e1"),
12
+ ]
6
13
 
7
14
 
8
15
  def register(subparsers) -> None:
9
- p = subparsers.add_parser("help", help=_("help.cmd.help"))
16
+ p = subparsers.add_parser(
17
+ "help",
18
+ help=_("help.cmd.help"),
19
+ formatter_class=argparse.RawDescriptionHelpFormatter,
20
+ epilog=build_help_epilog("help", _HELP_EXAMPLES),
21
+ )
10
22
  p.add_argument("topic", nargs="?", help="Command name")
11
23
  p.add_argument("--json", action="store_true", help="JSON output")
12
24
 
13
25
 
14
26
  _COMMANDS = [
27
+ (
28
+ "help",
29
+ "help.cmd.help",
30
+ [
31
+ ("", "help.help.e0"),
32
+ ("<command>", "help.help.e1"),
33
+ ],
34
+ ),
15
35
  (
16
36
  "list",
17
37
  "help.cmd.list",
@@ -173,8 +193,14 @@ def _print_command_help(cmd_name) -> Literal[1] | Literal[0]:
173
193
 
174
194
  desc_key, examples = entry
175
195
  print()
176
- print(_("help.cmd_header", cmd=cmd_name, desc=_(desc_key)))
196
+ print(f" {'─' * 55}")
197
+ print(f" {_('help.cmd_header', cmd=cmd_name, desc=_(desc_key))}")
198
+ print(f" {'─' * 55}")
199
+ print()
200
+ print(f" {_('help.flags_header', cmd=cmd_name)}")
201
+ print(f" opencode-db {cmd_name} --help")
177
202
  print()
203
+ print(f" {_('help.usage_header', cmd=cmd_name)}")
178
204
  for flag, example_key in examples:
179
205
  example_desc = _(example_key)
180
206
  if flag:
@@ -1,5 +1,7 @@
1
1
  """Команда info: детальная информация о сессии."""
2
2
 
3
+ import argparse
4
+
3
5
  from db import (
4
6
  SessionError,
5
7
  get_children_sessions,
@@ -13,11 +15,21 @@ from db import (
13
15
  parse_model,
14
16
  )
15
17
  from i18n import _
16
- from utils import format_cost, format_tokens, format_ts
18
+ from utils import build_help_epilog, format_cost, format_tokens, format_ts
19
+
20
+ _INFO_EXAMPLES = [
21
+ ("<session_id>", "help.info.e0"),
22
+ ("<session_id> --json", "help.info.e1"),
23
+ ]
17
24
 
18
25
 
19
26
  def register(subparsers) -> None:
20
- p = subparsers.add_parser("info", help=_("help.cmd.info"))
27
+ p = subparsers.add_parser(
28
+ "info",
29
+ help=_("help.cmd.info"),
30
+ formatter_class=argparse.RawDescriptionHelpFormatter,
31
+ epilog=build_help_epilog("info", _INFO_EXAMPLES),
32
+ )
21
33
  p.add_argument("session_id", help="Session ID")
22
34
  p.add_argument("--json", action="store_true", help="JSON output")
23
35
 
@@ -1,16 +1,30 @@
1
1
  """Команда list: список сессий с фильтрацией и сортировкой."""
2
2
 
3
+ import argparse
3
4
  from typing import Literal
4
5
 
5
6
  import db as db_module
6
7
  from db import get_session_title, parse_model
7
8
  from formatters import print_json, print_table_simple
8
9
  from i18n import _
9
- from utils import format_ts
10
+ from utils import build_help_epilog, format_ts
11
+
12
+ _LIST_EXAMPLES = [
13
+ ("", "help.list.e0"),
14
+ ("--limit 10", "help.list.e1"),
15
+ ("--sort cost", "help.list.e2"),
16
+ ("--project proj_xxx", "help.list.e3"),
17
+ ("--json", "help.list.e4"),
18
+ ]
10
19
 
11
20
 
12
21
  def register(subparsers) -> None:
13
- p = subparsers.add_parser("list", help=_("help.cmd.list"))
22
+ p = subparsers.add_parser(
23
+ "list",
24
+ help=_("help.cmd.list"),
25
+ formatter_class=argparse.RawDescriptionHelpFormatter,
26
+ epilog=build_help_epilog("list", _LIST_EXAMPLES),
27
+ )
14
28
  p.add_argument("--limit", type=int, default=25, help="Max results (default 25)")
15
29
  p.add_argument("--offset", type=int, default=0, help="Offset")
16
30
  p.add_argument("--project", type=str, help="Filter by project ID")
@@ -1,13 +1,24 @@
1
1
  """Команда projects: список проектов со статистикой."""
2
2
 
3
+ import argparse
3
4
  from typing import Literal
4
5
 
5
6
  from i18n import _
6
- from utils import format_cost, format_tokens, format_ts
7
+ from utils import build_help_epilog, format_cost, format_tokens, format_ts
8
+
9
+ _PROJECTS_EXAMPLES = [
10
+ ("", "help.projects.e0"),
11
+ ("--json", "help.projects.e1"),
12
+ ]
7
13
 
8
14
 
9
15
  def register(subparsers) -> None:
10
- p = subparsers.add_parser("projects", help=_("help.cmd.projects"))
16
+ p = subparsers.add_parser(
17
+ "projects",
18
+ help=_("help.cmd.projects"),
19
+ formatter_class=argparse.RawDescriptionHelpFormatter,
20
+ epilog=build_help_epilog("projects", _PROJECTS_EXAMPLES),
21
+ )
11
22
  p.add_argument("--json", action="store_true", help="JSON output")
12
23
 
13
24
 
@@ -0,0 +1,32 @@
1
+ """Команда prune: массовая очистка (alias для delete)."""
2
+
3
+ import argparse
4
+
5
+ from cmd_delete import run as delete_run
6
+ from i18n import _
7
+ from utils import build_help_epilog
8
+
9
+ _PRUNE_EXAMPLES = [
10
+ ("--older-than 90d", "help.prune.e0"),
11
+ ("--keep-last 20", "help.prune.e1"),
12
+ ("--dry-run", "help.prune.e2"),
13
+ ("--force", "help.prune.e3"),
14
+ ]
15
+
16
+
17
+ def register(subparsers) -> None:
18
+ p = subparsers.add_parser(
19
+ "prune",
20
+ help=_("help.cmd.prune"),
21
+ formatter_class=argparse.RawDescriptionHelpFormatter,
22
+ epilog=build_help_epilog("prune", _PRUNE_EXAMPLES),
23
+ )
24
+ p.add_argument("--older-than", type=str, help="Delete sessions older than (30d, 6m, 1y)")
25
+ p.add_argument("--keep-last", type=int, help="Keep N most recent sessions")
26
+ p.add_argument("--project", type=str, help="Limit to project")
27
+ p.add_argument("--dry-run", action="store_true", help="Preview without deleting")
28
+ p.add_argument("--force", "-f", action="store_true", help="Skip confirmation")
29
+
30
+
31
+ def run(args, db) -> int:
32
+ return delete_run(args, db)
@@ -1,14 +1,26 @@
1
1
  """Команда search: поиск по тексту сообщений и частей."""
2
2
 
3
+ import argparse
3
4
  import json
4
5
 
5
6
  from db import SessionError, resolve_session_id
6
7
  from i18n import _
7
- from utils import format_ts
8
+ from utils import build_help_epilog, format_ts
9
+
10
+ _SEARCH_EXAMPLES = [
11
+ ('"text"', "help.search.e0"),
12
+ ('"text" --session ses_xxx', "help.search.e1"),
13
+ ('"text" --json', "help.search.e2"),
14
+ ]
8
15
 
9
16
 
10
17
  def register(subparsers) -> None:
11
- p = subparsers.add_parser("search", help=_("help.cmd.search"))
18
+ p = subparsers.add_parser(
19
+ "search",
20
+ help=_("help.cmd.search"),
21
+ formatter_class=argparse.RawDescriptionHelpFormatter,
22
+ epilog=build_help_epilog("search", _SEARCH_EXAMPLES),
23
+ )
12
24
  p.add_argument("query", help="Search text")
13
25
  p.add_argument("--session", type=str, help="Limit to session")
14
26
  p.add_argument("--limit", type=int, default=30, help="Max results")
@@ -1,14 +1,25 @@
1
1
  """Команда stats: общая статистика по базе данных."""
2
2
 
3
+ import argparse
3
4
  from typing import Literal
4
5
 
5
6
  from db import parse_model
6
7
  from i18n import _
7
- from utils import format_cost, format_tokens, format_ts
8
+ from utils import build_help_epilog, format_cost, format_tokens, format_ts
9
+
10
+ _STATS_EXAMPLES = [
11
+ ("", "help.stats.e0"),
12
+ ("--json", "help.stats.e1"),
13
+ ]
8
14
 
9
15
 
10
16
  def register(subparsers) -> None:
11
- p = subparsers.add_parser("stats", help=_("help.cmd.stats"))
17
+ p = subparsers.add_parser(
18
+ "stats",
19
+ help=_("help.cmd.stats"),
20
+ formatter_class=argparse.RawDescriptionHelpFormatter,
21
+ epilog=build_help_epilog("stats", _STATS_EXAMPLES),
22
+ )
12
23
  p.add_argument("--json", action="store_true", help="JSON output")
13
24
 
14
25
 
@@ -1,12 +1,26 @@
1
1
  """Команда todos: просмотр задач (todo) из сессий."""
2
2
 
3
+ import argparse
4
+
3
5
  from db import SessionError, resolve_session_id
4
6
  from i18n import _
5
- from utils import format_ts
7
+ from utils import build_help_epilog, format_ts
8
+
9
+ _TODOS_EXAMPLES = [
10
+ ("", "help.todos.e0"),
11
+ ("<session_id>", "help.todos.e1"),
12
+ ("--status pending", "help.todos.e2"),
13
+ ("--json", "help.todos.e3"),
14
+ ]
6
15
 
7
16
 
8
17
  def register(subparsers) -> None:
9
- p = subparsers.add_parser("todos", help=_("help.cmd.todos"))
18
+ p = subparsers.add_parser(
19
+ "todos",
20
+ help=_("help.cmd.todos"),
21
+ formatter_class=argparse.RawDescriptionHelpFormatter,
22
+ epilog=build_help_epilog("todos", _TODOS_EXAMPLES),
23
+ )
10
24
  p.add_argument("session_id", nargs="?", help="Session ID")
11
25
  p.add_argument(
12
26
  "--status",
@@ -1,12 +1,25 @@
1
1
  """Команда tree: дерево ветвления сессий."""
2
2
 
3
+ import argparse
4
+
3
5
  from db import SessionError, get_session_title, resolve_session_id
4
6
  from i18n import _
5
- from utils import format_ts
7
+ from utils import build_help_epilog, format_ts
8
+
9
+ _TREE_EXAMPLES = [
10
+ ("", "help.tree.e0"),
11
+ ("<session_id>", "help.tree.e1"),
12
+ ("--depth 3", "help.tree.e2"),
13
+ ]
6
14
 
7
15
 
8
16
  def register(subparsers) -> None:
9
- p = subparsers.add_parser("tree", help=_("help.cmd.tree"))
17
+ p = subparsers.add_parser(
18
+ "tree",
19
+ help=_("help.cmd.tree"),
20
+ formatter_class=argparse.RawDescriptionHelpFormatter,
21
+ epilog=build_help_epilog("tree", _TREE_EXAMPLES),
22
+ )
10
23
  p.add_argument("session_id", nargs="?", help="Start from this session")
11
24
  p.add_argument("--project", type=str, help="Filter by project")
12
25
  p.add_argument("--depth", type=int, default=5, help="Max depth")
@@ -1,15 +1,26 @@
1
1
  """Команда vacuum: оптимизация базы данных."""
2
2
 
3
+ import argparse
3
4
  import os
4
5
  from typing import Literal
5
6
 
6
7
  from config import OPENCODE_DB
7
8
  from i18n import _
8
- from utils import confirm
9
+ from utils import build_help_epilog, confirm
10
+
11
+ _VACUUM_EXAMPLES = [
12
+ ("", "help.vacuum.e0"),
13
+ ("--force", "help.vacuum.e1"),
14
+ ]
9
15
 
10
16
 
11
17
  def register(subparsers) -> None:
12
- p = subparsers.add_parser("vacuum", help=_("help.cmd.vacuum"))
18
+ p = subparsers.add_parser(
19
+ "vacuum",
20
+ help=_("help.cmd.vacuum"),
21
+ formatter_class=argparse.RawDescriptionHelpFormatter,
22
+ epilog=build_help_epilog("vacuum", _VACUUM_EXAMPLES),
23
+ )
13
24
  p.add_argument("--force", "-f", action="store_true", help="Skip confirmation")
14
25
 
15
26
 
@@ -185,6 +185,40 @@ L: dict[str, dict[str, str]] = {
185
185
  "ru": " Сообщений: {msg}, частей: {parts}",
186
186
  "en": " Messages: {msg}, parts: {parts}",
187
187
  },
188
+ "delete.none": {"ru": " ✅ Нет сессий для удаления.", "en": " ✅ No sessions to delete."},
189
+ "delete.filter_required": {
190
+ "ru": "❌ Укажи ID сессии, фильтр, или --interactive",
191
+ "en": "❌ Specify session ID, a filter, or --interactive",
192
+ },
193
+ "delete.usage": {
194
+ "ru": " Использование: opencode-db delete <session_id>",
195
+ "en": " Usage: opencode-db delete <session_id>",
196
+ },
197
+ "delete.bad_spec": {
198
+ "ru": "❌ Не могу разобрать: --older-than {spec}",
199
+ "en": "❌ Cannot parse: --older-than {spec}",
200
+ },
201
+ "delete.bad_date": {
202
+ "ru": "❌ Неверный формат даты: {date}. Используй ГГГГ-ММ-ДД",
203
+ "en": "❌ Invalid date format: {date}. Use YYYY-MM-DD",
204
+ },
205
+ "delete.mass_header": {
206
+ "ru": "Будет удалено сессий: {n}",
207
+ "en": "Sessions to delete: {n}",
208
+ },
209
+ "delete.cost_total": {
210
+ "ru": "Стоимость удаляемых: {cost}",
211
+ "en": "Cost of deleted sessions: {cost}",
212
+ },
213
+ "delete.tokens_total": {"ru": "Освободится токенов: ~{n}", "en": "Tokens freed: ~{n}"},
214
+ "delete.mass_confirm": {"ru": "Удалить {n} сессий?", "en": "Delete {n} sessions?"},
215
+ "delete.done_mass": {"ru": " ✅ Удалено {n} сессий.", "en": " ✅ Deleted {n} sessions."},
216
+ "delete.interactive_header": {"ru": "Последние сессии:", "en": "Recent sessions:"},
217
+ "delete.interactive_prompt": {
218
+ "ru": " Введи номера (1,3-5) или Enter для отмены: ",
219
+ "en": " Enter numbers (1,3-5) or Enter to cancel: ",
220
+ },
221
+ "delete.no_sessions": {"ru": "❌ Нет сессий для удаления.", "en": "❌ No sessions to delete."},
188
222
  # ==================================================================
189
223
  # COSTS
190
224
  # ==================================================================
@@ -322,8 +356,8 @@ L: dict[str, dict[str, str]] = {
322
356
  "en": "╔══════════════════════════════════════════════════════════╗",
323
357
  },
324
358
  "help.header_title": {
325
- "ru": "║ opencode-db — справка ║",
326
- "en": "║ opencode-db — help ║",
359
+ "ru": "║ opencode-db — справка ║",
360
+ "en": "║ opencode-db — help ║",
327
361
  },
328
362
  "help.header_box_bottom": {
329
363
  "ru": "╚══════════════════════════════════════════════════════════╝",
@@ -355,13 +389,21 @@ L: dict[str, dict[str, str]] = {
355
389
  },
356
390
  "help.unknown": {"ru": "❌ Неизвестная команда: {cmd}", "en": "❌ Unknown command: {cmd}"},
357
391
  "help.available": {"ru": " Доступные: {cmds}", "en": " Available: {cmds}"},
392
+ "help.usage_header": {
393
+ "ru": "Примеры использования команды {cmd}:",
394
+ "en": "Usage examples for {cmd}:",
395
+ },
396
+ "help.flags_header": {
397
+ "ru": "Все доступные флаги для {cmd}:",
398
+ "en": "Available flags for {cmd}:",
399
+ },
358
400
  # ==================================================================
359
401
  # HELP — команды (краткие описания в справке)
360
402
  # ==================================================================
361
403
  "help.cmd.list": {"ru": "Список сессий", "en": "List sessions"},
362
404
  "help.cmd.info": {"ru": "Детальная информация о сессии", "en": "Session details"},
363
405
  "help.cmd.export": {"ru": "Экспорт диалога в Markdown", "en": "Export dialog to Markdown"},
364
- "help.cmd.delete": {"ru": "Удаление сессии", "en": "Delete a session"},
406
+ "help.cmd.delete": {"ru": "Удаление сессий", "en": "Delete sessions"},
365
407
  "help.cmd.costs": {"ru": "Анализ расходов на токены", "en": "Token cost analysis"},
366
408
  "help.cmd.prune": {"ru": "Массовая очистка сессий", "en": "Bulk session cleanup"},
367
409
  "help.cmd.search": {"ru": "Поиск по сообщениям", "en": "Search messages"},
@@ -372,8 +414,12 @@ L: dict[str, dict[str, str]] = {
372
414
  "help.cmd.vacuum": {"ru": "Оптимизация БД", "en": "Database optimization"},
373
415
  "help.cmd.help": {"ru": "Подробная справка", "en": "Detailed help"},
374
416
  # ==================================================================
375
- # HELP — примеры
417
+ # HELP — примеры (epilog для argparse --help)
376
418
  # ==================================================================
419
+ "help.cmd.list.examples": {
420
+ "ru": "\nПримеры:\n opencode-db list # Сессии за последнее время\n opencode-db list --limit 10 # Показать 10 последних\n opencode-db list --sort cost # Сортировка по стоимости\n opencode-db list --project proj_xxx # Фильтр по проекту\n opencode-db list --json # Вывод в JSON\n",
421
+ "en": "\nExamples:\n opencode-db list # Recent sessions\n opencode-db list --limit 10 # Show last 10\n opencode-db list --sort cost # Sort by cost\n opencode-db list --project proj_xxx # Filter by project\n opencode-db list --json # JSON output\n",
422
+ },
377
423
  "help.list.e0": {"ru": "Сессии за последнее время", "en": "Recent sessions"},
378
424
  "help.list.e1": {"ru": "Показать 10 последних", "en": "Show last 10"},
379
425
  "help.list.e2": {"ru": "Сортировка по стоимости", "en": "Sort by cost"},
@@ -396,6 +442,22 @@ L: dict[str, dict[str, str]] = {
396
442
  "help.delete.e0": {"ru": "Удалить с подтверждением", "en": "Delete with confirmation"},
397
443
  "help.delete.e1": {"ru": "Показать что будет удалено", "en": "Preview what will be deleted"},
398
444
  "help.delete.e2": {"ru": "Без подтверждения", "en": "Skip confirmation"},
445
+ "help.delete.e3": {
446
+ "ru": "Удалить сессии старше 90 дней",
447
+ "en": "Delete sessions older than 90 days",
448
+ },
449
+ "help.delete.e4": {
450
+ "ru": "Оставить 30 последних, остальное удалить",
451
+ "en": "Keep 30 most recent, delete the rest",
452
+ },
453
+ "help.delete.e5": {
454
+ "ru": "Удалить сессии до указанной даты",
455
+ "en": "Delete sessions before a date",
456
+ },
457
+ "help.delete.e6": {
458
+ "ru": "Интерактивный выбор из списка",
459
+ "en": "Choose sessions from a list",
460
+ },
399
461
  "help.costs.e0": {"ru": "Топ сессий по расходам", "en": "Top sessions by cost"},
400
462
  "help.costs.e1": {"ru": "Детально по сессии", "en": "Single session breakdown"},
401
463
  "help.costs.e2": {"ru": "Сумма по всей БД", "en": "Total across all sessions"},
@@ -427,6 +489,14 @@ L: dict[str, dict[str, str]] = {
427
489
  "en": "VACUUM + REINDEX + ANALYZE with confirmation",
428
490
  },
429
491
  "help.vacuum.e1": {"ru": "Без подтверждения", "en": "Skip confirmation"},
492
+ "help.help.e0": {
493
+ "ru": "Общая справка по всем командам",
494
+ "en": "General help for all commands",
495
+ },
496
+ "help.help.e1": {
497
+ "ru": "Справка по конкретной команде",
498
+ "en": "Help for a specific command",
499
+ },
430
500
  # ==================================================================
431
501
  # MARKDOWN EXPORT (formatters.py, config.py)
432
502
  # ==================================================================
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: opencode-db
3
- Version: 0.5.0
3
+ Version: 0.6.0
4
4
  Summary: OpenCode database CLI manager — browse, export, analyze, clean up sessions
5
5
  Project-URL: Homepage, https://github.com/VasilevNStas/opencode-db
6
6
  Project-URL: Source, https://github.com/VasilevNStas/opencode-db
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "opencode-db"
7
- version = "0.5.0"
7
+ version = "0.6.0"
8
8
  description = "OpenCode database CLI manager — browse, export, analyze, clean up sessions"
9
9
  readme = "README_PYPI.md"
10
10
  requires-python = ">=3.12"
@@ -477,7 +477,7 @@ class TestPruneCommand:
477
477
  from cmd_prune import run
478
478
 
479
479
  args = _ns(older_than="30d", keep_last=None, project=None, dry_run=False, force=False)
480
- with patch("cmd_prune.confirm", return_value=False):
480
+ with patch("cmd_delete.confirm", return_value=False):
481
481
  assert run(args, db) == 0
482
482
  assert _count_sessions(db) == 4
483
483
 
@@ -37,6 +37,19 @@ def summarize_val(v, max_len=120) -> str:
37
37
  return s
38
38
 
39
39
 
40
+ def parse_date(date_str: str) -> datetime | None:
41
+ """Парсит дату в формате ГГГГ-ММ-ДД."""
42
+ if not date_str or not isinstance(date_str, str):
43
+ return None
44
+ try:
45
+ parts = date_str.strip().split("-")
46
+ if len(parts) != 3:
47
+ return None
48
+ return datetime(int(parts[0]), int(parts[1]), int(parts[2]), tzinfo=UTC)
49
+ except (ValueError, IndexError):
50
+ return None
51
+
52
+
40
53
  def parse_time_spec(spec) -> None | datetime:
41
54
  """Парсит спецификацию времени ('30d', '6m', '1y') в timedelta."""
42
55
  if not spec or not isinstance(spec, str):
@@ -91,6 +104,27 @@ def confirm(prompt=None, default=False) -> bool:
91
104
  return False
92
105
 
93
106
 
107
+ def build_help_epilog(cmd_name: str, examples: list[tuple[str, str]]) -> str:
108
+ """Формирует epilog для argparse из списка примеров.
109
+
110
+ Args:
111
+ cmd_name: имя команды (для заголовка)
112
+ examples: список (флаги, ключ в i18n с описанием)
113
+
114
+ Returns:
115
+ str — готовый текст для epilog
116
+ """
117
+ header = _("help.usage_header", cmd=cmd_name)
118
+ lines = [f"\n{header}"]
119
+ for flags, desc_key in examples:
120
+ desc = _(desc_key)
121
+ if flags:
122
+ lines.append(f" opencode-db {cmd_name} {flags:33s} # {desc}")
123
+ else:
124
+ lines.append(f" opencode-db {cmd_name:36s} # {desc}")
125
+ return "\n".join(lines)
126
+
127
+
94
128
  def format_cost(cost_value) -> str:
95
129
  """Форматирует стоимость в долларах."""
96
130
  if cost_value is None:
@@ -1,61 +0,0 @@
1
- """Команда delete: удаление сессии."""
2
-
3
- from db import (
4
- SessionError,
5
- get_message_count,
6
- get_part_count,
7
- get_session_info,
8
- get_session_title,
9
- parse_model,
10
- )
11
- from i18n import _
12
- from utils import confirm, format_cost, format_ts
13
-
14
-
15
- def register(subparsers) -> None:
16
- p = subparsers.add_parser("delete", help=_("help.cmd.delete"))
17
- p.add_argument("session_id", help="Session ID to delete")
18
- p.add_argument("--dry-run", action="store_true", help="Preview without deleting")
19
- p.add_argument("--force", "-f", action="store_true", help="Skip confirmation")
20
-
21
-
22
- def run(args, db) -> int:
23
- try:
24
- info = get_session_info(db, args.session_id)
25
- except SessionError as e:
26
- print(e.message)
27
- return 1
28
- session_id = info["id"]
29
- title = get_session_title(info)
30
- model = parse_model(info)
31
-
32
- msg_count = get_message_count(db, session_id)
33
- part_count = get_part_count(db, session_id)
34
- created = format_ts(info["time_created"])
35
-
36
- print(f"\n{'=' * 50}")
37
- print(f" {_('delete.header')}")
38
- print(f"{'=' * 50}")
39
- print(f" {_('delete.id')}: {session_id}")
40
- print(f" {_('delete.title')}: {title}")
41
- print(f" {_('delete.model')}: {model}")
42
- print(f" {_('delete.created')}: {created}")
43
- print(f" {_('delete.messages')}: {msg_count}")
44
- print(f" {_('delete.parts')}: {part_count}")
45
- print(f" {_('delete.cost')}: {format_cost(info['cost'])}")
46
-
47
- if args.dry_run:
48
- print(f"\n{_('delete.dry_run')}")
49
- print(f" {_('delete.dry_run_hint')}")
50
- return 0
51
-
52
- if not args.force and not confirm(_("delete.confirm", id=session_id[:24])):
53
- print(_("canceled"))
54
- return 0
55
-
56
- db.execute("DELETE FROM session WHERE id = ?", (session_id,))
57
- db.commit()
58
-
59
- print(f"\n{_('delete.done', id=session_id[:24])}")
60
- print(_("delete.done_detail", msg=msg_count, parts=part_count))
61
- return 0
@@ -1,127 +0,0 @@
1
- """Команда prune: массовая очистка старых сессий."""
2
-
3
- # from db import get_session_info, get_session_title, parse_model, \
4
- # get_message_count, get_part_count
5
- from typing import Literal
6
-
7
- from i18n import _
8
- from utils import confirm, format_ts, parse_time_spec
9
-
10
-
11
- def register(subparsers) -> None:
12
- p = subparsers.add_parser("prune", help=_("help.cmd.prune"))
13
- p.add_argument("--older-than", type=str, help="Delete sessions older than (30d, 6m, 1y)")
14
- p.add_argument("--keep-last", type=int, help="Keep N most recent sessions")
15
- p.add_argument("--project", type=str, help="Limit to project")
16
- p.add_argument("--dry-run", action="store_true", help="Preview without deleting")
17
- p.add_argument("--force", "-f", action="store_true", help="Skip confirmation")
18
-
19
-
20
- def run(args, db) -> Literal[1] | Literal[0]:
21
- if not args.older_than and not args.keep_last and not args.project:
22
- print(_("prune.filter_required"))
23
- print(_("prune.filter_example"))
24
- return 1
25
-
26
- conditions = []
27
- params = []
28
-
29
- if args.project:
30
- conditions.append("s.project_id = ?")
31
- params.append(args.project)
32
-
33
- if args.older_than:
34
- cutoff = parse_time_spec(args.older_than)
35
- if cutoff is None:
36
- print(_("prune.bad_spec", spec=args.older_than))
37
- print(_("prune.bad_spec_hint"))
38
- return 1
39
- cutoff_ms = int(cutoff.timestamp() * 1000)
40
- conditions.append("s.time_created < ?")
41
- params.append(cutoff_ms)
42
-
43
- exclude_ids = set()
44
- if args.keep_last:
45
- inner_where = " AND ".join(conditions) if conditions else "1=1"
46
- keep_rows = db.execute(
47
- f"""
48
- SELECT s.id FROM session s
49
- WHERE {inner_where}
50
- ORDER BY s.time_created DESC
51
- LIMIT ?
52
- """,
53
- (*params, args.keep_last),
54
- ).fetchall()
55
- exclude_ids = {r["id"] for r in keep_rows}
56
-
57
- where = " AND ".join(conditions) if conditions else "1=1"
58
-
59
- if exclude_ids:
60
- placeholders = ",".join("?" for _ in exclude_ids)
61
- target_rows = db.execute(
62
- f"""
63
- SELECT s.id, s.title, s.model, s.time_created, s.cost
64
- FROM session s
65
- WHERE {where}
66
- AND s.id NOT IN ({placeholders})
67
- ORDER BY s.time_created DESC
68
- """,
69
- (*params, *exclude_ids),
70
- ).fetchall()
71
- else:
72
- target_rows = db.execute(
73
- f"""
74
- SELECT s.id, s.title, s.model, s.time_created, s.cost
75
- FROM session s
76
- WHERE {where}
77
- ORDER BY s.time_created DESC
78
- """,
79
- params,
80
- ).fetchall()
81
-
82
- if not target_rows:
83
- print(_("prune.none"))
84
- return 0
85
-
86
- total_cost = sum(r["cost"] or 0 for r in target_rows)
87
- total_tokens = sum(
88
- db.execute(
89
- "SELECT COALESCE(SUM(tokens_input + tokens_output), 0) FROM session WHERE id = ?",
90
- (r["id"],),
91
- ).fetchone()[0]
92
- for r in target_rows
93
- )
94
-
95
- print(f"\n {'=' * 55}")
96
- print(f" {_('prune.header', n=len(target_rows))}")
97
- print(f" {'=' * 55}")
98
-
99
- for r in target_rows[:20]:
100
- created = format_ts(r["time_created"])
101
- print(f" • {r['id'][:24]} {created} {r['title'] or '—'}")
102
-
103
- if len(target_rows) > 20:
104
- print(f" … и ещё {len(target_rows) - 20}")
105
-
106
- print(f"\n {_('prune.cost_total', cost=f'${total_cost:.4f}')}")
107
- print(f" {_('prune.tokens_total', n=str(total_tokens))}")
108
-
109
- if args.dry_run:
110
- print(f"\n{_('prune.dry_run')}")
111
- return 0
112
-
113
- if not args.force and not confirm(_("prune.confirm", n=len(target_rows))):
114
- print(_("canceled"))
115
- return 0
116
-
117
- ids_to_delete = [r["id"] for r in target_rows]
118
- placeholders = ",".join("?" for _ in ids_to_delete)
119
-
120
- db.execute(f"DELETE FROM part WHERE session_id IN ({placeholders})", ids_to_delete)
121
- db.execute(f"DELETE FROM message WHERE session_id IN ({placeholders})", ids_to_delete)
122
- db.execute(f"DELETE FROM todo WHERE session_id IN ({placeholders})", ids_to_delete)
123
- db.execute(f"DELETE FROM session WHERE id IN ({placeholders})", ids_to_delete)
124
- db.commit()
125
-
126
- print(f"\n{_('prune.done', n=len(ids_to_delete))}")
127
- return 0
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes