opencode-db 0.8.2__tar.gz → 0.9.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 (55) hide show
  1. {opencode_db-0.8.2/opencode_db.egg-info → opencode_db-0.9.0}/PKG-INFO +3 -2
  2. {opencode_db-0.8.2 → opencode_db-0.9.0}/README.md +147 -3
  3. {opencode_db-0.8.2 → opencode_db-0.9.0}/docs/README_PYPI.md +2 -1
  4. {opencode_db-0.8.2 → opencode_db-0.9.0}/opencode_db/cli.py +6 -0
  5. {opencode_db-0.8.2 → opencode_db-0.9.0}/opencode_db/commands/cmd_delete.py +174 -0
  6. {opencode_db-0.8.2 → opencode_db-0.9.0}/opencode_db/commands/cmd_prune.py +10 -0
  7. {opencode_db-0.8.2 → opencode_db-0.9.0}/opencode_db/i18n.py +24 -0
  8. opencode_db-0.9.0/opencode_db/version.py +25 -0
  9. {opencode_db-0.8.2 → opencode_db-0.9.0/opencode_db.egg-info}/PKG-INFO +3 -2
  10. {opencode_db-0.8.2 → opencode_db-0.9.0}/opencode_db.egg-info/SOURCES.txt +3 -1
  11. {opencode_db-0.8.2 → opencode_db-0.9.0}/pyproject.toml +1 -1
  12. {opencode_db-0.8.2 → opencode_db-0.9.0}/tests/test_commands.py +121 -0
  13. {opencode_db-0.8.2 → opencode_db-0.9.0}/tests/test_store_v2.py +25 -0
  14. opencode_db-0.9.0/tests/test_version.py +22 -0
  15. {opencode_db-0.8.2 → opencode_db-0.9.0}/LICENSE +0 -0
  16. {opencode_db-0.8.2 → opencode_db-0.9.0}/opencode_db/__init__.py +0 -0
  17. {opencode_db-0.8.2 → opencode_db-0.9.0}/opencode_db/__main__.py +0 -0
  18. {opencode_db-0.8.2 → opencode_db-0.9.0}/opencode_db/commands/__init__.py +0 -0
  19. {opencode_db-0.8.2 → opencode_db-0.9.0}/opencode_db/commands/cmd_costs.py +0 -0
  20. {opencode_db-0.8.2 → opencode_db-0.9.0}/opencode_db/commands/cmd_directories.py +0 -0
  21. {opencode_db-0.8.2 → opencode_db-0.9.0}/opencode_db/commands/cmd_export.py +0 -0
  22. {opencode_db-0.8.2 → opencode_db-0.9.0}/opencode_db/commands/cmd_help.py +0 -0
  23. {opencode_db-0.8.2 → opencode_db-0.9.0}/opencode_db/commands/cmd_info.py +0 -0
  24. {opencode_db-0.8.2 → opencode_db-0.9.0}/opencode_db/commands/cmd_list.py +0 -0
  25. {opencode_db-0.8.2 → opencode_db-0.9.0}/opencode_db/commands/cmd_projects.py +0 -0
  26. {opencode_db-0.8.2 → opencode_db-0.9.0}/opencode_db/commands/cmd_schema.py +0 -0
  27. {opencode_db-0.8.2 → opencode_db-0.9.0}/opencode_db/commands/cmd_search.py +0 -0
  28. {opencode_db-0.8.2 → opencode_db-0.9.0}/opencode_db/commands/cmd_stats.py +0 -0
  29. {opencode_db-0.8.2 → opencode_db-0.9.0}/opencode_db/commands/cmd_todos.py +0 -0
  30. {opencode_db-0.8.2 → opencode_db-0.9.0}/opencode_db/commands/cmd_tree.py +0 -0
  31. {opencode_db-0.8.2 → opencode_db-0.9.0}/opencode_db/commands/cmd_vacuum.py +0 -0
  32. {opencode_db-0.8.2 → opencode_db-0.9.0}/opencode_db/commands/cmd_view.py +0 -0
  33. {opencode_db-0.8.2 → opencode_db-0.9.0}/opencode_db/config.py +0 -0
  34. {opencode_db-0.8.2 → opencode_db-0.9.0}/opencode_db/db.py +0 -0
  35. {opencode_db-0.8.2 → opencode_db-0.9.0}/opencode_db/formatters.py +0 -0
  36. {opencode_db-0.8.2 → opencode_db-0.9.0}/opencode_db/markdown_to_ansi.py +0 -0
  37. {opencode_db-0.8.2 → opencode_db-0.9.0}/opencode_db/runtime.py +0 -0
  38. {opencode_db-0.8.2 → opencode_db-0.9.0}/opencode_db/schema.py +0 -0
  39. {opencode_db-0.8.2 → opencode_db-0.9.0}/opencode_db/schema_manifest.json +0 -0
  40. {opencode_db-0.8.2 → opencode_db-0.9.0}/opencode_db/store/__init__.py +0 -0
  41. {opencode_db-0.8.2 → opencode_db-0.9.0}/opencode_db/store/base.py +0 -0
  42. {opencode_db-0.8.2 → opencode_db-0.9.0}/opencode_db/store/v1.py +0 -0
  43. {opencode_db-0.8.2 → opencode_db-0.9.0}/opencode_db/store/v2.py +0 -0
  44. {opencode_db-0.8.2 → opencode_db-0.9.0}/opencode_db/utils.py +0 -0
  45. {opencode_db-0.8.2 → opencode_db-0.9.0}/opencode_db.egg-info/dependency_links.txt +0 -0
  46. {opencode_db-0.8.2 → opencode_db-0.9.0}/opencode_db.egg-info/entry_points.txt +0 -0
  47. {opencode_db-0.8.2 → opencode_db-0.9.0}/opencode_db.egg-info/requires.txt +0 -0
  48. {opencode_db-0.8.2 → opencode_db-0.9.0}/opencode_db.egg-info/top_level.txt +0 -0
  49. {opencode_db-0.8.2 → opencode_db-0.9.0}/setup.cfg +0 -0
  50. {opencode_db-0.8.2 → opencode_db-0.9.0}/tests/test_db.py +0 -0
  51. {opencode_db-0.8.2 → opencode_db-0.9.0}/tests/test_formatters.py +0 -0
  52. {opencode_db-0.8.2 → opencode_db-0.9.0}/tests/test_i18n.py +0 -0
  53. {opencode_db-0.8.2 → opencode_db-0.9.0}/tests/test_runtime.py +0 -0
  54. {opencode_db-0.8.2 → opencode_db-0.9.0}/tests/test_schema.py +0 -0
  55. {opencode_db-0.8.2 → opencode_db-0.9.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.8.2
3
+ Version: 0.9.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
@@ -31,6 +31,7 @@ uv tool install opencode-db
31
31
  ## Quick start
32
32
 
33
33
  ```bash
34
+ opencode-db --version # installed version
34
35
  opencode-db list # recent sessions
35
36
  opencode-db directories # launch directories, including missing ones
36
37
  opencode-db view # interactive session viewer (colored, scrollable)
@@ -47,7 +48,7 @@ opencode-db help # full reference
47
48
  - **`list`** / **`info`** / **`search`** / **`tree`** — навигация и поиск по сессиям
48
49
  - **`directories`** (`dirs`) — все директории запусков, включая отсутствующие; `list --directory` добавляет путь к сессии
49
50
  - **`export`** — экспорт диалога в Markdown (с поддержкой Obsidian)
50
- - **`delete`** / **`prune`** — удаление и массовая очистка с фильтрами
51
+ - **`delete`** / **`prune`** — удаление и массовая очистка с фильтрами; `prune --missing-directories` — защищённая очистка сессий из отсутствующих путей
51
52
  - **`costs`** / **`stats`** — аналитика токенов и расходов
52
53
  - **`schema`** — определяет v1/v2-схему OpenCode, миграции и доступные операции
53
54
  - **`--db-path`** / **`OPENCODE_DB`** — кастомный путь к БД
@@ -13,6 +13,7 @@
13
13
  > **Published on PyPI:** [opencode-db](https://pypi.org/project/opencode-db/) — install the ready-made package with `uv tool install opencode-db` or `pip install opencode-db`.
14
14
 
15
15
  ```
16
+ opencode-db --version # installed version
16
17
  opencode-db help # full reference with examples
17
18
  opencode-db list # list recent sessions
18
19
  opencode-db stats # database summary
@@ -312,6 +313,7 @@ opencode-db delete --after 2026-01-01 # sessions after a spec
312
313
  opencode-db delete --project proj_xxx # sessions in a specific project
313
314
  opencode-db delete --older-than 90d --keep-last 20 # combined filters
314
315
  opencode-db delete --interactive # choose from a list interactively
316
+ opencode-db prune --missing-directories # preview sessions from missing paths
315
317
  ```
316
318
 
317
319
  | Flag | Description |
@@ -325,6 +327,10 @@ opencode-db delete --interactive # choose from a list in
325
327
  | `--dry-run` | Only show what would be deleted |
326
328
  | `--force`, `-f` | Skip confirmation prompt |
327
329
  | `--interactive` | Pick sessions from a list |
330
+ | `--missing-directories` | Sessions whose directory no longer exists (preview by default) |
331
+ | `--execute` | Execute missing-directory cleanup |
332
+ | `--backup` | Create a DB backup before deletion |
333
+ | `--no-backup` | Explicitly skip the DB backup |
328
334
 
329
335
  ---
330
336
 
@@ -364,6 +370,144 @@ opencode-db prune --keep-last 20 # same as opencode-db delete --keep-l
364
370
 
365
371
  Time spec formats: `30d` (days), `6m` (months), `1y` (years).
366
372
 
373
+ ### Missing-directory cleanup
374
+
375
+ OpenCode stores the launch directory of every session in `session.directory`.
376
+ The TUI only shows sessions for the current project, so sessions created in a
377
+ deleted, renamed or unmounted directory can be hard to find. This mode finds
378
+ those sessions and deletes them only after several independent confirmations.
379
+
380
+ #### 1. Inspect candidates first (read-only)
381
+
382
+ ```bash
383
+ opencode-db directories --missing
384
+ opencode-db directories --missing --json
385
+ opencode-db prune --missing-directories
386
+ opencode-db prune --missing-directories --project global
387
+ ```
388
+
389
+ `directories --missing` is the safest first step: it does not touch the
390
+ database and shows the same paths, counts and costs.
391
+
392
+ Example preview:
393
+
394
+ ```text
395
+ ============================================================
396
+ DELETE SESSIONS FROM MISSING DIRECTORIES
397
+ ============================================================
398
+ Database: ~/.local/share/opencode/opencode.db
399
+ Sessions: 51, directories: 10, cost: $3.85
400
+ 2026-05-06 08:19:14 — 2026-09-12 16:45:41
401
+ /Users/me/old-project — 15 sessions
402
+ /Users/me/Documents/old-notes — 7 sessions
403
+ ...
404
+ This is a preview. Add --execute to delete.
405
+ ```
406
+
407
+ How “missing” is detected:
408
+
409
+ - the tool reads `session.directory` (or `session_v2.directory`);
410
+ - it calls `os.path.isdir()` on each path at command runtime;
411
+ - `False` means **not found or inaccessible** — it may be deleted, renamed,
412
+ on an unmounted disk, on an offline network volume, or blocked by permissions;
413
+ - symlinks are followed; results are only true at the moment the command runs;
414
+ - `--no-check` on `directories` skips this check and reports `exists: null`.
415
+
416
+ Review this list carefully. A temporarily unavailable external disk can look
417
+ exactly like a deleted directory.
418
+
419
+ #### 2. Execute with the safety gates
420
+
421
+ ```bash
422
+ # recommended: create a consistent SQLite backup first
423
+ opencode-db prune --missing-directories --execute --backup
424
+
425
+ # only if you already have an external backup
426
+ opencode-db prune --missing-directories --execute --no-backup
427
+ ```
428
+
429
+ `delete --missing-directories ...` works the same way as `prune`.
430
+
431
+ Before anything is deleted, all of these gates must pass:
432
+
433
+ 1. `--missing-directories` selects only sessions whose path is not found.
434
+ 2. `--execute` is required; without it the command always stops after preview.
435
+ 3. `--backup` or `--no-backup` must be chosen explicitly.
436
+ 4. The command prints every path, session count, cost and date range.
437
+ 5. You must type the exact phrase `DELETE <N> SESSIONS`.
438
+ 6. You must answer a final `y/N` confirmation.
439
+ 7. The candidate list is recomputed; if it changed, deletion is aborted.
440
+ 8. All deletes run in one transaction; on error the transaction is rolled back.
441
+
442
+ `--force` does **not** bypass any of these gates. It is ignored in this mode.
443
+
444
+ #### What exactly is deleted
445
+
446
+ For each selected session:
447
+
448
+ - the `session` row;
449
+ - `message`, `part`, `todo`;
450
+ - `session_message`, `session_input`, `session_context_epoch`, `session_share`;
451
+ - `event` and `event_sequence` rows for that session.
452
+
453
+ Projects and the synthetic `global` project are **never** deleted. The tool
454
+ does not touch directories on disk — it only removes database rows.
455
+
456
+ After a successful cleanup you can reclaim disk space:
457
+
458
+ ```bash
459
+ opencode-db vacuum
460
+ ```
461
+
462
+ `VACUUM` can take time and temporarily needs free disk space roughly equal to
463
+ the database size.
464
+
465
+ #### Backup and recovery
466
+
467
+ With `--backup`, the tool creates a consistent SQLite copy next to the database:
468
+
469
+ ```text
470
+ opencode.db.bak.20261020-111530
471
+ ```
472
+
473
+ The exact path is printed before deletion. If you delete something by mistake:
474
+
475
+ 1. stop OpenCode;
476
+ 2. restore the backup over `opencode.db`;
477
+ 3. remove stale `opencode.db-wal` / `opencode.db-shm` files if present;
478
+ 4. start OpenCode again.
479
+
480
+ Without a backup, deleted sessions and their events cannot be recovered.
481
+
482
+ #### Recommended workflow
483
+
484
+ ```bash
485
+ # 1. Close OpenCode to avoid concurrent writes.
486
+ opencode-db directories --missing
487
+
488
+ # 2. Preview the exact deletion.
489
+ opencode-db prune --missing-directories
490
+
491
+ # 3. Optional: only one project.
492
+ opencode-db prune --missing-directories --project global
493
+
494
+ # 4. Execute with a backup.
495
+ opencode-db prune --missing-directories --execute --backup
496
+
497
+ # 5. Reclaim disk space.
498
+ opencode-db vacuum
499
+ ```
500
+
501
+ #### Limitations
502
+
503
+ - This mode is available for the v1 schema; v2 is refused until v2 writes are
504
+ implemented.
505
+ - It cannot tell a deleted directory from an offline volume or permission error.
506
+ - It does not remove `project` rows or directories from disk.
507
+ - It cannot be combined with `--older-than`, `--before`, `--after`,
508
+ `--keep-last`, `--interactive` or a session ID.
509
+ - There is no automatic or background cleanup.
510
+
367
511
  ---
368
512
 
369
513
  ### `search` — search across messages
@@ -576,7 +720,7 @@ opencode-db/
576
720
  │ ├── config.py ← constants and paths
577
721
  │ ├── db.py ← SQLite queries
578
722
  │ ├── formatters.py ← tables, JSON, Markdown, snapshots
579
- │ ├── i18n.py ← localization (EN/RU, 324 keys)
723
+ │ ├── i18n.py ← localization (EN/RU, 348 keys)
580
724
  │ ├── markdown_to_ansi.py ← Markdown → ANSI renderer for `view`
581
725
  │ ├── runtime.py ← Python 3.12+ version guard
582
726
  │ ├── utils.py ← date/token formatting, validation
@@ -725,7 +869,7 @@ opencode-db --lang en help # English help text
725
869
  opencode-db --lang en stats # English statistics
726
870
  ```
727
871
 
728
- The `--lang` flag applies to any command. All 324 UI strings are translated. Data content (session titles, project names, etc.) remains in its original language.
872
+ The `--lang` flag applies to any command. All 348 UI strings are translated. Data content (session titles, project names, etc.) remains in its original language.
729
873
 
730
874
  The language can also be set via environment variable `OPENCODE_DB_LANG=en`.
731
875
 
@@ -810,7 +954,7 @@ source .venv/bin/activate
810
954
  uv pip install -e ".[rich]"
811
955
  uv pip install pytest ruff==0.17.1
812
956
 
813
- make check # check-python + ruff + 214 tests
957
+ make check # check-python + ruff + 224 tests
814
958
  ```
815
959
 
816
960
  `make check` fails fast with a clear message if the active interpreter is older than 3.12.
@@ -15,6 +15,7 @@ uv tool install opencode-db
15
15
  ## Quick start
16
16
 
17
17
  ```bash
18
+ opencode-db --version # installed version
18
19
  opencode-db list # recent sessions
19
20
  opencode-db directories # launch directories, including missing ones
20
21
  opencode-db view # interactive session viewer (colored, scrollable)
@@ -31,7 +32,7 @@ opencode-db help # full reference
31
32
  - **`list`** / **`info`** / **`search`** / **`tree`** — навигация и поиск по сессиям
32
33
  - **`directories`** (`dirs`) — все директории запусков, включая отсутствующие; `list --directory` добавляет путь к сессии
33
34
  - **`export`** — экспорт диалога в Markdown (с поддержкой Obsidian)
34
- - **`delete`** / **`prune`** — удаление и массовая очистка с фильтрами
35
+ - **`delete`** / **`prune`** — удаление и массовая очистка с фильтрами; `prune --missing-directories` — защищённая очистка сессий из отсутствующих путей
35
36
  - **`costs`** / **`stats`** — аналитика токенов и расходов
36
37
  - **`schema`** — определяет v1/v2-схему OpenCode, миграции и доступные операции
37
38
  - **`--db-path`** / **`OPENCODE_DB`** — кастомный путь к БД
@@ -21,6 +21,7 @@ require_python()
21
21
  from . import commands # noqa: E402
22
22
  from .db import get_db # noqa: E402
23
23
  from .i18n import _, set_lang # noqa: E402
24
+ from .version import get_version # noqa: E402
24
25
 
25
26
 
26
27
  def build_parser() -> argparse.ArgumentParser:
@@ -41,6 +42,11 @@ def build_parser() -> argparse.ArgumentParser:
41
42
  default=None,
42
43
  help="Path to opencode.db (default: ~/.local/share/opencode/opencode.db)",
43
44
  )
45
+ parser.add_argument(
46
+ "--version",
47
+ action="version",
48
+ version=f"%(prog)s {get_version()}",
49
+ )
44
50
 
45
51
  subparsers = parser.add_subparsers(dest="command", required=True)
46
52
  seen: set[str] = set()
@@ -1,7 +1,11 @@
1
1
  """Команда delete: удаление сессии или массовая очистка."""
2
2
 
3
3
  import argparse
4
+ import os
5
+ import sqlite3
6
+ from datetime import datetime
4
7
 
8
+ from ..config import resolve_db_path
5
9
  from ..db import (
6
10
  SessionError,
7
11
  get_message_count,
@@ -23,6 +27,8 @@ _DELETE_EXAMPLES = [
23
27
  ("--keep-last 30", "help.delete.e4"),
24
28
  ("--before 2026-05-20", "help.delete.e5"),
25
29
  ("--interactive", "help.delete.e6"),
30
+ ("--missing-directories", "help.delete.e7"),
31
+ ("--missing-directories --execute", "help.delete.e8"),
26
32
  ]
27
33
 
28
34
 
@@ -42,6 +48,14 @@ def register(subparsers) -> None:
42
48
  p.add_argument("--dry-run", action="store_true", help="Preview without deleting")
43
49
  p.add_argument("--force", "-f", action="store_true", help="Skip confirmation")
44
50
  p.add_argument("--interactive", action="store_true", help="Choose sessions from a list")
51
+ p.add_argument(
52
+ "--missing-directories",
53
+ action="store_true",
54
+ help="Sessions whose directory no longer exists (preview by default)",
55
+ )
56
+ p.add_argument("--execute", action="store_true", help="Execute missing-directory cleanup")
57
+ p.add_argument("--backup", action="store_true", help="Create a DB backup before deletion")
58
+ p.add_argument("--no-backup", action="store_true", help="Skip the DB backup")
45
59
 
46
60
 
47
61
  def _delete_single(db, session_id, dry_run, force) -> int:
@@ -317,6 +331,161 @@ def _parse_choices(text: str, max_n: int) -> list[int]:
317
331
  return sorted(result)
318
332
 
319
333
 
334
+ _SESSION_SCOPED_TABLES = (
335
+ "part",
336
+ "message",
337
+ "todo",
338
+ "session_message",
339
+ "session_input",
340
+ "session_context_epoch",
341
+ "session_share",
342
+ )
343
+ _EVENT_SCOPED_TABLES = ("event", "event_sequence")
344
+
345
+
346
+ def _missing_directory_targets(db, project_id=None):
347
+ where = "WHERE directory IS NOT NULL AND directory != ''"
348
+ params = []
349
+ if project_id:
350
+ where += " AND project_id = ?"
351
+ params.append(project_id)
352
+ rows = db.execute(
353
+ f"SELECT id, title, directory, time_created, cost FROM session {where}",
354
+ params,
355
+ ).fetchall()
356
+ return [dict(row) for row in rows if not os.path.isdir(row["directory"])]
357
+
358
+
359
+ def _delete_in_chunks(db, table, column, ids):
360
+ deleted = 0
361
+ for start in range(0, len(ids), 500):
362
+ chunk = ids[start : start + 500]
363
+ placeholders = ",".join("?" for _ in chunk)
364
+ cur = db.execute(f"DELETE FROM {table} WHERE {column} IN ({placeholders})", chunk)
365
+ deleted += max(cur.rowcount, 0)
366
+ return deleted
367
+
368
+
369
+ def _delete_session_rows(db, ids, tables):
370
+ db.execute("PRAGMA foreign_keys=ON")
371
+ deleted = 0
372
+ for table in _EVENT_SCOPED_TABLES:
373
+ if table in tables:
374
+ deleted += _delete_in_chunks(db, table, "aggregate_id", ids)
375
+ for table in _SESSION_SCOPED_TABLES:
376
+ if table in tables:
377
+ deleted += _delete_in_chunks(db, table, "session_id", ids)
378
+ deleted += _delete_in_chunks(db, "session", "id", ids)
379
+ return deleted
380
+
381
+
382
+ def _backup_database(db, db_path):
383
+ stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
384
+ target = f"{db_path}.bak.{stamp}"
385
+ suffix = 1
386
+ while os.path.exists(target):
387
+ target = f"{db_path}.bak.{stamp}-{suffix}"
388
+ suffix += 1
389
+ dest = sqlite3.connect(target)
390
+ try:
391
+ db.backup(dest)
392
+ finally:
393
+ dest.close()
394
+ return target
395
+
396
+
397
+ def _delete_missing_dirs(db, args) -> int:
398
+ incompatible = any(
399
+ _a(args, name)
400
+ for name in ("older_than", "before", "after", "keep_last", "interactive", "session_id")
401
+ )
402
+ if incompatible:
403
+ print(_("delete.missing_incompatible"))
404
+ return 1
405
+
406
+ backup = _a(args, "backup", False)
407
+ no_backup = _a(args, "no_backup", False)
408
+ if backup and no_backup:
409
+ print(_("delete.missing_both_backup"))
410
+ return 1
411
+
412
+ project_id = _a(args, "project")
413
+ targets = _missing_directory_targets(db, project_id)
414
+ if not targets:
415
+ print(_("delete.missing_none"))
416
+ return 0
417
+
418
+ db_path = resolve_db_path()[0]
419
+ groups = {}
420
+ for target in targets:
421
+ groups.setdefault(target["directory"], []).append(target)
422
+ total_cost = sum(target["cost"] or 0 for target in targets)
423
+ times = [target["time_created"] for target in targets if target["time_created"]]
424
+ first_ts = min(times) if times else None
425
+ last_ts = max(times) if times else None
426
+
427
+ print(f"\n {'=' * 60}")
428
+ print(f" {_('delete.missing_header')}")
429
+ print(f" {'=' * 60}")
430
+ print(_("delete.missing_db", path=db_path))
431
+ print(_("delete.missing_summary", sessions=len(targets), dirs=len(groups), cost=format_cost(total_cost)))
432
+ first_label = format_ts(first_ts) if first_ts else "—"
433
+ last_label = format_ts(last_ts) if last_ts else "—"
434
+ print(f" {first_label} — {last_label}")
435
+ for path in sorted(groups):
436
+ print(_("delete.missing_dir_line", path=path, n=len(groups[path])))
437
+
438
+ if not _a(args, "execute", False) or _a(args, "dry_run", False):
439
+ print(f"\n{_('delete.missing_preview')}")
440
+ return 0
441
+
442
+ if not backup and not no_backup:
443
+ print(_("delete.missing_backup_choice"))
444
+ return 1
445
+ if no_backup:
446
+ print(_("delete.missing_no_backup_warning"))
447
+
448
+ phrase = f"DELETE {len(targets)} SESSIONS"
449
+ try:
450
+ answer = input(_("delete.missing_phrase_prompt", phrase=phrase))
451
+ except EOFError:
452
+ print(_("canceled"))
453
+ return 0
454
+ if answer.strip() != phrase:
455
+ print(_("delete.missing_phrase_mismatch"))
456
+ return 0
457
+
458
+ if not confirm(_("delete.missing_final_confirm", n=len(targets))):
459
+ print(_("canceled"))
460
+ return 0
461
+
462
+ if backup:
463
+ try:
464
+ backup_path = _backup_database(db, db_path)
465
+ except Exception as exc:
466
+ print(_("delete.missing_backup_failed", error=exc))
467
+ return 1
468
+ print(_("delete.missing_backup_done", path=backup_path))
469
+
470
+ fresh = _missing_directory_targets(db, project_id)
471
+ fresh_ids = {target["id"] for target in fresh}
472
+ target_ids = {target["id"] for target in targets}
473
+ if fresh_ids != target_ids:
474
+ print(_("delete.missing_changed"))
475
+ return 1
476
+
477
+ try:
478
+ deleted = _delete_session_rows(db, sorted(target_ids), set(open_store(db).schema.tables))
479
+ db.commit()
480
+ except Exception as exc:
481
+ db.rollback()
482
+ print(_("delete.missing_error", error=exc))
483
+ return 1
484
+
485
+ print(_("delete.missing_done", n=len(target_ids), rows=deleted))
486
+ return 0
487
+
488
+
320
489
  def _a(args, name: str, default=None):
321
490
  """getattr с дефолтом — для совместимости Namespace из разных парсеров."""
322
491
  return getattr(args, name, default)
@@ -327,6 +496,11 @@ def run(args, db) -> int:
327
496
  if store.schema.read_only:
328
497
  print(_("schema.unsupported_write"))
329
498
  return 1
499
+ if _a(args, "execute", False) and not _a(args, "missing_directories", False):
500
+ print(_("delete.missing_execute_requires"))
501
+ return 1
502
+ if _a(args, "missing_directories", False):
503
+ return _delete_missing_dirs(db, args)
330
504
  if _a(args, "interactive", False):
331
505
  return _delete_interactive(db, args)
332
506
 
@@ -11,6 +11,8 @@ _PRUNE_EXAMPLES = [
11
11
  ("--keep-last 20", "help.prune.e1"),
12
12
  ("--dry-run", "help.prune.e2"),
13
13
  ("--force", "help.prune.e3"),
14
+ ("--missing-directories", "help.prune.e4"),
15
+ ("--missing-directories --execute", "help.prune.e5"),
14
16
  ]
15
17
 
16
18
 
@@ -26,6 +28,14 @@ def register(subparsers) -> None:
26
28
  p.add_argument("--project", type=str, help="Limit to project")
27
29
  p.add_argument("--dry-run", action="store_true", help="Preview without deleting")
28
30
  p.add_argument("--force", "-f", action="store_true", help="Skip confirmation")
31
+ p.add_argument(
32
+ "--missing-directories",
33
+ action="store_true",
34
+ help="Sessions whose directory no longer exists (preview by default)",
35
+ )
36
+ p.add_argument("--execute", action="store_true", help="Execute missing-directory cleanup")
37
+ p.add_argument("--backup", action="store_true", help="Create a DB backup before deletion")
38
+ p.add_argument("--no-backup", action="store_true", help="Skip the DB backup")
29
39
 
30
40
 
31
41
  def run(args, db) -> int:
@@ -215,6 +215,26 @@ L: dict[str, dict[str, str]] = {
215
215
  "delete.mass_confirm": {"ru": "Удалить {n} сессий?", "en": "Delete {n} sessions?"},
216
216
  "delete.done_mass": {"ru": " ✅ Удалено {n} сессий.", "en": " ✅ Deleted {n} sessions."},
217
217
  "delete.interactive_header": {"ru": "Последние сессии:", "en": "Recent sessions:"},
218
+ "delete.missing_header": {"ru": "ОЧИСТКА СЕССИЙ ИЗ ОТСУТСТВУЮЩИХ ДИРЕКТОРИЙ", "en": "DELETE SESSIONS FROM MISSING DIRECTORIES"},
219
+ "delete.missing_none": {"ru": " ✅ Сессий из отсутствующих директорий не найдено.", "en": " ✅ No sessions from missing directories found."},
220
+ "delete.missing_summary": {"ru": " Сессий: {sessions}, директорий: {dirs}, стоимость: {cost}", "en": " Sessions: {sessions}, directories: {dirs}, cost: {cost}"},
221
+ "delete.missing_dir_line": {"ru": " {path} — {n} сессий", "en": " {path} — {n} sessions"},
222
+ "delete.missing_preview": {"ru": "Это preview. Для удаления добавь --execute.", "en": "This is a preview. Add --execute to delete."},
223
+ "delete.missing_incompatible": {"ru": "--missing-directories нельзя сочетать с --older-than/--before/--after/--keep-last/--interactive/session_id.", "en": "--missing-directories cannot be combined with --older-than/--before/--after/--keep-last/--interactive/session_id."},
224
+ "delete.missing_execute_requires": {"ru": "--execute используется только вместе с --missing-directories.", "en": "--execute can only be used with --missing-directories."},
225
+ "delete.missing_no_backup_warning": {"ru": " ⚠️ Backup отключён (--no-backup). Удаление необратимо.", "en": " ⚠️ Backup disabled (--no-backup). Deletion is irreversible."},
226
+ "delete.missing_backup_choice": {"ru": "Для --execute укажи --backup или --no-backup (осознанный выбор).", "en": "For --execute, choose --backup or --no-backup explicitly."},
227
+ "delete.missing_both_backup": {"ru": "Нельзя одновременно --backup и --no-backup.", "en": "Cannot use --backup and --no-backup together."},
228
+ "delete.missing_phrase_prompt": {"ru": "Введи точно «{phrase}» для продолжения: ", "en": "Type exactly \"{phrase}\" to continue: "},
229
+ "delete.missing_phrase_mismatch": {"ru": "Фраза не совпала. Удаление отменено.", "en": "Phrase did not match. Deletion canceled."},
230
+ "delete.missing_final_confirm": {"ru": "Последнее подтверждение: удалить {n} сессий? Это необратимо.", "en": "Final confirmation: delete {n} sessions? This is irreversible."},
231
+ "delete.missing_changed": {"ru": "Список кандидатов изменился. Удаление отменено; повтори preview.", "en": "Candidate list changed. Deletion canceled; rerun the preview."},
232
+ "delete.missing_backup_done": {"ru": " Backup: {path}", "en": " Backup: {path}"},
233
+ "delete.missing_backup_failed": {"ru": "Не удалось создать backup: {error}. Удаление отменено.", "en": "Failed to create backup: {error}. Deletion canceled."},
234
+ "delete.missing_done": {"ru": " ✅ Удалено сессий: {n}; строк: {rows}.", "en": " ✅ Sessions deleted: {n}; rows: {rows}."},
235
+ "delete.missing_error": {"ru": "Ошибка удаления: {error}. Транзакция отменена.", "en": "Deletion error: {error}. Transaction rolled back."},
236
+ "delete.missing_db": {"ru": " База: {path}", "en": " Database: {path}"},
237
+ "delete.missing_dry_run": {"ru": "Это preview; база не изменена.", "en": "This is a preview; the database was not changed."},
218
238
  "delete.interactive_prompt": {
219
239
  "ru": " Введи номера (1,3-5) или Enter для отмены: ",
220
240
  "en": " Enter numbers (1,3-5) or Enter to cancel: ",
@@ -550,6 +570,10 @@ L: dict[str, dict[str, str]] = {
550
570
  "ru": "Удалить сессии до указанной даты",
551
571
  "en": "Delete sessions before a date",
552
572
  },
573
+ "help.delete.e7": {"ru": "Очистить сессии из отсутствующих директорий (preview)", "en": "Preview sessions from missing directories"},
574
+ "help.delete.e8": {"ru": "Выполнить очистку после подтверждений", "en": "Execute cleanup after confirmations"},
575
+ "help.prune.e4": {"ru": "Preview очистки отсутствующих директорий", "en": "Preview missing-directory cleanup"},
576
+ "help.prune.e5": {"ru": "Выполнить очистку отсутствующих директорий", "en": "Execute missing-directory cleanup"},
553
577
  "help.delete.e6": {
554
578
  "ru": "Интерактивный выбор из списка",
555
579
  "en": "Choose sessions from a list",
@@ -0,0 +1,25 @@
1
+ """Version lookup for the installed package and source checkouts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import tomllib
6
+ from functools import lru_cache
7
+ from importlib.metadata import PackageNotFoundError
8
+ from importlib.metadata import version as package_version
9
+ from pathlib import Path
10
+
11
+
12
+ @lru_cache(maxsize=1)
13
+ def get_version() -> str:
14
+ """Return the installed package version, with a source-tree fallback."""
15
+ try:
16
+ return package_version("opencode-db")
17
+ except PackageNotFoundError:
18
+ pass
19
+
20
+ pyproject = Path(__file__).resolve().parents[1] / "pyproject.toml"
21
+ try:
22
+ data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
23
+ return str(data["project"]["version"])
24
+ except (OSError, KeyError, TypeError, tomllib.TOMLDecodeError):
25
+ return "unknown"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: opencode-db
3
- Version: 0.8.2
3
+ Version: 0.9.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
@@ -31,6 +31,7 @@ uv tool install opencode-db
31
31
  ## Quick start
32
32
 
33
33
  ```bash
34
+ opencode-db --version # installed version
34
35
  opencode-db list # recent sessions
35
36
  opencode-db directories # launch directories, including missing ones
36
37
  opencode-db view # interactive session viewer (colored, scrollable)
@@ -47,7 +48,7 @@ opencode-db help # full reference
47
48
  - **`list`** / **`info`** / **`search`** / **`tree`** — навигация и поиск по сессиям
48
49
  - **`directories`** (`dirs`) — все директории запусков, включая отсутствующие; `list --directory` добавляет путь к сессии
49
50
  - **`export`** — экспорт диалога в Markdown (с поддержкой Obsidian)
50
- - **`delete`** / **`prune`** — удаление и массовая очистка с фильтрами
51
+ - **`delete`** / **`prune`** — удаление и массовая очистка с фильтрами; `prune --missing-directories` — защищённая очистка сессий из отсутствующих путей
51
52
  - **`costs`** / **`stats`** — аналитика токенов и расходов
52
53
  - **`schema`** — определяет v1/v2-схему OpenCode, миграции и доступные операции
53
54
  - **`--db-path`** / **`OPENCODE_DB`** — кастомный путь к БД
@@ -14,6 +14,7 @@ opencode_db/runtime.py
14
14
  opencode_db/schema.py
15
15
  opencode_db/schema_manifest.json
16
16
  opencode_db/utils.py
17
+ opencode_db/version.py
17
18
  opencode_db.egg-info/PKG-INFO
18
19
  opencode_db.egg-info/SOURCES.txt
19
20
  opencode_db.egg-info/dependency_links.txt
@@ -48,4 +49,5 @@ tests/test_i18n.py
48
49
  tests/test_runtime.py
49
50
  tests/test_schema.py
50
51
  tests/test_store_v2.py
51
- tests/test_utils.py
52
+ tests/test_utils.py
53
+ tests/test_version.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "opencode-db"
7
- version = "0.8.2"
7
+ version = "0.9.0"
8
8
  description = "OpenCode database CLI manager — browse, export, analyze, clean up sessions"
9
9
  readme = "docs/README_PYPI.md"
10
10
  requires-python = ">=3.12"
@@ -784,3 +784,124 @@ class TestDirectoriesCommand:
784
784
  )
785
785
  assert run(args, db) == 0
786
786
  assert "None" not in capsys.readouterr().out
787
+
788
+
789
+ class TestMissingDirectoryCleanup:
790
+ def _args(self, **overrides):
791
+ values = dict(
792
+ session_id=None,
793
+ older_than=None,
794
+ before=None,
795
+ after=None,
796
+ keep_last=None,
797
+ project=None,
798
+ dry_run=False,
799
+ force=False,
800
+ interactive=False,
801
+ missing_directories=True,
802
+ execute=False,
803
+ backup=False,
804
+ no_backup=False,
805
+ )
806
+ values.update(overrides)
807
+ return _ns(**values)
808
+
809
+ def test_preview_does_not_delete(self, db, capsys) -> None:
810
+ from opencode_db.commands.cmd_delete import run
811
+
812
+ assert run(self._args(), db) == 0
813
+ assert db.execute("SELECT COUNT(*) FROM session").fetchone()[0] == 4
814
+ output = capsys.readouterr().out
815
+ assert "preview" in output.lower()
816
+ assert "/home/user/proj1" in output
817
+
818
+ def test_execute_requires_backup_choice(self, db) -> None:
819
+ from opencode_db.commands.cmd_delete import run
820
+
821
+ assert run(self._args(execute=True, force=True), db) == 1
822
+ assert db.execute("SELECT COUNT(*) FROM session").fetchone()[0] == 4
823
+
824
+ def test_execute_requires_missing_directories(self, db) -> None:
825
+ from opencode_db.commands.cmd_delete import run
826
+
827
+ args = self._args(missing_directories=False, execute=True)
828
+ assert run(args, db) == 1
829
+
830
+ def test_phrase_mismatch_cancels(self, db) -> None:
831
+ from opencode_db.commands.cmd_delete import run
832
+
833
+ with patch("builtins.input", return_value="no"):
834
+ assert run(self._args(execute=True, no_backup=True), db) == 0
835
+ assert db.execute("SELECT COUNT(*) FROM session").fetchone()[0] == 4
836
+
837
+ def test_final_confirmation_cancels(self, db) -> None:
838
+ from opencode_db.commands.cmd_delete import run
839
+
840
+ with (
841
+ patch("builtins.input", return_value="DELETE 2 SESSIONS"),
842
+ patch("opencode_db.commands.cmd_delete.confirm", return_value=False),
843
+ ):
844
+ assert run(self._args(execute=True, no_backup=True), db) == 0
845
+ assert db.execute("SELECT COUNT(*) FROM session").fetchone()[0] == 4
846
+
847
+ def test_successful_delete_preserves_existing_directory(self, db_file, tmp_path) -> None:
848
+ import sqlite3
849
+
850
+ from opencode_db.commands.cmd_delete import run
851
+
852
+ present = tmp_path / "present"
853
+ present.mkdir()
854
+ conn = sqlite3.connect(db_file)
855
+ conn.row_factory = sqlite3.Row
856
+ conn.execute("UPDATE session SET directory = ? WHERE id = 'ses_001'", (str(present),))
857
+ conn.commit()
858
+
859
+ with (
860
+ patch("builtins.input", return_value="DELETE 1 SESSIONS"),
861
+ patch("opencode_db.commands.cmd_delete.confirm", return_value=True),
862
+ ):
863
+ assert run(
864
+ self._args(execute=True, no_backup=True),
865
+ conn,
866
+ ) == 0
867
+
868
+ assert conn.execute("SELECT COUNT(*) FROM session").fetchone()[0] == 3
869
+ assert conn.execute("SELECT id FROM session WHERE id = 'ses_001'").fetchone() is not None
870
+ assert conn.execute("SELECT id FROM session WHERE id = 'ses_002'").fetchone() is None
871
+ conn.close()
872
+
873
+ def test_backup_and_event_cleanup(self, db_file, tmp_path) -> None:
874
+ import sqlite3
875
+
876
+ from opencode_db.commands.cmd_delete import run
877
+
878
+ present = tmp_path / "present"
879
+ present.mkdir()
880
+ conn = sqlite3.connect(db_file)
881
+ conn.row_factory = sqlite3.Row
882
+ conn.execute("UPDATE session SET directory = ? WHERE id = 'ses_001'", (str(present),))
883
+ conn.executescript(
884
+ """
885
+ CREATE TABLE event_sequence (aggregate_id TEXT, seq INTEGER, owner_id TEXT);
886
+ CREATE TABLE event (id TEXT, aggregate_id TEXT, seq INTEGER, type TEXT, data TEXT);
887
+ """
888
+ )
889
+ conn.execute(
890
+ "INSERT INTO event_sequence VALUES ('ses_002', 1, NULL)"
891
+ )
892
+ conn.execute(
893
+ "INSERT INTO event VALUES ('evt_1', 'ses_002', 1, 'test', '{}')"
894
+ )
895
+ conn.commit()
896
+
897
+ with (
898
+ patch("builtins.input", return_value="DELETE 1 SESSIONS"),
899
+ patch("opencode_db.commands.cmd_delete.confirm", return_value=True),
900
+ patch("opencode_db.commands.cmd_delete.resolve_db_path", return_value=(db_file, False)),
901
+ ):
902
+ assert run(self._args(execute=True, backup=True), conn) == 0
903
+
904
+ assert list(tmp_path.glob("test.db.bak.*"))
905
+ assert conn.execute("SELECT COUNT(*) FROM event").fetchone()[0] == 0
906
+ assert conn.execute("SELECT COUNT(*) FROM event_sequence").fetchone()[0] == 0
907
+ conn.close()
@@ -379,3 +379,28 @@ def test_v2_projects_global_and_paths(v2db, capsys) -> None:
379
379
  assert by_id["global"]["path"] is None
380
380
  assert by_id["proj_v2"]["path"] == "/tmp/v2"
381
381
  assert by_id["proj_v2"]["name"] == "V2 Project"
382
+
383
+
384
+ def test_v2_missing_directory_cleanup_is_refused(v2db, capsys) -> None:
385
+ from opencode_db.commands import cmd_delete
386
+
387
+ code = cmd_delete.run(
388
+ _ns(
389
+ session_id=None,
390
+ older_than=None,
391
+ before=None,
392
+ after=None,
393
+ keep_last=None,
394
+ project=None,
395
+ dry_run=False,
396
+ force=False,
397
+ interactive=False,
398
+ missing_directories=True,
399
+ execute=True,
400
+ backup=False,
401
+ no_backup=True,
402
+ ),
403
+ v2db,
404
+ )
405
+ assert code == 1
406
+ assert "v2" in capsys.readouterr().out.lower()
@@ -0,0 +1,22 @@
1
+ import re
2
+
3
+ import pytest
4
+
5
+ from opencode_db.version import get_version
6
+
7
+
8
+ def test_get_version_returns_a_version() -> None:
9
+ value = get_version()
10
+ assert value == "unknown" or re.fullmatch(r"\d+\.\d+\.\d+(?:[.\-+][0-9A-Za-z.\-+]+)?", value)
11
+
12
+
13
+ def test_version_flag_prints_version(capsys) -> None:
14
+ from opencode_db.cli import build_parser
15
+
16
+ parser = build_parser()
17
+ with pytest.raises(SystemExit) as exc:
18
+ parser.parse_args(["--version"])
19
+
20
+ assert exc.value.code == 0
21
+ output = capsys.readouterr().out.strip()
22
+ assert output == f"opencode-db {get_version()}"
File without changes
File without changes