opencode-db 0.8.0__tar.gz → 0.8.2__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.
- {opencode_db-0.8.0/opencode_db.egg-info → opencode_db-0.8.2}/PKG-INFO +5 -1
- {opencode_db-0.8.0 → opencode_db-0.8.2}/README.md +40 -4
- {opencode_db-0.8.0 → opencode_db-0.8.2}/docs/README_PYPI.md +4 -0
- {opencode_db-0.8.0 → opencode_db-0.8.2}/opencode_db/cli.py +5 -1
- {opencode_db-0.8.0 → opencode_db-0.8.2}/opencode_db/commands/__init__.py +3 -0
- opencode_db-0.8.2/opencode_db/commands/cmd_directories.py +135 -0
- {opencode_db-0.8.0 → opencode_db-0.8.2}/opencode_db/commands/cmd_help.py +11 -0
- {opencode_db-0.8.0 → opencode_db-0.8.2}/opencode_db/commands/cmd_list.py +23 -11
- opencode_db-0.8.2/opencode_db/commands/cmd_projects.py +130 -0
- {opencode_db-0.8.0 → opencode_db-0.8.2}/opencode_db/i18n.py +29 -0
- {opencode_db-0.8.0 → opencode_db-0.8.2}/opencode_db/store/base.py +124 -0
- {opencode_db-0.8.0 → opencode_db-0.8.2}/opencode_db/store/v1.py +1 -1
- {opencode_db-0.8.0 → opencode_db-0.8.2}/opencode_db/store/v2.py +1 -1
- {opencode_db-0.8.0 → opencode_db-0.8.2}/opencode_db/utils.py +8 -0
- {opencode_db-0.8.0 → opencode_db-0.8.2/opencode_db.egg-info}/PKG-INFO +5 -1
- {opencode_db-0.8.0 → opencode_db-0.8.2}/opencode_db.egg-info/SOURCES.txt +1 -0
- {opencode_db-0.8.0 → opencode_db-0.8.2}/pyproject.toml +1 -1
- {opencode_db-0.8.0 → opencode_db-0.8.2}/tests/test_commands.py +85 -0
- {opencode_db-0.8.0 → opencode_db-0.8.2}/tests/test_store_v2.py +99 -0
- opencode_db-0.8.0/opencode_db/commands/cmd_projects.py +0 -83
- {opencode_db-0.8.0 → opencode_db-0.8.2}/LICENSE +0 -0
- {opencode_db-0.8.0 → opencode_db-0.8.2}/opencode_db/__init__.py +0 -0
- {opencode_db-0.8.0 → opencode_db-0.8.2}/opencode_db/__main__.py +0 -0
- {opencode_db-0.8.0 → opencode_db-0.8.2}/opencode_db/commands/cmd_costs.py +0 -0
- {opencode_db-0.8.0 → opencode_db-0.8.2}/opencode_db/commands/cmd_delete.py +0 -0
- {opencode_db-0.8.0 → opencode_db-0.8.2}/opencode_db/commands/cmd_export.py +0 -0
- {opencode_db-0.8.0 → opencode_db-0.8.2}/opencode_db/commands/cmd_info.py +0 -0
- {opencode_db-0.8.0 → opencode_db-0.8.2}/opencode_db/commands/cmd_prune.py +0 -0
- {opencode_db-0.8.0 → opencode_db-0.8.2}/opencode_db/commands/cmd_schema.py +0 -0
- {opencode_db-0.8.0 → opencode_db-0.8.2}/opencode_db/commands/cmd_search.py +0 -0
- {opencode_db-0.8.0 → opencode_db-0.8.2}/opencode_db/commands/cmd_stats.py +0 -0
- {opencode_db-0.8.0 → opencode_db-0.8.2}/opencode_db/commands/cmd_todos.py +0 -0
- {opencode_db-0.8.0 → opencode_db-0.8.2}/opencode_db/commands/cmd_tree.py +0 -0
- {opencode_db-0.8.0 → opencode_db-0.8.2}/opencode_db/commands/cmd_vacuum.py +0 -0
- {opencode_db-0.8.0 → opencode_db-0.8.2}/opencode_db/commands/cmd_view.py +0 -0
- {opencode_db-0.8.0 → opencode_db-0.8.2}/opencode_db/config.py +0 -0
- {opencode_db-0.8.0 → opencode_db-0.8.2}/opencode_db/db.py +0 -0
- {opencode_db-0.8.0 → opencode_db-0.8.2}/opencode_db/formatters.py +0 -0
- {opencode_db-0.8.0 → opencode_db-0.8.2}/opencode_db/markdown_to_ansi.py +0 -0
- {opencode_db-0.8.0 → opencode_db-0.8.2}/opencode_db/runtime.py +0 -0
- {opencode_db-0.8.0 → opencode_db-0.8.2}/opencode_db/schema.py +0 -0
- {opencode_db-0.8.0 → opencode_db-0.8.2}/opencode_db/schema_manifest.json +0 -0
- {opencode_db-0.8.0 → opencode_db-0.8.2}/opencode_db/store/__init__.py +0 -0
- {opencode_db-0.8.0 → opencode_db-0.8.2}/opencode_db.egg-info/dependency_links.txt +0 -0
- {opencode_db-0.8.0 → opencode_db-0.8.2}/opencode_db.egg-info/entry_points.txt +0 -0
- {opencode_db-0.8.0 → opencode_db-0.8.2}/opencode_db.egg-info/requires.txt +0 -0
- {opencode_db-0.8.0 → opencode_db-0.8.2}/opencode_db.egg-info/top_level.txt +0 -0
- {opencode_db-0.8.0 → opencode_db-0.8.2}/setup.cfg +0 -0
- {opencode_db-0.8.0 → opencode_db-0.8.2}/tests/test_db.py +0 -0
- {opencode_db-0.8.0 → opencode_db-0.8.2}/tests/test_formatters.py +0 -0
- {opencode_db-0.8.0 → opencode_db-0.8.2}/tests/test_i18n.py +0 -0
- {opencode_db-0.8.0 → opencode_db-0.8.2}/tests/test_runtime.py +0 -0
- {opencode_db-0.8.0 → opencode_db-0.8.2}/tests/test_schema.py +0 -0
- {opencode_db-0.8.0 → opencode_db-0.8.2}/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.
|
|
3
|
+
Version: 0.8.2
|
|
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
|
|
@@ -32,6 +32,7 @@ uv tool install opencode-db
|
|
|
32
32
|
|
|
33
33
|
```bash
|
|
34
34
|
opencode-db list # recent sessions
|
|
35
|
+
opencode-db directories # launch directories, including missing ones
|
|
35
36
|
opencode-db view # interactive session viewer (colored, scrollable)
|
|
36
37
|
opencode-db stats # database summary
|
|
37
38
|
opencode-db schema # detect v1/v2 schema
|
|
@@ -44,6 +45,7 @@ opencode-db help # full reference
|
|
|
44
45
|
|
|
45
46
|
- **`view`** — просмотр сессии в терминале с ANSI-цветами, Markdown-рендерингом и прокруткой через `less`
|
|
46
47
|
- **`list`** / **`info`** / **`search`** / **`tree`** — навигация и поиск по сессиям
|
|
48
|
+
- **`directories`** (`dirs`) — все директории запусков, включая отсутствующие; `list --directory` добавляет путь к сессии
|
|
47
49
|
- **`export`** — экспорт диалога в Markdown (с поддержкой Obsidian)
|
|
48
50
|
- **`delete`** / **`prune`** — удаление и массовая очистка с фильтрами
|
|
49
51
|
- **`costs`** / **`stats`** — аналитика токенов и расходов
|
|
@@ -63,4 +65,6 @@ pip install opencode-db # zero external dependencies
|
|
|
63
65
|
- [OpenCode](https://opencode.ai) (must have been run at least once)
|
|
64
66
|
- macOS / Linux
|
|
65
67
|
|
|
68
|
+
**Directory support:** `global` is a synthetic project for non-Git directories; `directories` shows the real `session.directory` and marks it as `Global (no project root)`. Missing paths are detected with `os.path.isdir()` at command runtime and can be filtered with `--missing`.
|
|
69
|
+
|
|
66
70
|
**Schema support:** OpenCode 1.19.x (v1) — read and write; OpenCode 2.x preview (v2) — read-only. Run `opencode-db schema` to check the detected schema. On v2, `delete`, `prune`, and `vacuum` are refused until write support is implemented.
|
|
@@ -172,6 +172,7 @@ opencode-db list --project proj_xxx # filter by project
|
|
|
172
172
|
opencode-db list --agent build # filter by agent type
|
|
173
173
|
opencode-db list --model deepseek # filter by model name
|
|
174
174
|
opencode-db list --json # JSON output
|
|
175
|
+
opencode-db list --all --directory # include directory paths
|
|
175
176
|
```
|
|
176
177
|
|
|
177
178
|
| Flag | Description | Default |
|
|
@@ -184,6 +185,37 @@ opencode-db list --json # JSON output
|
|
|
184
185
|
| `--sort {date,cost,tokens,messages}` | Sort order | date |
|
|
185
186
|
| `--json` | Output as JSON | — |
|
|
186
187
|
| `--all` | Show all (overrides --limit) | — |
|
|
188
|
+
| `--directory` | Add a session directory column | — |
|
|
189
|
+
|
|
190
|
+
---
|
|
191
|
+
|
|
192
|
+
### `directories` — launch directories
|
|
193
|
+
|
|
194
|
+
Lists every directory where OpenCode has run, including paths that no longer exist. Combines session directories with project roots and project-directory mappings.
|
|
195
|
+
|
|
196
|
+
```bash
|
|
197
|
+
opencode-db directories # grouped by path
|
|
198
|
+
opencode-db directories --missing # only missing/inaccessible paths
|
|
199
|
+
opencode-db directories --existing # only existing paths
|
|
200
|
+
opencode-db directories --project global # Global sessions by real directory
|
|
201
|
+
opencode-db directories --json # full paths, counts, tokens, cost
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
`global` is a synthetic project for non-Git directories. The command never shows
|
|
205
|
+
`/` as a real directory; it shows the actual `session.directory` and marks the
|
|
206
|
+
project as `Global (no project root)`.
|
|
207
|
+
|
|
208
|
+
| Flag | Description | Default |
|
|
209
|
+
|---|---|---|
|
|
210
|
+
| `--missing` | Only paths not found on disk | — |
|
|
211
|
+
| `--existing` | Only paths that exist | — |
|
|
212
|
+
| `--project ID` | Filter by project | — |
|
|
213
|
+
| `--sort {date,sessions,cost,path}` | Sort order | date |
|
|
214
|
+
| `--limit N` | Max entries | 50 |
|
|
215
|
+
| `--no-check` | Skip filesystem check (`exists: null`) | — |
|
|
216
|
+
| `--json` | Output as JSON | — |
|
|
217
|
+
|
|
218
|
+
> `os.path.isdir()` is a point-in-time check. An offline volume or permission error can look like a missing directory.
|
|
187
219
|
|
|
188
220
|
---
|
|
189
221
|
|
|
@@ -390,13 +422,17 @@ Example output:
|
|
|
390
422
|
Shows all projects in the database with session counts, tokens, and costs.
|
|
391
423
|
|
|
392
424
|
```bash
|
|
393
|
-
opencode-db projects # project table
|
|
425
|
+
opencode-db projects # project table with paths
|
|
394
426
|
opencode-db projects --json # JSON output
|
|
427
|
+
opencode-db projects --missing # projects whose path is gone
|
|
428
|
+
opencode-db projects --no-global # hide the synthetic Global project
|
|
395
429
|
```
|
|
396
430
|
|
|
397
431
|
| Flag | Description |
|
|
398
432
|
|---|---|
|
|
399
433
|
| `--json` | JSON output |
|
|
434
|
+
| `--missing` | Only projects with a missing path |
|
|
435
|
+
| `--no-global` | Hide the synthetic Global project |
|
|
400
436
|
|
|
401
437
|
---
|
|
402
438
|
|
|
@@ -540,7 +576,7 @@ opencode-db/
|
|
|
540
576
|
│ ├── config.py ← constants and paths
|
|
541
577
|
│ ├── db.py ← SQLite queries
|
|
542
578
|
│ ├── formatters.py ← tables, JSON, Markdown, snapshots
|
|
543
|
-
│ ├── i18n.py ← localization (EN/RU,
|
|
579
|
+
│ ├── i18n.py ← localization (EN/RU, 324 keys)
|
|
544
580
|
│ ├── markdown_to_ansi.py ← Markdown → ANSI renderer for `view`
|
|
545
581
|
│ ├── runtime.py ← Python 3.12+ version guard
|
|
546
582
|
│ ├── utils.py ← date/token formatting, validation
|
|
@@ -689,7 +725,7 @@ opencode-db --lang en help # English help text
|
|
|
689
725
|
opencode-db --lang en stats # English statistics
|
|
690
726
|
```
|
|
691
727
|
|
|
692
|
-
The `--lang` flag applies to any command. All
|
|
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.
|
|
693
729
|
|
|
694
730
|
The language can also be set via environment variable `OPENCODE_DB_LANG=en`.
|
|
695
731
|
|
|
@@ -774,7 +810,7 @@ source .venv/bin/activate
|
|
|
774
810
|
uv pip install -e ".[rich]"
|
|
775
811
|
uv pip install pytest ruff==0.17.1
|
|
776
812
|
|
|
777
|
-
make check # check-python + ruff +
|
|
813
|
+
make check # check-python + ruff + 214 tests
|
|
778
814
|
```
|
|
779
815
|
|
|
780
816
|
`make check` fails fast with a clear message if the active interpreter is older than 3.12.
|
|
@@ -16,6 +16,7 @@ uv tool install opencode-db
|
|
|
16
16
|
|
|
17
17
|
```bash
|
|
18
18
|
opencode-db list # recent sessions
|
|
19
|
+
opencode-db directories # launch directories, including missing ones
|
|
19
20
|
opencode-db view # interactive session viewer (colored, scrollable)
|
|
20
21
|
opencode-db stats # database summary
|
|
21
22
|
opencode-db schema # detect v1/v2 schema
|
|
@@ -28,6 +29,7 @@ opencode-db help # full reference
|
|
|
28
29
|
|
|
29
30
|
- **`view`** — просмотр сессии в терминале с ANSI-цветами, Markdown-рендерингом и прокруткой через `less`
|
|
30
31
|
- **`list`** / **`info`** / **`search`** / **`tree`** — навигация и поиск по сессиям
|
|
32
|
+
- **`directories`** (`dirs`) — все директории запусков, включая отсутствующие; `list --directory` добавляет путь к сессии
|
|
31
33
|
- **`export`** — экспорт диалога в Markdown (с поддержкой Obsidian)
|
|
32
34
|
- **`delete`** / **`prune`** — удаление и массовая очистка с фильтрами
|
|
33
35
|
- **`costs`** / **`stats`** — аналитика токенов и расходов
|
|
@@ -47,4 +49,6 @@ pip install opencode-db # zero external dependencies
|
|
|
47
49
|
- [OpenCode](https://opencode.ai) (must have been run at least once)
|
|
48
50
|
- macOS / Linux
|
|
49
51
|
|
|
52
|
+
**Directory support:** `global` is a synthetic project for non-Git directories; `directories` shows the real `session.directory` and marks it as `Global (no project root)`. Missing paths are detected with `os.path.isdir()` at command runtime and can be filtered with `--missing`.
|
|
53
|
+
|
|
50
54
|
**Schema support:** OpenCode 1.19.x (v1) — read and write; OpenCode 2.x preview (v2) — read-only. Run `opencode-db schema` to check the detected schema. On v2, `delete`, `prune`, and `vacuum` are refused until write support is implemented.
|
|
@@ -43,7 +43,11 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
43
43
|
)
|
|
44
44
|
|
|
45
45
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
46
|
-
|
|
46
|
+
seen: set[str] = set()
|
|
47
|
+
for mod in commands.get_all().values():
|
|
48
|
+
if mod.__name__ in seen:
|
|
49
|
+
continue
|
|
50
|
+
seen.add(mod.__name__)
|
|
47
51
|
mod.register(subparsers)
|
|
48
52
|
return parser
|
|
49
53
|
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
from . import (
|
|
14
14
|
cmd_costs,
|
|
15
15
|
cmd_delete,
|
|
16
|
+
cmd_directories,
|
|
16
17
|
cmd_export,
|
|
17
18
|
cmd_help,
|
|
18
19
|
cmd_info,
|
|
@@ -35,6 +36,8 @@ COMMANDS = {
|
|
|
35
36
|
"info": cmd_info,
|
|
36
37
|
"export": cmd_export,
|
|
37
38
|
"delete": cmd_delete,
|
|
39
|
+
"directories": cmd_directories,
|
|
40
|
+
"dirs": cmd_directories,
|
|
38
41
|
"costs": cmd_costs,
|
|
39
42
|
"prune": cmd_prune,
|
|
40
43
|
"search": cmd_search,
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Команда directories: все директории запусков OpenCode."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
from ..formatters import print_json, print_table_simple
|
|
7
|
+
from ..i18n import _
|
|
8
|
+
from ..store import open_store
|
|
9
|
+
from ..utils import build_help_epilog, format_cost, format_ts, shorten_path
|
|
10
|
+
|
|
11
|
+
_DIRECTORIES_EXAMPLES = [
|
|
12
|
+
("", "help.directories.e0"),
|
|
13
|
+
("--missing", "help.directories.e1"),
|
|
14
|
+
("--json", "help.directories.e2"),
|
|
15
|
+
("--no-check", "help.directories.e3"),
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def register(subparsers) -> None:
|
|
20
|
+
p = subparsers.add_parser(
|
|
21
|
+
"directories",
|
|
22
|
+
aliases=["dirs"],
|
|
23
|
+
help=_("help.cmd.directories"),
|
|
24
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
25
|
+
epilog=build_help_epilog("directories", _DIRECTORIES_EXAMPLES),
|
|
26
|
+
)
|
|
27
|
+
p.add_argument("--missing", action="store_true", help="Only missing directories")
|
|
28
|
+
p.add_argument("--existing", action="store_true", help="Only existing directories")
|
|
29
|
+
p.add_argument("--project", type=str, help="Filter by project ID")
|
|
30
|
+
p.add_argument(
|
|
31
|
+
"--sort",
|
|
32
|
+
choices=["date", "sessions", "cost", "path"],
|
|
33
|
+
default="date",
|
|
34
|
+
help="Sort order (default: date)",
|
|
35
|
+
)
|
|
36
|
+
p.add_argument("--limit", type=int, default=50, help="Max results (default 50)")
|
|
37
|
+
p.add_argument("--no-check", action="store_true", help="Skip filesystem check")
|
|
38
|
+
p.add_argument("--json", action="store_true", help="JSON output")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def run(args, db) -> Literal[1] | Literal[0]:
|
|
42
|
+
if getattr(args, "missing", False) and getattr(args, "existing", False):
|
|
43
|
+
print(_("directories.conflict"))
|
|
44
|
+
return 1
|
|
45
|
+
|
|
46
|
+
entries = open_store(db).get_directories(check_exists=not getattr(args, "no_check", False))
|
|
47
|
+
|
|
48
|
+
if getattr(args, "project", None):
|
|
49
|
+
entries = [
|
|
50
|
+
entry
|
|
51
|
+
for entry in entries
|
|
52
|
+
if args.project == entry["project_id"] or args.project in entry["project_ids"]
|
|
53
|
+
]
|
|
54
|
+
if getattr(args, "missing", False):
|
|
55
|
+
entries = [entry for entry in entries if entry["exists"] is False]
|
|
56
|
+
elif getattr(args, "existing", False):
|
|
57
|
+
entries = [entry for entry in entries if entry["exists"] is True]
|
|
58
|
+
|
|
59
|
+
sorters = {
|
|
60
|
+
"date": lambda entry: entry["last_ts"] or 0,
|
|
61
|
+
"sessions": lambda entry: entry["sessions"],
|
|
62
|
+
"cost": lambda entry: entry["cost"],
|
|
63
|
+
"path": lambda entry: entry["path"],
|
|
64
|
+
}
|
|
65
|
+
reverse = getattr(args, "sort", "date") != "path"
|
|
66
|
+
entries.sort(key=sorters[getattr(args, "sort", "date")], reverse=reverse)
|
|
67
|
+
if getattr(args, "limit", 50):
|
|
68
|
+
entries = entries[: getattr(args, "limit", 50)]
|
|
69
|
+
|
|
70
|
+
if getattr(args, "json", False):
|
|
71
|
+
data = [
|
|
72
|
+
{
|
|
73
|
+
"path": entry["path"],
|
|
74
|
+
"exists": entry["exists"],
|
|
75
|
+
"project_id": entry["project_id"],
|
|
76
|
+
"project_ids": entry["project_ids"],
|
|
77
|
+
"project": entry["project_label"],
|
|
78
|
+
"is_global": entry["is_global"],
|
|
79
|
+
"sessions": entry["sessions"],
|
|
80
|
+
"first_activity": format_ts(entry["first_ts"]) if entry["first_ts"] else None,
|
|
81
|
+
"last_activity": format_ts(entry["last_ts"]) if entry["last_ts"] else None,
|
|
82
|
+
"tokens_input": entry["tokens_input"],
|
|
83
|
+
"tokens_output": entry["tokens_output"],
|
|
84
|
+
"tokens_reasoning": entry["tokens_reasoning"],
|
|
85
|
+
"tokens_cache_read": entry["tokens_cache_read"],
|
|
86
|
+
"tokens_cache_write": entry["tokens_cache_write"],
|
|
87
|
+
"cost": entry["cost"],
|
|
88
|
+
"sources": entry["sources"],
|
|
89
|
+
}
|
|
90
|
+
for entry in entries
|
|
91
|
+
]
|
|
92
|
+
print_json(data)
|
|
93
|
+
return 0
|
|
94
|
+
|
|
95
|
+
if not entries:
|
|
96
|
+
print(_("directories.none"))
|
|
97
|
+
return 0
|
|
98
|
+
|
|
99
|
+
status_icon = {True: "✓", False: "✗", None: "?"}
|
|
100
|
+
table = []
|
|
101
|
+
for entry in entries:
|
|
102
|
+
status = {
|
|
103
|
+
True: _("directories.exists"),
|
|
104
|
+
False: _("directories.missing"),
|
|
105
|
+
None: _("directories.unknown"),
|
|
106
|
+
}[entry["exists"]]
|
|
107
|
+
last = format_ts(entry["last_ts"], "%Y-%m-%d") if entry["last_ts"] else "—"
|
|
108
|
+
table.append(
|
|
109
|
+
[
|
|
110
|
+
shorten_path(entry["path"], 52),
|
|
111
|
+
f"{status_icon[entry['exists']]} {status}",
|
|
112
|
+
entry["project_label"][:22],
|
|
113
|
+
str(entry["sessions"]),
|
|
114
|
+
last,
|
|
115
|
+
format_cost(entry["cost"]),
|
|
116
|
+
]
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
print(f"\n {_('directories.title')}")
|
|
120
|
+
print_table_simple(
|
|
121
|
+
[
|
|
122
|
+
_("directories.header.path"),
|
|
123
|
+
_("directories.header.status"),
|
|
124
|
+
_("directories.header.project"),
|
|
125
|
+
_("directories.header.sessions"),
|
|
126
|
+
_("directories.header.last"),
|
|
127
|
+
_("directories.header.cost"),
|
|
128
|
+
],
|
|
129
|
+
table,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
missing = sum(1 for entry in entries if entry["exists"] is False)
|
|
133
|
+
sessions = sum(entry["sessions"] for entry in entries)
|
|
134
|
+
print(_("directories.summary", total=len(entries), sessions=sessions, missing=missing))
|
|
135
|
+
return 0
|
|
@@ -41,6 +41,7 @@ _COMMANDS = [
|
|
|
41
41
|
("--sort cost", "help.list.e2"),
|
|
42
42
|
("--project proj_xxx", "help.list.e3"),
|
|
43
43
|
("--json", "help.list.e4"),
|
|
44
|
+
("--directory", "help.list.e5"),
|
|
44
45
|
],
|
|
45
46
|
),
|
|
46
47
|
(
|
|
@@ -119,6 +120,16 @@ _COMMANDS = [
|
|
|
119
120
|
("--json", "help.projects.e1"),
|
|
120
121
|
],
|
|
121
122
|
),
|
|
123
|
+
(
|
|
124
|
+
"directories",
|
|
125
|
+
"help.cmd.directories",
|
|
126
|
+
[
|
|
127
|
+
("", "help.directories.e0"),
|
|
128
|
+
("--missing", "help.directories.e1"),
|
|
129
|
+
("--json", "help.directories.e2"),
|
|
130
|
+
("--no-check", "help.directories.e3"),
|
|
131
|
+
],
|
|
132
|
+
),
|
|
122
133
|
(
|
|
123
134
|
"schema",
|
|
124
135
|
"help.cmd.schema",
|
|
@@ -7,7 +7,7 @@ from ..db import get_session_title, parse_model
|
|
|
7
7
|
from ..formatters import print_json, print_table_simple
|
|
8
8
|
from ..i18n import _
|
|
9
9
|
from ..store import open_store
|
|
10
|
-
from ..utils import build_help_epilog, format_ts
|
|
10
|
+
from ..utils import build_help_epilog, format_ts, shorten_path
|
|
11
11
|
|
|
12
12
|
_LIST_EXAMPLES = [
|
|
13
13
|
("", "help.list.e0"),
|
|
@@ -15,6 +15,7 @@ _LIST_EXAMPLES = [
|
|
|
15
15
|
("--sort cost", "help.list.e2"),
|
|
16
16
|
("--project proj_xxx", "help.list.e3"),
|
|
17
17
|
("--json", "help.list.e4"),
|
|
18
|
+
("--directory", "help.list.e5"),
|
|
18
19
|
]
|
|
19
20
|
|
|
20
21
|
|
|
@@ -38,6 +39,7 @@ def register(subparsers) -> None:
|
|
|
38
39
|
)
|
|
39
40
|
p.add_argument("--json", action="store_true", help="JSON output")
|
|
40
41
|
p.add_argument("--all", action="store_true", help="Show all (no limit)")
|
|
42
|
+
p.add_argument("--directory", action="store_true", help="Show session directory")
|
|
41
43
|
|
|
42
44
|
|
|
43
45
|
def run(args, db) -> Literal[0]:
|
|
@@ -60,6 +62,7 @@ def run(args, db) -> Literal[0]:
|
|
|
60
62
|
"id": r["id"],
|
|
61
63
|
"title": get_session_title(r),
|
|
62
64
|
"project_id": r["project_id"],
|
|
65
|
+
"directory": r["directory"],
|
|
63
66
|
"model": parse_model(r),
|
|
64
67
|
"agent": r["agent"],
|
|
65
68
|
"cost": r["cost"],
|
|
@@ -78,29 +81,38 @@ def run(args, db) -> Literal[0]:
|
|
|
78
81
|
model_name = parse_model(r)
|
|
79
82
|
last_act = format_ts(r["last_time"], "%Y-%m-%d %H:%M") if r["last_time"] else "—"
|
|
80
83
|
|
|
81
|
-
|
|
84
|
+
row = [
|
|
85
|
+
r["id"][:24],
|
|
86
|
+
get_session_title(r)[:32],
|
|
87
|
+
project_name[:16],
|
|
88
|
+
]
|
|
89
|
+
if getattr(args, "directory", False):
|
|
90
|
+
row.append(shorten_path(r["directory"] or "—", 44))
|
|
91
|
+
row.extend(
|
|
82
92
|
[
|
|
83
|
-
r["id"][:24],
|
|
84
|
-
get_session_title(r)[:32],
|
|
85
|
-
project_name[:16],
|
|
86
93
|
model_name[:20],
|
|
87
94
|
last_act,
|
|
88
95
|
str(r["msg_count"]),
|
|
89
96
|
f"${r['cost']:.4f}" if r["cost"] else "—",
|
|
90
97
|
]
|
|
91
98
|
)
|
|
99
|
+
table_rows.append(row)
|
|
92
100
|
|
|
93
|
-
|
|
101
|
+
headers = [
|
|
102
|
+
_("list.header.id"),
|
|
103
|
+
_("list.header.title"),
|
|
104
|
+
_("list.header.project"),
|
|
105
|
+
]
|
|
106
|
+
if getattr(args, "directory", False):
|
|
107
|
+
headers.append(_("list.header.directory"))
|
|
108
|
+
headers.extend(
|
|
94
109
|
[
|
|
95
|
-
_("list.header.id"),
|
|
96
|
-
_("list.header.title"),
|
|
97
|
-
_("list.header.project"),
|
|
98
110
|
_("list.header.model"),
|
|
99
111
|
_("list.header.activity"),
|
|
100
112
|
_("list.header.messages"),
|
|
101
113
|
_("list.header.cost"),
|
|
102
|
-
]
|
|
103
|
-
table_rows,
|
|
114
|
+
]
|
|
104
115
|
)
|
|
116
|
+
print_table_simple(headers, table_rows)
|
|
105
117
|
|
|
106
118
|
return 0
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Команда projects: список проектов со статистикой."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
from ..i18n import _
|
|
8
|
+
from ..store import open_store
|
|
9
|
+
from ..utils import build_help_epilog, format_cost, format_tokens, format_ts, shorten_path
|
|
10
|
+
|
|
11
|
+
_PROJECTS_EXAMPLES = [
|
|
12
|
+
("", "help.projects.e0"),
|
|
13
|
+
("--json", "help.projects.e1"),
|
|
14
|
+
("--no-global", "help.projects.e2"),
|
|
15
|
+
("--missing", "help.projects.e3"),
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def register(subparsers) -> None:
|
|
20
|
+
p = subparsers.add_parser(
|
|
21
|
+
"projects",
|
|
22
|
+
help=_("help.cmd.projects"),
|
|
23
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
24
|
+
epilog=build_help_epilog("projects", _PROJECTS_EXAMPLES),
|
|
25
|
+
)
|
|
26
|
+
p.add_argument("--json", action="store_true", help="JSON output")
|
|
27
|
+
p.add_argument("--no-global", action="store_true", help="Hide the Global project")
|
|
28
|
+
p.add_argument("--missing", action="store_true", help="Only projects with a missing path")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _project_view(r) -> dict:
|
|
32
|
+
worktree = r["worktree"]
|
|
33
|
+
is_global = r["id"] == "global"
|
|
34
|
+
path = None if is_global else worktree
|
|
35
|
+
exists = None if is_global else (os.path.isdir(path) if path else False)
|
|
36
|
+
name = r["name"]
|
|
37
|
+
if not name and path and not is_global:
|
|
38
|
+
name = os.path.basename(str(path).rstrip("/")) or None
|
|
39
|
+
return {
|
|
40
|
+
"id": r["id"],
|
|
41
|
+
"name": name,
|
|
42
|
+
"worktree": worktree,
|
|
43
|
+
"path": path,
|
|
44
|
+
"exists": exists,
|
|
45
|
+
"is_global": is_global,
|
|
46
|
+
"time_created": format_ts(r["time_created"]) if r["time_created"] else None,
|
|
47
|
+
"time_updated": format_ts(r["time_updated"]) if r["time_updated"] else None,
|
|
48
|
+
"sessions": r["session_count"],
|
|
49
|
+
"cost": r["total_cost"],
|
|
50
|
+
"tokens_input": r["total_ti"],
|
|
51
|
+
"tokens_output": r["total_to"],
|
|
52
|
+
"tokens_reasoning": r["total_tr"],
|
|
53
|
+
"tokens_total": r["total_ti"] + r["total_to"] + r["total_tr"],
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def run(args, db) -> Literal[0]:
|
|
58
|
+
rows = open_store(db).get_projects()
|
|
59
|
+
views = [_project_view(r) for r in rows]
|
|
60
|
+
if getattr(args, "no_global", False):
|
|
61
|
+
views = [v for v in views if not v["is_global"]]
|
|
62
|
+
if getattr(args, "missing", False):
|
|
63
|
+
views = [v for v in views if v["exists"] is False]
|
|
64
|
+
|
|
65
|
+
if args.json:
|
|
66
|
+
from ..formatters import print_json
|
|
67
|
+
|
|
68
|
+
data = []
|
|
69
|
+
for v in views:
|
|
70
|
+
data.append(
|
|
71
|
+
{
|
|
72
|
+
"id": v["id"],
|
|
73
|
+
"name": v["name"],
|
|
74
|
+
"worktree": v["worktree"],
|
|
75
|
+
"path": v["path"],
|
|
76
|
+
"exists": v["exists"],
|
|
77
|
+
"is_global": v["is_global"],
|
|
78
|
+
"time_created": v["time_created"],
|
|
79
|
+
"time_updated": v["time_updated"],
|
|
80
|
+
"sessions": v["sessions"],
|
|
81
|
+
"cost": v["cost"],
|
|
82
|
+
"tokens_input": v["tokens_input"],
|
|
83
|
+
"tokens_output": v["tokens_output"],
|
|
84
|
+
"tokens_reasoning": v["tokens_reasoning"],
|
|
85
|
+
"tokens_total": v["tokens_total"],
|
|
86
|
+
}
|
|
87
|
+
)
|
|
88
|
+
print_json(data)
|
|
89
|
+
return 0
|
|
90
|
+
|
|
91
|
+
from ..formatters import print_table_simple
|
|
92
|
+
|
|
93
|
+
if not views:
|
|
94
|
+
print(_("projects.none"))
|
|
95
|
+
return 0
|
|
96
|
+
|
|
97
|
+
table = []
|
|
98
|
+
for v in views:
|
|
99
|
+
name = _("projects.global") if v["is_global"] else (v["name"] or "—")
|
|
100
|
+
path = "—" if v["is_global"] else shorten_path(v["path"], 40)
|
|
101
|
+
if v["exists"] is False:
|
|
102
|
+
path = f"{path} ({_('projects.missing')})"
|
|
103
|
+
updated = v["time_updated"][:10] if v["time_updated"] else "—"
|
|
104
|
+
|
|
105
|
+
table.append(
|
|
106
|
+
[
|
|
107
|
+
v["id"][:12],
|
|
108
|
+
name[:20],
|
|
109
|
+
path,
|
|
110
|
+
updated,
|
|
111
|
+
str(v["sessions"]),
|
|
112
|
+
format_tokens(v["tokens_total"]),
|
|
113
|
+
format_cost(v["cost"]),
|
|
114
|
+
]
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
print_table_simple(
|
|
118
|
+
[
|
|
119
|
+
_("projects.header.id"),
|
|
120
|
+
_("projects.header.name"),
|
|
121
|
+
_("projects.header.path"),
|
|
122
|
+
_("projects.header.activity"),
|
|
123
|
+
_("projects.header.sessions"),
|
|
124
|
+
_("projects.header.tokens"),
|
|
125
|
+
_("projects.header.cost"),
|
|
126
|
+
],
|
|
127
|
+
table,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
return 0
|
|
@@ -93,6 +93,7 @@ L: dict[str, dict[str, str]] = {
|
|
|
93
93
|
"list.header.id": {"ru": "ID", "en": "ID"},
|
|
94
94
|
"list.header.title": {"ru": "Название", "en": "Title"},
|
|
95
95
|
"list.header.project": {"ru": "Проект", "en": "Project"},
|
|
96
|
+
"list.header.directory": {"ru": "Директория", "en": "Directory"},
|
|
96
97
|
"list.header.model": {"ru": "Модель", "en": "Model"},
|
|
97
98
|
"list.header.activity": {"ru": "Активность", "en": "Activity"},
|
|
98
99
|
"list.header.messages": {"ru": "Сообщ.", "en": "Msgs"},
|
|
@@ -303,10 +304,14 @@ L: dict[str, dict[str, str]] = {
|
|
|
303
304
|
# ==================================================================
|
|
304
305
|
"projects.header.id": {"ru": "ID", "en": "ID"},
|
|
305
306
|
"projects.header.name": {"ru": "Название", "en": "Name"},
|
|
307
|
+
"projects.header.path": {"ru": "Путь", "en": "Path"},
|
|
306
308
|
"projects.header.activity": {"ru": "Активность", "en": "Activity"},
|
|
307
309
|
"projects.header.sessions": {"ru": "Сессии", "en": "Sessions"},
|
|
308
310
|
"projects.header.tokens": {"ru": "Токены", "en": "Tokens"},
|
|
309
311
|
"projects.header.cost": {"ru": "Стоимость", "en": "Cost"},
|
|
312
|
+
"projects.global": {"ru": "Global (без корня)", "en": "Global (no project root)"},
|
|
313
|
+
"projects.missing": {"ru": "не найдена", "en": "not found"},
|
|
314
|
+
"projects.none": {"ru": "Проекты не найдены.", "en": "No projects found."},
|
|
310
315
|
# ==================================================================
|
|
311
316
|
# STATS
|
|
312
317
|
# ==================================================================
|
|
@@ -468,6 +473,27 @@ L: dict[str, dict[str, str]] = {
|
|
|
468
473
|
"help.cmd.search": {"ru": "Поиск по сообщениям", "en": "Search messages"},
|
|
469
474
|
"help.cmd.tree": {"ru": "Дерево ветвления сессий", "en": "Session branching tree"},
|
|
470
475
|
"help.cmd.projects": {"ru": "Список проектов", "en": "Project list"},
|
|
476
|
+
"help.cmd.directories": {"ru": "Директории запусков", "en": "Launch directories"},
|
|
477
|
+
"help.directories.e0": {"ru": "Все директории с сессиями", "en": "All directories with sessions"},
|
|
478
|
+
"help.directories.e1": {"ru": "Только отсутствующие директории", "en": "Only missing directories"},
|
|
479
|
+
"help.directories.e2": {"ru": "Вывод в JSON", "en": "JSON output"},
|
|
480
|
+
"help.directories.e3": {"ru": "Не проверять файловую систему", "en": "Skip filesystem check"},
|
|
481
|
+
"directories.title": {"ru": "Директории запусков OpenCode", "en": "OpenCode launch directories"},
|
|
482
|
+
"directories.header.path": {"ru": "Путь", "en": "Path"},
|
|
483
|
+
"directories.header.status": {"ru": "Статус", "en": "Status"},
|
|
484
|
+
"directories.header.project": {"ru": "Проект", "en": "Project"},
|
|
485
|
+
"directories.header.sessions": {"ru": "Сессии", "en": "Sessions"},
|
|
486
|
+
"directories.header.last": {"ru": "Последняя", "en": "Last"},
|
|
487
|
+
"directories.header.cost": {"ru": "Стоимость", "en": "Cost"},
|
|
488
|
+
"directories.exists": {"ru": "да", "en": "yes"},
|
|
489
|
+
"directories.missing": {"ru": "не найдена", "en": "not found"},
|
|
490
|
+
"directories.unknown": {"ru": "?", "en": "?"},
|
|
491
|
+
"directories.global": {"ru": "Global (без корня)", "en": "Global (no project root)"},
|
|
492
|
+
"directories.none": {"ru": "Директории не найдены.", "en": "No directories found."},
|
|
493
|
+
"directories.total": {"ru": "Всего директорий: {n}", "en": "Directories: {n}"},
|
|
494
|
+
"directories.summary": {"ru": "Директорий: {total}, сессий: {sessions}, отсутствует: {missing}", "en": "Directories: {total}, sessions: {sessions}, missing: {missing}"},
|
|
495
|
+
"directories.conflict": {"ru": "Нельзя одновременно использовать --missing и --existing.", "en": "Cannot use --missing and --existing together."},
|
|
496
|
+
|
|
471
497
|
"help.cmd.stats": {"ru": "Статистика БД", "en": "Database statistics"},
|
|
472
498
|
"help.cmd.todos": {"ru": "Задачи из сессий", "en": "Tasks from sessions"},
|
|
473
499
|
"help.cmd.vacuum": {"ru": "Оптимизация БД", "en": "Database optimization"},
|
|
@@ -494,6 +520,7 @@ L: dict[str, dict[str, str]] = {
|
|
|
494
520
|
"help.list.e2": {"ru": "Сортировка по стоимости", "en": "Sort by cost"},
|
|
495
521
|
"help.list.e3": {"ru": "Фильтр по проекту", "en": "Filter by project"},
|
|
496
522
|
"help.list.e4": {"ru": "Вывод в JSON", "en": "JSON output"},
|
|
523
|
+
"help.list.e5": {"ru": "Показать директории сессий", "en": "Show session directories"},
|
|
497
524
|
"help.info.e0": {
|
|
498
525
|
"ru": "Все метаданные, токены, todo, ветвление",
|
|
499
526
|
"en": "Metadata, tokens, todos, branching",
|
|
@@ -544,6 +571,8 @@ L: dict[str, dict[str, str]] = {
|
|
|
544
571
|
"help.tree.e2": {"ru": "Глубина 3 уровня", "en": "Max depth of 3"},
|
|
545
572
|
"help.projects.e0": {"ru": "Проекты со статистикой", "en": "Projects with statistics"},
|
|
546
573
|
"help.projects.e1": {"ru": "Вывод в JSON", "en": "JSON output"},
|
|
574
|
+
"help.projects.e2": {"ru": "Скрыть Global-проект", "en": "Hide the Global project"},
|
|
575
|
+
"help.projects.e3": {"ru": "Только проекты с отсутствующим путём", "en": "Only projects with a missing path"},
|
|
547
576
|
"help.stats.e0": {
|
|
548
577
|
"ru": "Сессии, сообщения, токены, стоимость, топ моделей",
|
|
549
578
|
"en": "Sessions, messages, tokens, costs, top models",
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import os
|
|
5
6
|
import sqlite3
|
|
6
7
|
from typing import Any
|
|
7
8
|
|
|
@@ -210,6 +211,129 @@ class BaseStore:
|
|
|
210
211
|
).fetchall()
|
|
211
212
|
return [dict(row) for row in rows]
|
|
212
213
|
|
|
214
|
+
def get_directories(self, check_exists: bool = True) -> list[dict[str, Any]]:
|
|
215
|
+
"""Aggregate directories from sessions, projects and project mappings."""
|
|
216
|
+
entries: dict[str, dict[str, Any]] = {}
|
|
217
|
+
|
|
218
|
+
def ensure(path: str, project_id: str | None, source: str) -> dict[str, Any]:
|
|
219
|
+
normalized = os.path.normpath(path)
|
|
220
|
+
entry = entries.setdefault(
|
|
221
|
+
normalized,
|
|
222
|
+
{
|
|
223
|
+
"path": normalized,
|
|
224
|
+
"sessions": 0,
|
|
225
|
+
"first_ts": None,
|
|
226
|
+
"last_ts": None,
|
|
227
|
+
"tokens_input": 0,
|
|
228
|
+
"tokens_output": 0,
|
|
229
|
+
"tokens_reasoning": 0,
|
|
230
|
+
"tokens_cache_read": 0,
|
|
231
|
+
"tokens_cache_write": 0,
|
|
232
|
+
"cost": 0.0,
|
|
233
|
+
"project_ids": set(),
|
|
234
|
+
"session_projects": {},
|
|
235
|
+
"project_sources": set(),
|
|
236
|
+
"sources": set(),
|
|
237
|
+
},
|
|
238
|
+
)
|
|
239
|
+
entry["sources"].add(source)
|
|
240
|
+
if project_id:
|
|
241
|
+
entry["project_ids"].add(project_id)
|
|
242
|
+
if source == "sessions":
|
|
243
|
+
entry["session_projects"][project_id] = (
|
|
244
|
+
entry["session_projects"].get(project_id, 0)
|
|
245
|
+
)
|
|
246
|
+
else:
|
|
247
|
+
entry["project_sources"].add(project_id)
|
|
248
|
+
return entry
|
|
249
|
+
|
|
250
|
+
session_rows = self.conn.execute(
|
|
251
|
+
f"""
|
|
252
|
+
SELECT directory, project_id,
|
|
253
|
+
COUNT(*) AS sessions,
|
|
254
|
+
MIN(time_created) AS first_ts,
|
|
255
|
+
MAX(time_created) AS last_ts,
|
|
256
|
+
COALESCE(SUM(tokens_input), 0) AS tokens_input,
|
|
257
|
+
COALESCE(SUM(tokens_output), 0) AS tokens_output,
|
|
258
|
+
COALESCE(SUM(tokens_reasoning), 0) AS tokens_reasoning,
|
|
259
|
+
COALESCE(SUM(tokens_cache_read), 0) AS tokens_cache_read,
|
|
260
|
+
COALESCE(SUM(tokens_cache_write), 0) AS tokens_cache_write,
|
|
261
|
+
COALESCE(SUM(cost), 0) AS cost
|
|
262
|
+
FROM {self.session_table}
|
|
263
|
+
WHERE directory IS NOT NULL AND directory != ''
|
|
264
|
+
GROUP BY directory, project_id
|
|
265
|
+
"""
|
|
266
|
+
).fetchall()
|
|
267
|
+
for row in session_rows:
|
|
268
|
+
entry = ensure(row["directory"], row["project_id"], "sessions")
|
|
269
|
+
entry["sessions"] += int(row["sessions"])
|
|
270
|
+
entry["session_projects"][row["project_id"]] = int(row["sessions"])
|
|
271
|
+
for key in ("tokens_input", "tokens_output", "tokens_reasoning",
|
|
272
|
+
"tokens_cache_read", "tokens_cache_write"):
|
|
273
|
+
entry[key] += int(row[key] or 0)
|
|
274
|
+
entry["cost"] += float(row["cost"] or 0)
|
|
275
|
+
first_ts = row["first_ts"]
|
|
276
|
+
last_ts = row["last_ts"]
|
|
277
|
+
if first_ts is not None:
|
|
278
|
+
entry["first_ts"] = first_ts if entry["first_ts"] is None else min(entry["first_ts"], first_ts)
|
|
279
|
+
if last_ts is not None:
|
|
280
|
+
entry["last_ts"] = last_ts if entry["last_ts"] is None else max(entry["last_ts"], last_ts)
|
|
281
|
+
|
|
282
|
+
project_rows = self.conn.execute(
|
|
283
|
+
"SELECT id, name, worktree FROM project WHERE worktree IS NOT NULL AND worktree != ''"
|
|
284
|
+
).fetchall()
|
|
285
|
+
project_meta = {row["id"]: dict(row) for row in project_rows}
|
|
286
|
+
for row in project_rows:
|
|
287
|
+
if row["id"] == "global" and os.path.normpath(row["worktree"]) == "/":
|
|
288
|
+
continue
|
|
289
|
+
ensure(row["worktree"], row["id"], "project")
|
|
290
|
+
|
|
291
|
+
if "project_directory" in self.schema.tables:
|
|
292
|
+
directory_rows = self.conn.execute(
|
|
293
|
+
"""
|
|
294
|
+
SELECT project_id, directory
|
|
295
|
+
FROM project_directory
|
|
296
|
+
WHERE directory IS NOT NULL AND directory != ''
|
|
297
|
+
"""
|
|
298
|
+
).fetchall()
|
|
299
|
+
for row in directory_rows:
|
|
300
|
+
ensure(row["directory"], row["project_id"], "project_directory")
|
|
301
|
+
|
|
302
|
+
for entry in entries.values():
|
|
303
|
+
candidates = [
|
|
304
|
+
project_id
|
|
305
|
+
for project_id in entry["project_ids"]
|
|
306
|
+
if entry["session_projects"].get(project_id, 0) > 0
|
|
307
|
+
]
|
|
308
|
+
if not candidates:
|
|
309
|
+
candidates = list(entry["project_ids"])
|
|
310
|
+
non_global = [project_id for project_id in candidates if project_id != "global"]
|
|
311
|
+
pool = non_global or candidates
|
|
312
|
+
chosen = max(
|
|
313
|
+
pool,
|
|
314
|
+
key=lambda project_id: entry["session_projects"].get(project_id, 0),
|
|
315
|
+
) if pool else None
|
|
316
|
+
|
|
317
|
+
entry["project_id"] = chosen
|
|
318
|
+
entry["project_ids"] = sorted(entry["project_ids"])
|
|
319
|
+
entry["is_global"] = chosen == "global"
|
|
320
|
+
entry["sources"] = sorted(entry["sources"])
|
|
321
|
+
if chosen == "global":
|
|
322
|
+
entry["project_label"] = _("directories.global")
|
|
323
|
+
elif chosen:
|
|
324
|
+
meta = project_meta.get(chosen, {})
|
|
325
|
+
label = meta.get("name")
|
|
326
|
+
if not label:
|
|
327
|
+
worktree = meta.get("worktree") or ""
|
|
328
|
+
label = os.path.basename(str(worktree).rstrip("/")) or chosen[:12]
|
|
329
|
+
entry["project_label"] = str(label)
|
|
330
|
+
else:
|
|
331
|
+
entry["project_label"] = "—"
|
|
332
|
+
|
|
333
|
+
entry["exists"] = os.path.isdir(entry["path"]) if check_exists else None
|
|
334
|
+
|
|
335
|
+
return list(entries.values())
|
|
336
|
+
|
|
213
337
|
def get_cost_total(self, project_id: str | None = None) -> dict[str, Any]:
|
|
214
338
|
where = "WHERE s.project_id = ?" if project_id else ""
|
|
215
339
|
params: list[Any] = [project_id] if project_id else []
|
|
@@ -136,7 +136,7 @@ class V1Store(BaseStore):
|
|
|
136
136
|
order = sort_map.get(sort, sort_map["date"])
|
|
137
137
|
rows = self.conn.execute(
|
|
138
138
|
f"""
|
|
139
|
-
SELECT s.id, s.title, s.model, s.project_id, s.agent,
|
|
139
|
+
SELECT s.id, s.title, s.model, s.project_id, s.agent, s.directory,
|
|
140
140
|
s.cost, s.tokens_input, s.tokens_output,
|
|
141
141
|
MAX(m.time_created) AS last_time,
|
|
142
142
|
COUNT(DISTINCT m.id) AS msg_count
|
|
@@ -329,7 +329,7 @@ class V2Store(BaseStore):
|
|
|
329
329
|
order = sort_map.get(sort, sort_map["date"])
|
|
330
330
|
rows = self.conn.execute(
|
|
331
331
|
f"""
|
|
332
|
-
SELECT s.id, s.title, s.model, s.project_id, s.agent,
|
|
332
|
+
SELECT s.id, s.title, s.model, s.project_id, s.agent, s.directory,
|
|
333
333
|
s.cost, s.tokens_input, s.tokens_output,
|
|
334
334
|
MAX(m.time_created) AS last_time,
|
|
335
335
|
COUNT(DISTINCT m.id) AS msg_count
|
|
@@ -37,6 +37,14 @@ def summarize_val(v, max_len=120) -> str:
|
|
|
37
37
|
return s
|
|
38
38
|
|
|
39
39
|
|
|
40
|
+
def shorten_path(path, max_len=48) -> str:
|
|
41
|
+
"""Обрезает длинный путь слева, сохраняя конечные сегменты."""
|
|
42
|
+
s = str(path)
|
|
43
|
+
if len(s) <= max_len:
|
|
44
|
+
return s
|
|
45
|
+
return "…" + s[-(max_len - 1) :]
|
|
46
|
+
|
|
47
|
+
|
|
40
48
|
def parse_date(date_str: str) -> datetime | None:
|
|
41
49
|
"""Парсит дату в формате ГГГГ-ММ-ДД."""
|
|
42
50
|
if not date_str or not isinstance(date_str, str):
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: opencode-db
|
|
3
|
-
Version: 0.8.
|
|
3
|
+
Version: 0.8.2
|
|
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
|
|
@@ -32,6 +32,7 @@ uv tool install opencode-db
|
|
|
32
32
|
|
|
33
33
|
```bash
|
|
34
34
|
opencode-db list # recent sessions
|
|
35
|
+
opencode-db directories # launch directories, including missing ones
|
|
35
36
|
opencode-db view # interactive session viewer (colored, scrollable)
|
|
36
37
|
opencode-db stats # database summary
|
|
37
38
|
opencode-db schema # detect v1/v2 schema
|
|
@@ -44,6 +45,7 @@ opencode-db help # full reference
|
|
|
44
45
|
|
|
45
46
|
- **`view`** — просмотр сессии в терминале с ANSI-цветами, Markdown-рендерингом и прокруткой через `less`
|
|
46
47
|
- **`list`** / **`info`** / **`search`** / **`tree`** — навигация и поиск по сессиям
|
|
48
|
+
- **`directories`** (`dirs`) — все директории запусков, включая отсутствующие; `list --directory` добавляет путь к сессии
|
|
47
49
|
- **`export`** — экспорт диалога в Markdown (с поддержкой Obsidian)
|
|
48
50
|
- **`delete`** / **`prune`** — удаление и массовая очистка с фильтрами
|
|
49
51
|
- **`costs`** / **`stats`** — аналитика токенов и расходов
|
|
@@ -63,4 +65,6 @@ pip install opencode-db # zero external dependencies
|
|
|
63
65
|
- [OpenCode](https://opencode.ai) (must have been run at least once)
|
|
64
66
|
- macOS / Linux
|
|
65
67
|
|
|
68
|
+
**Directory support:** `global` is a synthetic project for non-Git directories; `directories` shows the real `session.directory` and marks it as `Global (no project root)`. Missing paths are detected with `os.path.isdir()` at command runtime and can be filtered with `--missing`.
|
|
69
|
+
|
|
66
70
|
**Schema support:** OpenCode 1.19.x (v1) — read and write; OpenCode 2.x preview (v2) — read-only. Run `opencode-db schema` to check the detected schema. On v2, `delete`, `prune`, and `vacuum` are refused until write support is implemented.
|
|
@@ -23,6 +23,7 @@ opencode_db.egg-info/top_level.txt
|
|
|
23
23
|
opencode_db/commands/__init__.py
|
|
24
24
|
opencode_db/commands/cmd_costs.py
|
|
25
25
|
opencode_db/commands/cmd_delete.py
|
|
26
|
+
opencode_db/commands/cmd_directories.py
|
|
26
27
|
opencode_db/commands/cmd_export.py
|
|
27
28
|
opencode_db/commands/cmd_help.py
|
|
28
29
|
opencode_db/commands/cmd_info.py
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "opencode-db"
|
|
7
|
-
version = "0.8.
|
|
7
|
+
version = "0.8.2"
|
|
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"
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import argparse
|
|
2
|
+
import json
|
|
2
3
|
from unittest.mock import patch
|
|
3
4
|
|
|
4
5
|
|
|
@@ -699,3 +700,87 @@ class TestViewCommand:
|
|
|
699
700
|
sty = _init_styles(raw=True)
|
|
700
701
|
text = _format_session(db, info, [], sty)
|
|
701
702
|
assert "конец сессии" in text or "end of session" in text
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
class TestDirectoriesCommand:
|
|
706
|
+
def test_directories_json_includes_project_paths(self, db, capsys) -> None:
|
|
707
|
+
from opencode_db.commands.cmd_directories import run
|
|
708
|
+
|
|
709
|
+
args = _ns(
|
|
710
|
+
missing=False,
|
|
711
|
+
existing=False,
|
|
712
|
+
project=None,
|
|
713
|
+
sort="path",
|
|
714
|
+
limit=50,
|
|
715
|
+
no_check=True,
|
|
716
|
+
json=True,
|
|
717
|
+
)
|
|
718
|
+
assert run(args, db) == 0
|
|
719
|
+
data = json.loads(capsys.readouterr().out)
|
|
720
|
+
by_path = {item["path"]: item for item in data}
|
|
721
|
+
assert by_path["/home/user/proj1"]["sessions"] == 2
|
|
722
|
+
assert by_path["/home/user/proj1"]["project"] == "My Project"
|
|
723
|
+
assert by_path["/home/user/proj2"]["sessions"] == 0
|
|
724
|
+
assert by_path["/home/user/proj2"]["project"] == "proj2"
|
|
725
|
+
|
|
726
|
+
def test_directories_missing_and_existing_conflict(self, db) -> None:
|
|
727
|
+
from opencode_db.commands.cmd_directories import run
|
|
728
|
+
|
|
729
|
+
args = _ns(missing=True, existing=True, project=None, sort="date", limit=50, no_check=True, json=True)
|
|
730
|
+
assert run(args, db) == 1
|
|
731
|
+
|
|
732
|
+
def test_list_directory_json(self, db, capsys) -> None:
|
|
733
|
+
from opencode_db.commands.cmd_list import run
|
|
734
|
+
|
|
735
|
+
args = _ns(
|
|
736
|
+
limit=10,
|
|
737
|
+
offset=0,
|
|
738
|
+
project=None,
|
|
739
|
+
agent=None,
|
|
740
|
+
model=None,
|
|
741
|
+
sort="date",
|
|
742
|
+
json=True,
|
|
743
|
+
all=False,
|
|
744
|
+
directory=True,
|
|
745
|
+
)
|
|
746
|
+
assert run(args, db) == 0
|
|
747
|
+
data = json.loads(capsys.readouterr().out)
|
|
748
|
+
assert data[0]["directory"] == "/home/user/proj1"
|
|
749
|
+
|
|
750
|
+
def test_projects_show_paths_and_global(self, db, capsys) -> None:
|
|
751
|
+
from opencode_db.commands.cmd_projects import run
|
|
752
|
+
|
|
753
|
+
args = _ns(json=True, no_global=False, missing=False)
|
|
754
|
+
assert run(args, db) == 0
|
|
755
|
+
data = json.loads(capsys.readouterr().out)
|
|
756
|
+
by_id = {item["id"]: item for item in data}
|
|
757
|
+
assert by_id["proj_002"]["name"] == "proj2"
|
|
758
|
+
assert by_id["proj_002"]["path"] == "/home/user/proj2"
|
|
759
|
+
assert by_id["proj_002"]["exists"] is False
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def test_dirs_alias_is_registered(self, db) -> None:
|
|
763
|
+
from opencode_db.cli import build_parser
|
|
764
|
+
from opencode_db.commands import get_handler
|
|
765
|
+
|
|
766
|
+
parser = build_parser()
|
|
767
|
+
args = parser.parse_args(["dirs", "--json"])
|
|
768
|
+
assert args.command == "dirs"
|
|
769
|
+
assert get_handler("dirs") is get_handler("directories")
|
|
770
|
+
|
|
771
|
+
def test_list_directory_table_handles_null(self, db, capsys) -> None:
|
|
772
|
+
from opencode_db.commands.cmd_list import run
|
|
773
|
+
|
|
774
|
+
args = _ns(
|
|
775
|
+
limit=10,
|
|
776
|
+
offset=0,
|
|
777
|
+
project=None,
|
|
778
|
+
agent=None,
|
|
779
|
+
model=None,
|
|
780
|
+
sort="date",
|
|
781
|
+
json=False,
|
|
782
|
+
all=False,
|
|
783
|
+
directory=True,
|
|
784
|
+
)
|
|
785
|
+
assert run(args, db) == 0
|
|
786
|
+
assert "None" not in capsys.readouterr().out
|
|
@@ -280,3 +280,102 @@ def test_v2_delete_is_refused(v2db, capsys) -> None:
|
|
|
280
280
|
def test_unknown_message_type_is_ignored(v2db) -> None:
|
|
281
281
|
store = open_store(v2db)
|
|
282
282
|
assert len(store.get_messages("ses_v2")) == 2
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def test_v2_directories_and_global(v2db, capsys) -> None:
|
|
286
|
+
from opencode_db.commands import cmd_directories
|
|
287
|
+
|
|
288
|
+
v2db.execute(
|
|
289
|
+
"INSERT INTO project VALUES (?, ?, ?, ?, ?)",
|
|
290
|
+
("global", "/", None, TS, TS),
|
|
291
|
+
)
|
|
292
|
+
v2db.execute(
|
|
293
|
+
"INSERT INTO session_v2 (id, directory, project_id, time_created, time_updated) VALUES (?, ?, ?, ?, ?)",
|
|
294
|
+
("ses_global", "/tmp/v2-global", "global", TS, TS),
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
cmd_directories.run(
|
|
298
|
+
_ns(
|
|
299
|
+
missing=False,
|
|
300
|
+
existing=False,
|
|
301
|
+
project=None,
|
|
302
|
+
sort="path",
|
|
303
|
+
limit=50,
|
|
304
|
+
no_check=True,
|
|
305
|
+
json=True,
|
|
306
|
+
),
|
|
307
|
+
v2db,
|
|
308
|
+
)
|
|
309
|
+
data = json.loads(capsys.readouterr().out)
|
|
310
|
+
by_path = {item["path"]: item for item in data}
|
|
311
|
+
assert "/" not in by_path
|
|
312
|
+
assert by_path["/tmp/v2-global"]["is_global"] is True
|
|
313
|
+
from opencode_db.i18n import _
|
|
314
|
+
|
|
315
|
+
assert by_path["/tmp/v2-global"]["project"] == _("directories.global")
|
|
316
|
+
assert by_path["/tmp/v2"]["project"] == "V2 Project"
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def test_v2_directories_missing_filter(v2db, tmp_path, capsys) -> None:
|
|
320
|
+
from opencode_db.commands import cmd_directories
|
|
321
|
+
|
|
322
|
+
present = tmp_path / "present"
|
|
323
|
+
present.mkdir()
|
|
324
|
+
missing = tmp_path / "gone"
|
|
325
|
+
v2db.execute(
|
|
326
|
+
"INSERT INTO session_v2 (id, directory, project_id, time_created, time_updated) VALUES (?, ?, ?, ?, ?)",
|
|
327
|
+
("ses_present", str(present), "proj_v2", TS, TS),
|
|
328
|
+
)
|
|
329
|
+
v2db.execute(
|
|
330
|
+
"INSERT INTO session_v2 (id, directory, project_id, time_created, time_updated) VALUES (?, ?, ?, ?, ?)",
|
|
331
|
+
("ses_missing", str(missing), "proj_v2", TS, TS),
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
cmd_directories.run(
|
|
335
|
+
_ns(
|
|
336
|
+
missing=True,
|
|
337
|
+
existing=False,
|
|
338
|
+
project=None,
|
|
339
|
+
sort="path",
|
|
340
|
+
limit=50,
|
|
341
|
+
no_check=False,
|
|
342
|
+
json=True,
|
|
343
|
+
),
|
|
344
|
+
v2db,
|
|
345
|
+
)
|
|
346
|
+
data = json.loads(capsys.readouterr().out)
|
|
347
|
+
paths = {item["path"] for item in data}
|
|
348
|
+
assert str(missing) in paths
|
|
349
|
+
assert str(present) not in paths
|
|
350
|
+
assert all(item["exists"] is False for item in data)
|
|
351
|
+
|
|
352
|
+
cmd_directories.run(
|
|
353
|
+
_ns(
|
|
354
|
+
missing=False,
|
|
355
|
+
existing=True,
|
|
356
|
+
project=None,
|
|
357
|
+
sort="path",
|
|
358
|
+
limit=50,
|
|
359
|
+
no_check=False,
|
|
360
|
+
json=True,
|
|
361
|
+
),
|
|
362
|
+
v2db,
|
|
363
|
+
)
|
|
364
|
+
data = json.loads(capsys.readouterr().out)
|
|
365
|
+
assert str(present) in {item["path"] for item in data}
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def test_v2_projects_global_and_paths(v2db, capsys) -> None:
|
|
369
|
+
from opencode_db.commands import cmd_projects
|
|
370
|
+
|
|
371
|
+
v2db.execute(
|
|
372
|
+
"INSERT INTO project VALUES (?, ?, ?, ?, ?)",
|
|
373
|
+
("global", "/", None, TS, TS),
|
|
374
|
+
)
|
|
375
|
+
cmd_projects.run(_ns(json=True, no_global=False, missing=False), v2db)
|
|
376
|
+
data = json.loads(capsys.readouterr().out)
|
|
377
|
+
by_id = {item["id"]: item for item in data}
|
|
378
|
+
assert by_id["global"]["is_global"] is True
|
|
379
|
+
assert by_id["global"]["path"] is None
|
|
380
|
+
assert by_id["proj_v2"]["path"] == "/tmp/v2"
|
|
381
|
+
assert by_id["proj_v2"]["name"] == "V2 Project"
|
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
"""Команда projects: список проектов со статистикой."""
|
|
2
|
-
|
|
3
|
-
import argparse
|
|
4
|
-
from typing import Literal
|
|
5
|
-
|
|
6
|
-
from ..i18n import _
|
|
7
|
-
from ..store import open_store
|
|
8
|
-
from ..utils import build_help_epilog, format_cost, format_tokens, format_ts
|
|
9
|
-
|
|
10
|
-
_PROJECTS_EXAMPLES = [
|
|
11
|
-
("", "help.projects.e0"),
|
|
12
|
-
("--json", "help.projects.e1"),
|
|
13
|
-
]
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
def register(subparsers) -> None:
|
|
17
|
-
p = subparsers.add_parser(
|
|
18
|
-
"projects",
|
|
19
|
-
help=_("help.cmd.projects"),
|
|
20
|
-
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
21
|
-
epilog=build_help_epilog("projects", _PROJECTS_EXAMPLES),
|
|
22
|
-
)
|
|
23
|
-
p.add_argument("--json", action="store_true", help="JSON output")
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
def run(args, db) -> Literal[0]:
|
|
27
|
-
rows = open_store(db).get_projects()
|
|
28
|
-
|
|
29
|
-
if args.json:
|
|
30
|
-
from ..formatters import print_json
|
|
31
|
-
|
|
32
|
-
data = []
|
|
33
|
-
for r in rows:
|
|
34
|
-
data.append(
|
|
35
|
-
{
|
|
36
|
-
"id": r["id"],
|
|
37
|
-
"name": r["name"],
|
|
38
|
-
"worktree": r["worktree"],
|
|
39
|
-
"time_created": format_ts(r["time_created"]) if r["time_created"] else None,
|
|
40
|
-
"time_updated": format_ts(r["time_updated"]) if r["time_updated"] else None,
|
|
41
|
-
"sessions": r["session_count"],
|
|
42
|
-
"cost": r["total_cost"],
|
|
43
|
-
"tokens_input": r["total_ti"],
|
|
44
|
-
"tokens_output": r["total_to"],
|
|
45
|
-
"tokens_reasoning": r["total_tr"],
|
|
46
|
-
"tokens_total": r["total_ti"] + r["total_to"] + r["total_tr"],
|
|
47
|
-
}
|
|
48
|
-
)
|
|
49
|
-
print_json(data)
|
|
50
|
-
return 0
|
|
51
|
-
|
|
52
|
-
from ..formatters import print_table_simple
|
|
53
|
-
|
|
54
|
-
table = []
|
|
55
|
-
for r in rows:
|
|
56
|
-
name = r["name"] or "—"
|
|
57
|
-
updated = format_ts(r["time_updated"], "%Y-%m-%d") if r["time_updated"] else "—"
|
|
58
|
-
total_tokens = r["total_ti"] + r["total_to"] + r["total_tr"]
|
|
59
|
-
|
|
60
|
-
table.append(
|
|
61
|
-
[
|
|
62
|
-
r["id"][:12],
|
|
63
|
-
name[:20],
|
|
64
|
-
updated,
|
|
65
|
-
str(r["session_count"]),
|
|
66
|
-
format_tokens(total_tokens),
|
|
67
|
-
format_cost(r["total_cost"]),
|
|
68
|
-
]
|
|
69
|
-
)
|
|
70
|
-
|
|
71
|
-
print_table_simple(
|
|
72
|
-
[
|
|
73
|
-
_("projects.header.id"),
|
|
74
|
-
_("projects.header.name"),
|
|
75
|
-
_("projects.header.activity"),
|
|
76
|
-
_("projects.header.sessions"),
|
|
77
|
-
_("projects.header.tokens"),
|
|
78
|
-
_("projects.header.cost"),
|
|
79
|
-
],
|
|
80
|
-
table,
|
|
81
|
-
)
|
|
82
|
-
|
|
83
|
-
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
|
|
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
|