opencode-db 0.5.1__tar.gz → 0.6.1__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.1}/PKG-INFO +1 -1
  2. {opencode_db-0.5.1 → opencode_db-0.6.1}/README.md +49 -23
  3. {opencode_db-0.5.1 → opencode_db-0.6.1}/cli.py +10 -0
  4. opencode_db-0.6.1/cmd_delete.py +343 -0
  5. opencode_db-0.6.1/cmd_prune.py +32 -0
  6. {opencode_db-0.5.1 → opencode_db-0.6.1}/config.py +3 -1
  7. {opencode_db-0.5.1 → opencode_db-0.6.1}/i18n.py +51 -1
  8. {opencode_db-0.5.1 → opencode_db-0.6.1/opencode_db.egg-info}/PKG-INFO +1 -1
  9. {opencode_db-0.5.1 → opencode_db-0.6.1}/pyproject.toml +1 -1
  10. {opencode_db-0.5.1 → opencode_db-0.6.1}/tests/test_commands.py +1 -1
  11. {opencode_db-0.5.1 → opencode_db-0.6.1}/utils.py +13 -0
  12. opencode_db-0.5.1/cmd_delete.py +0 -74
  13. opencode_db-0.5.1/cmd_prune.py +0 -138
  14. {opencode_db-0.5.1 → opencode_db-0.6.1}/LICENSE +0 -0
  15. {opencode_db-0.5.1 → opencode_db-0.6.1}/README_PYPI.md +0 -0
  16. {opencode_db-0.5.1 → opencode_db-0.6.1}/cmd_costs.py +0 -0
  17. {opencode_db-0.5.1 → opencode_db-0.6.1}/cmd_export.py +0 -0
  18. {opencode_db-0.5.1 → opencode_db-0.6.1}/cmd_help.py +0 -0
  19. {opencode_db-0.5.1 → opencode_db-0.6.1}/cmd_info.py +0 -0
  20. {opencode_db-0.5.1 → opencode_db-0.6.1}/cmd_list.py +0 -0
  21. {opencode_db-0.5.1 → opencode_db-0.6.1}/cmd_projects.py +0 -0
  22. {opencode_db-0.5.1 → opencode_db-0.6.1}/cmd_search.py +0 -0
  23. {opencode_db-0.5.1 → opencode_db-0.6.1}/cmd_stats.py +0 -0
  24. {opencode_db-0.5.1 → opencode_db-0.6.1}/cmd_todos.py +0 -0
  25. {opencode_db-0.5.1 → opencode_db-0.6.1}/cmd_tree.py +0 -0
  26. {opencode_db-0.5.1 → opencode_db-0.6.1}/cmd_vacuum.py +0 -0
  27. {opencode_db-0.5.1 → opencode_db-0.6.1}/commands/__init__.py +0 -0
  28. {opencode_db-0.5.1 → opencode_db-0.6.1}/db.py +0 -0
  29. {opencode_db-0.5.1 → opencode_db-0.6.1}/formatters.py +0 -0
  30. {opencode_db-0.5.1 → opencode_db-0.6.1}/opencode_db.egg-info/SOURCES.txt +0 -0
  31. {opencode_db-0.5.1 → opencode_db-0.6.1}/opencode_db.egg-info/dependency_links.txt +0 -0
  32. {opencode_db-0.5.1 → opencode_db-0.6.1}/opencode_db.egg-info/entry_points.txt +0 -0
  33. {opencode_db-0.5.1 → opencode_db-0.6.1}/opencode_db.egg-info/top_level.txt +0 -0
  34. {opencode_db-0.5.1 → opencode_db-0.6.1}/setup.cfg +0 -0
  35. {opencode_db-0.5.1 → opencode_db-0.6.1}/tests/test_db.py +0 -0
  36. {opencode_db-0.5.1 → opencode_db-0.6.1}/tests/test_formatters.py +0 -0
  37. {opencode_db-0.5.1 → opencode_db-0.6.1}/tests/test_i18n.py +0 -0
  38. {opencode_db-0.5.1 → opencode_db-0.6.1}/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.1
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
@@ -26,6 +26,31 @@ opencode-db costs --total # total token costs
26
26
  - OpenCode (must have been run at least once — the database is created automatically)
27
27
  - macOS / Linux
28
28
 
29
+ ### Database location
30
+
31
+ By default, `opencode-db` looks for OpenCode's database at:
32
+
33
+ ```
34
+ ~/.local/share/opencode/opencode.db
35
+ ```
36
+
37
+ If your OpenCode stores the database at a custom path, you have two options:
38
+
39
+ **1. Environment variable** — set it once in your shell profile (`.zshrc`, `.bashrc`):
40
+
41
+ ```bash
42
+ export OPENCODE_DB=/path/to/opencode.db
43
+ ```
44
+
45
+ **2. Global `--db-path` flag** — for ad-hoc use or inspecting another database:
46
+
47
+ ```bash
48
+ opencode-db --db-path /path/to/opencode.db list
49
+ opencode-db --db-path /path/to/opencode.db stats
50
+ ```
51
+
52
+ The `--db-path` flag takes precedence over the environment variable.
53
+
29
54
  ### One-command install (recommended)
30
55
 
31
56
  ```bash
@@ -210,20 +235,35 @@ If the project directory contains `.obsidian`, a `log.md` file is automatically
210
235
 
211
236
  ---
212
237
 
213
- ### `delete` — delete a session
238
+ ### `delete` — delete sessions
214
239
 
215
- Removes a session and all related data (messages, parts, todos) thanks to CASCADE constraints in the database schema.
240
+ Removes sessions and all related data (messages, parts, todos). Supports single deletion by ID or bulk deletion with filters.
216
241
 
217
242
  ```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
243
+ opencode-db delete ses_1c9d # single session with confirmation
244
+ opencode-db delete ses_1c9d --force # single session, no confirmation
245
+ opencode-db delete ses_1c9d --dry-run # preview what would be deleted
246
+ opencode-db delete --older-than 90d # sessions older than 90 days
247
+ opencode-db delete --older-than 6m --dry-run # preview sessions older than 6 months
248
+ opencode-db delete --keep-last 30 # keep 30 most recent, delete the rest
249
+ opencode-db delete --before 2026-05-20 # sessions before a specific date
250
+ opencode-db delete --after 2026-01-01 # sessions after a specific date
251
+ opencode-db delete --project proj_xxx # sessions in a specific project
252
+ opencode-db delete --older-than 90d --keep-last 20 # combined filters
253
+ opencode-db delete --interactive # choose from a list interactively
221
254
  ```
222
255
 
223
256
  | Flag | Description |
224
257
  |---|---|
258
+ | `session_id` | Session ID (optional with `--interactive`) |
259
+ | `--older-than` | Delete sessions older than (e.g. `30d`, `6m`, `1y`) |
260
+ | `--before` | Delete sessions before date (`YYYY-MM-DD`) |
261
+ | `--after` | Delete sessions after date (`YYYY-MM-DD`) |
262
+ | `--keep-last` | Keep N most recent, delete the rest |
263
+ | `--project` | Limit to a specific project |
225
264
  | `--dry-run` | Only show what would be deleted |
226
265
  | `--force`, `-f` | Skip confirmation prompt |
266
+ | `--interactive` | Pick sessions from a list |
227
267
 
228
268
  ---
229
269
 
@@ -252,31 +292,17 @@ Costs are taken directly from the database (`session.cost`), which stores the ac
252
292
 
253
293
  ---
254
294
 
255
- ### `prune` — bulk cleanup of old sessions
295
+ ### `prune` — alias for `delete` (bulk cleanup)
256
296
 
257
- Deletes sessions by age, project, or keeps a specified number of the most recent ones.
297
+ Alias for `delete` with the same flags: `--older-than`, `--keep-last`, `--project`, `--dry-run`, `--force`.
258
298
 
259
299
  ```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
300
+ opencode-db prune --older-than 90d # same as opencode-db delete --older-than 90d
301
+ opencode-db prune --keep-last 20 # same as opencode-db delete --keep-last 20
268
302
  ```
269
303
 
270
304
  Time spec formats: `30d` (days), `6m` (months), `1y` (years).
271
305
 
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
306
  ---
281
307
 
282
308
  ### `search` — search across messages
@@ -8,6 +8,7 @@
8
8
  """
9
9
 
10
10
  import argparse
11
+ import os
11
12
  import sys
12
13
  from typing import Any, Literal
13
14
 
@@ -28,6 +29,12 @@ def build_parser() -> argparse.ArgumentParser:
28
29
  default=None,
29
30
  help="Output language / язык вывода (en/ru)",
30
31
  )
32
+ parser.add_argument(
33
+ "--db-path",
34
+ type=str,
35
+ default=None,
36
+ help="Path to opencode.db (default: ~/.local/share/opencode/opencode.db)",
37
+ )
31
38
 
32
39
  subparsers = parser.add_subparsers(dest="command", required=True)
33
40
  for _name, mod in commands.get_all().items():
@@ -58,6 +65,9 @@ def main(argv=None) -> Any | Literal[0] | Literal[1]:
58
65
  if args.lang:
59
66
  set_lang(args.lang)
60
67
 
68
+ if args.db_path:
69
+ os.environ["OPENCODE_DB"] = args.db_path
70
+
61
71
  db = get_db()
62
72
 
63
73
  try:
@@ -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)
@@ -8,7 +8,9 @@ import os
8
8
 
9
9
  # --- Пути ---
10
10
  # База данных opencode. Стандартное расположение для Linux/macOS.
11
- OPENCODE_DB = os.path.expanduser("~/.local/share/opencode/opencode.db")
11
+ OPENCODE_DB = os.environ.get("OPENCODE_DB") or os.path.expanduser(
12
+ "~/.local/share/opencode/opencode.db"
13
+ )
12
14
 
13
15
  # Директории, игнорируемые при сборе snapshot'а проекта
14
16
  IGNORE_DIRS = {".git", "node_modules", ".venv", "__pycache__", ".opencode"}
@@ -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.1
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.1"
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