opencode-db 0.5.1__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.1/opencode_db.egg-info → opencode_db-0.6.0}/PKG-INFO +1 -1
  2. {opencode_db-0.5.1 → opencode_db-0.6.0}/README.md +24 -23
  3. opencode_db-0.6.0/cmd_delete.py +343 -0
  4. opencode_db-0.6.0/cmd_prune.py +32 -0
  5. {opencode_db-0.5.1 → opencode_db-0.6.0}/i18n.py +51 -1
  6. {opencode_db-0.5.1 → opencode_db-0.6.0/opencode_db.egg-info}/PKG-INFO +1 -1
  7. {opencode_db-0.5.1 → opencode_db-0.6.0}/pyproject.toml +1 -1
  8. {opencode_db-0.5.1 → opencode_db-0.6.0}/tests/test_commands.py +1 -1
  9. {opencode_db-0.5.1 → opencode_db-0.6.0}/utils.py +13 -0
  10. opencode_db-0.5.1/cmd_delete.py +0 -74
  11. opencode_db-0.5.1/cmd_prune.py +0 -138
  12. {opencode_db-0.5.1 → opencode_db-0.6.0}/LICENSE +0 -0
  13. {opencode_db-0.5.1 → opencode_db-0.6.0}/README_PYPI.md +0 -0
  14. {opencode_db-0.5.1 → opencode_db-0.6.0}/cli.py +0 -0
  15. {opencode_db-0.5.1 → opencode_db-0.6.0}/cmd_costs.py +0 -0
  16. {opencode_db-0.5.1 → opencode_db-0.6.0}/cmd_export.py +0 -0
  17. {opencode_db-0.5.1 → opencode_db-0.6.0}/cmd_help.py +0 -0
  18. {opencode_db-0.5.1 → opencode_db-0.6.0}/cmd_info.py +0 -0
  19. {opencode_db-0.5.1 → opencode_db-0.6.0}/cmd_list.py +0 -0
  20. {opencode_db-0.5.1 → opencode_db-0.6.0}/cmd_projects.py +0 -0
  21. {opencode_db-0.5.1 → opencode_db-0.6.0}/cmd_search.py +0 -0
  22. {opencode_db-0.5.1 → opencode_db-0.6.0}/cmd_stats.py +0 -0
  23. {opencode_db-0.5.1 → opencode_db-0.6.0}/cmd_todos.py +0 -0
  24. {opencode_db-0.5.1 → opencode_db-0.6.0}/cmd_tree.py +0 -0
  25. {opencode_db-0.5.1 → opencode_db-0.6.0}/cmd_vacuum.py +0 -0
  26. {opencode_db-0.5.1 → opencode_db-0.6.0}/commands/__init__.py +0 -0
  27. {opencode_db-0.5.1 → opencode_db-0.6.0}/config.py +0 -0
  28. {opencode_db-0.5.1 → opencode_db-0.6.0}/db.py +0 -0
  29. {opencode_db-0.5.1 → opencode_db-0.6.0}/formatters.py +0 -0
  30. {opencode_db-0.5.1 → opencode_db-0.6.0}/opencode_db.egg-info/SOURCES.txt +0 -0
  31. {opencode_db-0.5.1 → opencode_db-0.6.0}/opencode_db.egg-info/dependency_links.txt +0 -0
  32. {opencode_db-0.5.1 → opencode_db-0.6.0}/opencode_db.egg-info/entry_points.txt +0 -0
  33. {opencode_db-0.5.1 → opencode_db-0.6.0}/opencode_db.egg-info/top_level.txt +0 -0
  34. {opencode_db-0.5.1 → opencode_db-0.6.0}/setup.cfg +0 -0
  35. {opencode_db-0.5.1 → opencode_db-0.6.0}/tests/test_db.py +0 -0
  36. {opencode_db-0.5.1 → opencode_db-0.6.0}/tests/test_formatters.py +0 -0
  37. {opencode_db-0.5.1 → opencode_db-0.6.0}/tests/test_i18n.py +0 -0
  38. {opencode_db-0.5.1 → 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.1
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
@@ -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
@@ -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)
@@ -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
  # ==================================================================
@@ -369,7 +403,7 @@ L: dict[str, dict[str, str]] = {
369
403
  "help.cmd.list": {"ru": "Список сессий", "en": "List sessions"},
370
404
  "help.cmd.info": {"ru": "Детальная информация о сессии", "en": "Session details"},
371
405
  "help.cmd.export": {"ru": "Экспорт диалога в Markdown", "en": "Export dialog to Markdown"},
372
- "help.cmd.delete": {"ru": "Удаление сессии", "en": "Delete a session"},
406
+ "help.cmd.delete": {"ru": "Удаление сессий", "en": "Delete sessions"},
373
407
  "help.cmd.costs": {"ru": "Анализ расходов на токены", "en": "Token cost analysis"},
374
408
  "help.cmd.prune": {"ru": "Массовая очистка сессий", "en": "Bulk session cleanup"},
375
409
  "help.cmd.search": {"ru": "Поиск по сообщениям", "en": "Search messages"},
@@ -408,6 +442,22 @@ L: dict[str, dict[str, str]] = {
408
442
  "help.delete.e0": {"ru": "Удалить с подтверждением", "en": "Delete with confirmation"},
409
443
  "help.delete.e1": {"ru": "Показать что будет удалено", "en": "Preview what will be deleted"},
410
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
+ },
411
461
  "help.costs.e0": {"ru": "Топ сессий по расходам", "en": "Top sessions by cost"},
412
462
  "help.costs.e1": {"ru": "Детально по сессии", "en": "Single session breakdown"},
413
463
  "help.costs.e2": {"ru": "Сумма по всей БД", "en": "Total across all sessions"},
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: opencode-db
3
- Version: 0.5.1
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.1"
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):
@@ -1,74 +0,0 @@
1
- """Команда delete: удаление сессии."""
2
-
3
- import argparse
4
-
5
- from db import (
6
- SessionError,
7
- get_message_count,
8
- get_part_count,
9
- get_session_info,
10
- get_session_title,
11
- parse_model,
12
- )
13
- from i18n import _
14
- from utils import build_help_epilog, confirm, format_cost, format_ts
15
-
16
- _DELETE_EXAMPLES = [
17
- ("<session_id>", "help.delete.e0"),
18
- ("<session_id> --dry-run", "help.delete.e1"),
19
- ("<session_id> --force", "help.delete.e2"),
20
- ]
21
-
22
-
23
- def register(subparsers) -> None:
24
- p = subparsers.add_parser(
25
- "delete",
26
- help=_("help.cmd.delete"),
27
- formatter_class=argparse.RawDescriptionHelpFormatter,
28
- epilog=build_help_epilog("delete", _DELETE_EXAMPLES),
29
- )
30
- p.add_argument("session_id", help="Session ID to delete")
31
- p.add_argument("--dry-run", action="store_true", help="Preview without deleting")
32
- p.add_argument("--force", "-f", action="store_true", help="Skip confirmation")
33
-
34
-
35
- def run(args, db) -> int:
36
- try:
37
- info = get_session_info(db, args.session_id)
38
- except SessionError as e:
39
- print(e.message)
40
- return 1
41
- session_id = info["id"]
42
- title = get_session_title(info)
43
- model = parse_model(info)
44
-
45
- msg_count = get_message_count(db, session_id)
46
- part_count = get_part_count(db, session_id)
47
- created = format_ts(info["time_created"])
48
-
49
- print(f"\n{'=' * 50}")
50
- print(f" {_('delete.header')}")
51
- print(f"{'=' * 50}")
52
- print(f" {_('delete.id')}: {session_id}")
53
- print(f" {_('delete.title')}: {title}")
54
- print(f" {_('delete.model')}: {model}")
55
- print(f" {_('delete.created')}: {created}")
56
- print(f" {_('delete.messages')}: {msg_count}")
57
- print(f" {_('delete.parts')}: {part_count}")
58
- print(f" {_('delete.cost')}: {format_cost(info['cost'])}")
59
-
60
- if args.dry_run:
61
- print(f"\n{_('delete.dry_run')}")
62
- print(f" {_('delete.dry_run_hint')}")
63
- return 0
64
-
65
- if not args.force and not confirm(_("delete.confirm", id=session_id[:24])):
66
- print(_("canceled"))
67
- return 0
68
-
69
- db.execute("DELETE FROM session WHERE id = ?", (session_id,))
70
- db.commit()
71
-
72
- print(f"\n{_('delete.done', id=session_id[:24])}")
73
- print(_("delete.done_detail", msg=msg_count, parts=part_count))
74
- return 0
@@ -1,138 +0,0 @@
1
- """Команда prune: массовая очистка старых сессий."""
2
-
3
- import argparse
4
- from typing import Literal
5
-
6
- from i18n import _
7
- from utils import build_help_epilog, confirm, format_ts, parse_time_spec
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) -> Literal[1] | Literal[0]:
32
- if not args.older_than and not args.keep_last and not args.project:
33
- print(_("prune.filter_required"))
34
- print(_("prune.filter_example"))
35
- return 1
36
-
37
- conditions = []
38
- params = []
39
-
40
- if args.project:
41
- conditions.append("s.project_id = ?")
42
- params.append(args.project)
43
-
44
- if args.older_than:
45
- cutoff = parse_time_spec(args.older_than)
46
- if cutoff is None:
47
- print(_("prune.bad_spec", spec=args.older_than))
48
- print(_("prune.bad_spec_hint"))
49
- return 1
50
- cutoff_ms = int(cutoff.timestamp() * 1000)
51
- conditions.append("s.time_created < ?")
52
- params.append(cutoff_ms)
53
-
54
- exclude_ids = set()
55
- if args.keep_last:
56
- inner_where = " AND ".join(conditions) if conditions else "1=1"
57
- keep_rows = db.execute(
58
- f"""
59
- SELECT s.id FROM session s
60
- WHERE {inner_where}
61
- ORDER BY s.time_created DESC
62
- LIMIT ?
63
- """,
64
- (*params, args.keep_last),
65
- ).fetchall()
66
- exclude_ids = {r["id"] for r in keep_rows}
67
-
68
- where = " AND ".join(conditions) if conditions else "1=1"
69
-
70
- if exclude_ids:
71
- placeholders = ",".join("?" for _ in exclude_ids)
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
- AND s.id NOT IN ({placeholders})
78
- ORDER BY s.time_created DESC
79
- """,
80
- (*params, *exclude_ids),
81
- ).fetchall()
82
- else:
83
- target_rows = db.execute(
84
- f"""
85
- SELECT s.id, s.title, s.model, s.time_created, s.cost
86
- FROM session s
87
- WHERE {where}
88
- ORDER BY s.time_created DESC
89
- """,
90
- params,
91
- ).fetchall()
92
-
93
- if not target_rows:
94
- print(_("prune.none"))
95
- return 0
96
-
97
- total_cost = sum(r["cost"] or 0 for r in target_rows)
98
- total_tokens = sum(
99
- db.execute(
100
- "SELECT COALESCE(SUM(tokens_input + tokens_output), 0) FROM session WHERE id = ?",
101
- (r["id"],),
102
- ).fetchone()[0]
103
- for r in target_rows
104
- )
105
-
106
- print(f"\n {'=' * 55}")
107
- print(f" {_('prune.header', n=len(target_rows))}")
108
- print(f" {'=' * 55}")
109
-
110
- for r in target_rows[:20]:
111
- created = format_ts(r["time_created"])
112
- print(f" • {r['id'][:24]} {created} {r['title'] or '—'}")
113
-
114
- if len(target_rows) > 20:
115
- print(f" … и ещё {len(target_rows) - 20}")
116
-
117
- print(f"\n {_('prune.cost_total', cost=f'${total_cost:.4f}')}")
118
- print(f" {_('prune.tokens_total', n=str(total_tokens))}")
119
-
120
- if args.dry_run:
121
- print(f"\n{_('prune.dry_run')}")
122
- return 0
123
-
124
- if not args.force and not confirm(_("prune.confirm", n=len(target_rows))):
125
- print(_("canceled"))
126
- return 0
127
-
128
- ids_to_delete = [r["id"] for r in target_rows]
129
- placeholders = ",".join("?" for _ in ids_to_delete)
130
-
131
- db.execute(f"DELETE FROM part WHERE session_id IN ({placeholders})", ids_to_delete)
132
- db.execute(f"DELETE FROM message WHERE session_id IN ({placeholders})", ids_to_delete)
133
- db.execute(f"DELETE FROM todo WHERE session_id IN ({placeholders})", ids_to_delete)
134
- db.execute(f"DELETE FROM session WHERE id IN ({placeholders})", ids_to_delete)
135
- db.commit()
136
-
137
- print(f"\n{_('prune.done', n=len(ids_to_delete))}")
138
- return 0
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes