opencode-db 0.6.2__tar.gz → 0.7.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.7.2/PKG-INFO +62 -0
- {opencode_db-0.6.2 → opencode_db-0.7.2}/README.md +87 -14
- opencode_db-0.7.2/README_PYPI.md +46 -0
- {opencode_db-0.6.2 → opencode_db-0.7.2}/cli.py +9 -3
- opencode_db-0.7.2/cmd_view.py +363 -0
- {opencode_db-0.6.2 → opencode_db-0.7.2}/commands/__init__.py +2 -0
- {opencode_db-0.6.2 → opencode_db-0.7.2}/formatters.py +44 -11
- {opencode_db-0.6.2 → opencode_db-0.7.2}/i18n.py +80 -0
- opencode_db-0.7.2/markdown_to_ansi.py +221 -0
- opencode_db-0.7.2/opencode_db.egg-info/PKG-INFO +62 -0
- {opencode_db-0.6.2 → opencode_db-0.7.2}/opencode_db.egg-info/SOURCES.txt +5 -0
- opencode_db-0.7.2/opencode_db.egg-info/requires.txt +6 -0
- {opencode_db-0.6.2 → opencode_db-0.7.2}/opencode_db.egg-info/top_level.txt +3 -0
- {opencode_db-0.6.2 → opencode_db-0.7.2}/pyproject.toml +7 -2
- opencode_db-0.7.2/runtime.py +55 -0
- {opencode_db-0.6.2 → opencode_db-0.7.2}/tests/test_commands.py +192 -0
- opencode_db-0.7.2/tests/test_runtime.py +21 -0
- {opencode_db-0.6.2 → opencode_db-0.7.2}/utils.py +1 -1
- opencode_db-0.6.2/PKG-INFO +0 -38
- opencode_db-0.6.2/README_PYPI.md +0 -26
- opencode_db-0.6.2/opencode_db.egg-info/PKG-INFO +0 -38
- {opencode_db-0.6.2 → opencode_db-0.7.2}/LICENSE +0 -0
- {opencode_db-0.6.2 → opencode_db-0.7.2}/cmd_costs.py +0 -0
- {opencode_db-0.6.2 → opencode_db-0.7.2}/cmd_delete.py +0 -0
- {opencode_db-0.6.2 → opencode_db-0.7.2}/cmd_export.py +0 -0
- {opencode_db-0.6.2 → opencode_db-0.7.2}/cmd_help.py +0 -0
- {opencode_db-0.6.2 → opencode_db-0.7.2}/cmd_info.py +0 -0
- {opencode_db-0.6.2 → opencode_db-0.7.2}/cmd_list.py +0 -0
- {opencode_db-0.6.2 → opencode_db-0.7.2}/cmd_projects.py +0 -0
- {opencode_db-0.6.2 → opencode_db-0.7.2}/cmd_prune.py +0 -0
- {opencode_db-0.6.2 → opencode_db-0.7.2}/cmd_search.py +0 -0
- {opencode_db-0.6.2 → opencode_db-0.7.2}/cmd_stats.py +0 -0
- {opencode_db-0.6.2 → opencode_db-0.7.2}/cmd_todos.py +0 -0
- {opencode_db-0.6.2 → opencode_db-0.7.2}/cmd_tree.py +0 -0
- {opencode_db-0.6.2 → opencode_db-0.7.2}/cmd_vacuum.py +0 -0
- {opencode_db-0.6.2 → opencode_db-0.7.2}/config.py +0 -0
- {opencode_db-0.6.2 → opencode_db-0.7.2}/db.py +0 -0
- {opencode_db-0.6.2 → opencode_db-0.7.2}/opencode_db.egg-info/dependency_links.txt +0 -0
- {opencode_db-0.6.2 → opencode_db-0.7.2}/opencode_db.egg-info/entry_points.txt +0 -0
- {opencode_db-0.6.2 → opencode_db-0.7.2}/setup.cfg +0 -0
- {opencode_db-0.6.2 → opencode_db-0.7.2}/tests/test_db.py +0 -0
- {opencode_db-0.6.2 → opencode_db-0.7.2}/tests/test_formatters.py +0 -0
- {opencode_db-0.6.2 → opencode_db-0.7.2}/tests/test_i18n.py +0 -0
- {opencode_db-0.6.2 → opencode_db-0.7.2}/tests/test_utils.py +0 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: opencode-db
|
|
3
|
+
Version: 0.7.2
|
|
4
|
+
Summary: OpenCode database CLI manager — browse, export, analyze, clean up sessions
|
|
5
|
+
Project-URL: Homepage, https://github.com/VasilevNStas/opencode-db
|
|
6
|
+
Project-URL: Source, https://github.com/VasilevNStas/opencode-db
|
|
7
|
+
Project-URL: Tracker, https://github.com/VasilevNStas/opencode-db/issues
|
|
8
|
+
Requires-Python: >=3.12
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Provides-Extra: rich
|
|
12
|
+
Requires-Dist: rich>=13.0; extra == "rich"
|
|
13
|
+
Provides-Extra: all
|
|
14
|
+
Requires-Dist: opencode-db[rich]; extra == "all"
|
|
15
|
+
Dynamic: license-file
|
|
16
|
+
|
|
17
|
+
# opencode-db
|
|
18
|
+
|
|
19
|
+
[](https://www.python.org/downloads/)
|
|
20
|
+
[](LICENSE)
|
|
21
|
+
|
|
22
|
+
**CLI for OpenCode's local database.** OpenCode saves every agent session, conversation, token usage, and cost in a local SQLite file. This tool lets you inspect, export, analyze, and clean up that data.
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install opencode-db
|
|
26
|
+
|
|
27
|
+
# recommended if your system python3 is older than 3.12:
|
|
28
|
+
uv tool install opencode-db
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Quick start
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
opencode-db list # recent sessions
|
|
35
|
+
opencode-db view # interactive session viewer (colored, scrollable)
|
|
36
|
+
opencode-db stats # database summary
|
|
37
|
+
opencode-db costs --total # total token costs
|
|
38
|
+
opencode-db export # export dialog to .md (interactive)
|
|
39
|
+
opencode-db help # full reference
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Features
|
|
43
|
+
|
|
44
|
+
- **`view`** — просмотр сессии в терминале с ANSI-цветами, Markdown-рендерингом и прокруткой через `less`
|
|
45
|
+
- **`list`** / **`info`** / **`search`** / **`tree`** — навигация и поиск по сессиям
|
|
46
|
+
- **`export`** — экспорт диалога в Markdown (с поддержкой Obsidian)
|
|
47
|
+
- **`delete`** / **`prune`** — удаление и массовая очистка с фильтрами
|
|
48
|
+
- **`costs`** / **`stats`** — аналитика токенов и расходов
|
|
49
|
+
- **`--db-path`** / **`OPENCODE_DB`** — кастомный путь к БД
|
|
50
|
+
|
|
51
|
+
## Optional extras
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pip install opencode-db[rich] # enhanced Markdown rendering (recommended)
|
|
55
|
+
pip install opencode-db # zero external dependencies
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Requirements
|
|
59
|
+
|
|
60
|
+
- Python 3.12+ — the CLI checks the interpreter at startup and exits with a clear message instead of an `ImportError` on older versions
|
|
61
|
+
- [OpenCode](https://opencode.ai) (must have been run at least once)
|
|
62
|
+
- macOS / Linux
|
|
@@ -22,9 +22,10 @@ opencode-db costs --total # total token costs
|
|
|
22
22
|
|
|
23
23
|
### Requirements
|
|
24
24
|
|
|
25
|
-
- Python 3.12+
|
|
25
|
+
- Python 3.12+ — `opencode-db` refuses to start on older interpreters with a clear message (no cryptic `ImportError`)
|
|
26
26
|
- OpenCode (must have been run at least once — the database is created automatically)
|
|
27
27
|
- macOS / Linux
|
|
28
|
+
- [`uv`](https://docs.astral.sh/uv/) — optional, but recommended for installing and running on systems whose default `python3` is older than 3.12
|
|
28
29
|
|
|
29
30
|
### Database location
|
|
30
31
|
|
|
@@ -51,13 +52,26 @@ opencode-db --db-path /path/to/opencode.db stats
|
|
|
51
52
|
|
|
52
53
|
The `--db-path` flag takes precedence over the environment variable.
|
|
53
54
|
|
|
54
|
-
###
|
|
55
|
+
### Recommended: uv
|
|
56
|
+
|
|
57
|
+
If your system `python3` is older than 3.12 (for example, macOS ships 3.9), use `uv` — it installs the tool into an isolated environment with a supported interpreter:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
uv tool install opencode-db
|
|
61
|
+
# with enhanced Markdown rendering:
|
|
62
|
+
uv tool install "opencode-db[rich]"
|
|
63
|
+
|
|
64
|
+
# one-off run without installing:
|
|
65
|
+
uvx opencode-db stats
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### One-command install
|
|
55
69
|
|
|
56
70
|
```bash
|
|
57
71
|
curl -fsSL https://raw.githubusercontent.com/VasilevNStas/opencode-db/master/install.sh | sh
|
|
58
72
|
```
|
|
59
73
|
|
|
60
|
-
Downloads everything to `~/.local/share/opencode-db/` and creates `~/.local/bin/opencode-db`.
|
|
74
|
+
Downloads everything to `~/.local/share/opencode-db/` and creates `~/.local/bin/opencode-db`. The installer looks for Python 3.12+ (including interpreters managed by `uv` or `pyenv`) and writes the discovered path into the launcher shebang.
|
|
61
75
|
|
|
62
76
|
### pip / pipx install
|
|
63
77
|
|
|
@@ -67,7 +81,7 @@ pipx install opencode-db
|
|
|
67
81
|
pip install opencode-db
|
|
68
82
|
```
|
|
69
83
|
|
|
70
|
-
[Zero external dependencies](https://treyhunner.com/2015/12/python-imports-and-path-manipulation/) — pure Python standard library only.
|
|
84
|
+
[Zero external dependencies](https://treyhunner.com/2015/12/python-imports-and-path-manipulation/) — pure Python standard library only (`rich` is optional).
|
|
71
85
|
|
|
72
86
|
### Manual install
|
|
73
87
|
|
|
@@ -82,7 +96,17 @@ git clone https://github.com/VasilevNStas/opencode-db.git ~/.local/share/opencod
|
|
|
82
96
|
```bash
|
|
83
97
|
cat > ~/.local/bin/opencode-db << 'SCRIPT'
|
|
84
98
|
#!/usr/bin/env python3
|
|
85
|
-
import
|
|
99
|
+
import os
|
|
100
|
+
import sys
|
|
101
|
+
|
|
102
|
+
if sys.version_info < (3, 12):
|
|
103
|
+
print(
|
|
104
|
+
"opencode-db requires Python 3.12+; current: %s (%s)"
|
|
105
|
+
% (".".join(map(str, sys.version_info[:3])), sys.executable),
|
|
106
|
+
file=sys.stderr,
|
|
107
|
+
)
|
|
108
|
+
raise SystemExit(1)
|
|
109
|
+
|
|
86
110
|
PKG_DIR = os.path.expanduser("~/.local/share/opencode-db")
|
|
87
111
|
if PKG_DIR not in sys.path:
|
|
88
112
|
sys.path.insert(0, PKG_DIR)
|
|
@@ -488,14 +512,16 @@ Example output:
|
|
|
488
512
|
~/.local/share/opencode-db/ ← all source code
|
|
489
513
|
│
|
|
490
514
|
├── __init__.py ← executable entry point
|
|
491
|
-
├── __main__.py ←
|
|
515
|
+
├── __main__.py ← python3 . (directory entry point)
|
|
492
516
|
├── cli.py ← argparse + command dispatcher
|
|
493
517
|
│
|
|
494
518
|
├── config.py ← constants and paths
|
|
519
|
+
├── runtime.py ← Python 3.12+ version guard
|
|
495
520
|
├── db.py ← SQLite queries
|
|
496
521
|
├── utils.py ← date/token formatting, validation
|
|
497
522
|
├── formatters.py ← tables, JSON, Markdown, snapshots
|
|
498
|
-
├── i18n.py ← localization (EN/RU,
|
|
523
|
+
├── i18n.py ← localization (EN/RU, 280 keys)
|
|
524
|
+
├── markdown_to_ansi.py ← Markdown → ANSI renderer for `view`
|
|
499
525
|
│
|
|
500
526
|
├── commands/
|
|
501
527
|
│ └── __init__.py ← command registry
|
|
@@ -512,6 +538,7 @@ Example output:
|
|
|
512
538
|
├── cmd_stats.py ← opencode-db stats
|
|
513
539
|
├── cmd_todos.py ← opencode-db todos
|
|
514
540
|
├── cmd_vacuum.py ← opencode-db vacuum
|
|
541
|
+
├── cmd_view.py ← opencode-db view
|
|
515
542
|
└── cmd_help.py ← opencode-db help
|
|
516
543
|
|
|
517
544
|
README.md ← this file (English)
|
|
@@ -522,10 +549,11 @@ opencode-db-commands.json ← OpenCode TUI custom commands confi
|
|
|
522
549
|
### How it works
|
|
523
550
|
|
|
524
551
|
1. The launcher script at `~/.local/bin/opencode-db` (a shebang Python script) adds `~/.local/share/opencode-db/` to `sys.path` and calls `cli.main()`.
|
|
525
|
-
2. `
|
|
526
|
-
3.
|
|
527
|
-
4. `
|
|
528
|
-
5.
|
|
552
|
+
2. `runtime.require_python()` checks the interpreter before importing `cli`/`db`; on Python < 3.12 it prints a clear message with remediation steps instead of failing with `ImportError`.
|
|
553
|
+
3. `cli.py` builds an `argparse` parser with subparsers from every command module and dispatches the call.
|
|
554
|
+
4. Each command is a separate `cmd_*.py` module exporting two functions: `register(subparsers)` and `run(args, db)`.
|
|
555
|
+
5. `db.py` opens a connection to OpenCode's SQLite database (`~/.local/share/opencode/opencode.db`).
|
|
556
|
+
6. Destructive operations (`delete`, `prune`) always require confirmation (or `--force`).
|
|
529
557
|
|
|
530
558
|
### Database schema (key tables)
|
|
531
559
|
|
|
@@ -617,7 +645,7 @@ opencode-db --lang en help # English help text
|
|
|
617
645
|
opencode-db --lang en stats # English statistics
|
|
618
646
|
```
|
|
619
647
|
|
|
620
|
-
The `--lang` flag applies to any command. All
|
|
648
|
+
The `--lang` flag applies to any command. All 280 UI strings are translated. Data content (session titles, project names, etc.) remains in its original language.
|
|
621
649
|
|
|
622
650
|
The language can also be set via environment variable `OPENCODE_DB_LANG=en`.
|
|
623
651
|
|
|
@@ -659,10 +687,54 @@ Use a longer prefix or the full session ID (use `list --json` to find it).
|
|
|
659
687
|
|
|
660
688
|
If the database is being used by another process (e.g., OpenCode is running), VACUUM may fail. Close OpenCode and try again.
|
|
661
689
|
|
|
690
|
+
### `ImportError: cannot import name 'UTC' from 'datetime'`
|
|
691
|
+
|
|
692
|
+
This means `opencode-db` is running on Python older than 3.12 (for example, the system Python 3.9). Check:
|
|
693
|
+
|
|
694
|
+
```bash
|
|
695
|
+
python3 --version
|
|
696
|
+
```
|
|
697
|
+
|
|
698
|
+
Fix with `uv` (recommended):
|
|
699
|
+
|
|
700
|
+
```bash
|
|
701
|
+
uv python install 3.13
|
|
702
|
+
uv tool install opencode-db
|
|
703
|
+
opencode-db stats
|
|
704
|
+
```
|
|
705
|
+
|
|
706
|
+
Or run the repository checkout with an explicit interpreter:
|
|
707
|
+
|
|
708
|
+
```bash
|
|
709
|
+
uvx --from . opencode-db stats
|
|
710
|
+
# alternative for a persistent dev environment:
|
|
711
|
+
uv run --python 3.13 opencode-db stats
|
|
712
|
+
```
|
|
713
|
+
|
|
714
|
+
After changing the interpreter, rerun `install.sh` to regenerate the `~/.local/bin/opencode-db` launcher.
|
|
715
|
+
|
|
662
716
|
---
|
|
663
717
|
|
|
664
718
|
## Development
|
|
665
719
|
|
|
720
|
+
### Local development
|
|
721
|
+
|
|
722
|
+
Requires Python 3.12+ and `pytest`/`ruff`:
|
|
723
|
+
|
|
724
|
+
```bash
|
|
725
|
+
git clone https://github.com/VasilevNStas/opencode-db.git
|
|
726
|
+
cd opencode-db
|
|
727
|
+
|
|
728
|
+
uv venv --python 3.13
|
|
729
|
+
source .venv/bin/activate
|
|
730
|
+
uv pip install -e ".[rich]"
|
|
731
|
+
uv pip install pytest ruff
|
|
732
|
+
|
|
733
|
+
make check # check-python + ruff + 191 tests
|
|
734
|
+
```
|
|
735
|
+
|
|
736
|
+
`make check` fails fast with a clear message if the active interpreter is older than 3.12.
|
|
737
|
+
|
|
666
738
|
### Adding a new command
|
|
667
739
|
|
|
668
740
|
1. Create `cmd_xxx.py`:
|
|
@@ -692,10 +764,11 @@ The command will appear in `--help` automatically.
|
|
|
692
764
|
python3 ~/.local/share/opencode-db list
|
|
693
765
|
```
|
|
694
766
|
|
|
695
|
-
Or
|
|
767
|
+
Or from the repository root (Python 3.12+):
|
|
696
768
|
|
|
697
769
|
```bash
|
|
698
|
-
python3
|
|
770
|
+
python3 . list
|
|
771
|
+
uvx --from . opencode-db list
|
|
699
772
|
```
|
|
700
773
|
|
|
701
774
|
---
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# opencode-db
|
|
2
|
+
|
|
3
|
+
[](https://www.python.org/downloads/)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
|
|
6
|
+
**CLI for OpenCode's local database.** OpenCode saves every agent session, conversation, token usage, and cost in a local SQLite file. This tool lets you inspect, export, analyze, and clean up that data.
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pip install opencode-db
|
|
10
|
+
|
|
11
|
+
# recommended if your system python3 is older than 3.12:
|
|
12
|
+
uv tool install opencode-db
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Quick start
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
opencode-db list # recent sessions
|
|
19
|
+
opencode-db view # interactive session viewer (colored, scrollable)
|
|
20
|
+
opencode-db stats # database summary
|
|
21
|
+
opencode-db costs --total # total token costs
|
|
22
|
+
opencode-db export # export dialog to .md (interactive)
|
|
23
|
+
opencode-db help # full reference
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Features
|
|
27
|
+
|
|
28
|
+
- **`view`** — просмотр сессии в терминале с ANSI-цветами, Markdown-рендерингом и прокруткой через `less`
|
|
29
|
+
- **`list`** / **`info`** / **`search`** / **`tree`** — навигация и поиск по сессиям
|
|
30
|
+
- **`export`** — экспорт диалога в Markdown (с поддержкой Obsidian)
|
|
31
|
+
- **`delete`** / **`prune`** — удаление и массовая очистка с фильтрами
|
|
32
|
+
- **`costs`** / **`stats`** — аналитика токенов и расходов
|
|
33
|
+
- **`--db-path`** / **`OPENCODE_DB`** — кастомный путь к БД
|
|
34
|
+
|
|
35
|
+
## Optional extras
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install opencode-db[rich] # enhanced Markdown rendering (recommended)
|
|
39
|
+
pip install opencode-db # zero external dependencies
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Requirements
|
|
43
|
+
|
|
44
|
+
- Python 3.12+ — the CLI checks the interpreter at startup and exits with a clear message instead of an `ImportError` on older versions
|
|
45
|
+
- [OpenCode](https://opencode.ai) (must have been run at least once)
|
|
46
|
+
- macOS / Linux
|
|
@@ -7,14 +7,20 @@
|
|
|
7
7
|
- Единообразный интерфейс
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
10
12
|
import argparse
|
|
11
13
|
import os
|
|
12
14
|
import sys
|
|
13
15
|
from typing import Any, Literal
|
|
14
16
|
|
|
15
|
-
import
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
from runtime import require_python
|
|
18
|
+
|
|
19
|
+
require_python()
|
|
20
|
+
|
|
21
|
+
import commands # noqa: E402
|
|
22
|
+
from db import get_db # noqa: E402
|
|
23
|
+
from i18n import _, set_lang # noqa: E402
|
|
18
24
|
|
|
19
25
|
|
|
20
26
|
def build_parser() -> argparse.ArgumentParser:
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
"""Command view: просмотр сессии в терминале с прокруткой.
|
|
2
|
+
|
|
3
|
+
Выводит отформатированный диалог с ANSI-цветами,
|
|
4
|
+
автоматически использует $PAGER (less -R) для прокрутки.
|
|
5
|
+
|
|
6
|
+
Режимы выбора сессии:
|
|
7
|
+
- view <session_id> — по ID (полный или префикс)
|
|
8
|
+
- view --latest — самая свежая сессия
|
|
9
|
+
- view (без аргументов) — интерактивный выбор из списка
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import os
|
|
14
|
+
import shutil
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
|
|
18
|
+
from db import (
|
|
19
|
+
SessionError,
|
|
20
|
+
get_latest_session,
|
|
21
|
+
get_messages,
|
|
22
|
+
get_project_name,
|
|
23
|
+
get_recent_sessions,
|
|
24
|
+
get_session_info,
|
|
25
|
+
get_session_title,
|
|
26
|
+
parse_model,
|
|
27
|
+
)
|
|
28
|
+
from i18n import _
|
|
29
|
+
from markdown_to_ansi import render as md_render
|
|
30
|
+
from utils import build_help_epilog, format_cost, format_tokens, format_ts
|
|
31
|
+
|
|
32
|
+
_VIEW_EXAMPLES = [
|
|
33
|
+
("<session_id>", "help.view.e0"),
|
|
34
|
+
("--latest", "help.view.e1"),
|
|
35
|
+
("", "help.view.e2"),
|
|
36
|
+
("--no-pager", "help.view.e3"),
|
|
37
|
+
("--raw", "help.view.e4"),
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def register(subparsers) -> None:
|
|
42
|
+
p = subparsers.add_parser(
|
|
43
|
+
"view",
|
|
44
|
+
help=_("help.cmd.view"),
|
|
45
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
46
|
+
epilog=build_help_epilog("view", _VIEW_EXAMPLES),
|
|
47
|
+
)
|
|
48
|
+
p.add_argument(
|
|
49
|
+
"session_id",
|
|
50
|
+
nargs="?",
|
|
51
|
+
help="Session ID (optional — interactive mode if omitted)",
|
|
52
|
+
)
|
|
53
|
+
p.add_argument("--latest", "-l", action="store_true", help=_("view.flag.latest"))
|
|
54
|
+
p.add_argument("--no-pager", "-P", action="store_true", help=_("view.flag.no_pager"))
|
|
55
|
+
p.add_argument("--raw", "-R", action="store_true", help=_("view.flag.raw"))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def run(args, db) -> int:
|
|
59
|
+
if args.session_id:
|
|
60
|
+
return _view_by_id(db, args.session_id, args.no_pager, args.raw)
|
|
61
|
+
if args.latest:
|
|
62
|
+
return _view_latest(db, args.no_pager, args.raw)
|
|
63
|
+
return _view_interactive(db, args.no_pager, args.raw)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ======================================================================
|
|
67
|
+
# Selection modes
|
|
68
|
+
# ======================================================================
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _view_by_id(db, session_id, no_pager, raw) -> int:
|
|
72
|
+
try:
|
|
73
|
+
info = get_session_info(db, session_id)
|
|
74
|
+
except SessionError as e:
|
|
75
|
+
print(e.message)
|
|
76
|
+
return 1
|
|
77
|
+
return _do_view(db, info, no_pager, raw)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _view_latest(db, no_pager, raw) -> int:
|
|
81
|
+
try:
|
|
82
|
+
session_id = get_latest_session(db)
|
|
83
|
+
info = get_session_info(db, session_id)
|
|
84
|
+
except SessionError as e:
|
|
85
|
+
print(e.message)
|
|
86
|
+
return 1
|
|
87
|
+
title = get_session_title(info)
|
|
88
|
+
print(_("session.latest", title=title))
|
|
89
|
+
return _do_view(db, info, no_pager, raw)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _view_interactive(db, no_pager, raw) -> int:
|
|
93
|
+
sessions = get_recent_sessions(db)
|
|
94
|
+
if not sessions:
|
|
95
|
+
print(_("view.no_sessions"))
|
|
96
|
+
return 1
|
|
97
|
+
|
|
98
|
+
print()
|
|
99
|
+
print(f" {'─' * 60}")
|
|
100
|
+
print(f" {_('view.interactive_header')}")
|
|
101
|
+
print(f" {'─' * 60}")
|
|
102
|
+
|
|
103
|
+
for i, s in enumerate(sessions, 1):
|
|
104
|
+
title = get_session_title(s)
|
|
105
|
+
created = format_ts(s["time_created"], "%Y-%m-%d %H:%M")
|
|
106
|
+
cost = format_cost(s["cost"])
|
|
107
|
+
print(f" {i:2d}. {s['id'][:24]} {created} {title[:40]:40s} {cost}")
|
|
108
|
+
|
|
109
|
+
print(f" {'─' * 60}")
|
|
110
|
+
|
|
111
|
+
while True:
|
|
112
|
+
try:
|
|
113
|
+
choice = input(" " + _("view.interactive_prompt")).strip()
|
|
114
|
+
except (EOFError, KeyboardInterrupt):
|
|
115
|
+
print()
|
|
116
|
+
return 1
|
|
117
|
+
|
|
118
|
+
if not choice:
|
|
119
|
+
print(_("view.interactive_abort"))
|
|
120
|
+
return 1
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
idx = int(choice) - 1
|
|
124
|
+
if 0 <= idx < len(sessions):
|
|
125
|
+
break
|
|
126
|
+
except ValueError:
|
|
127
|
+
pass
|
|
128
|
+
|
|
129
|
+
print(f" {_('view.interactive_error', n=len(sessions))}")
|
|
130
|
+
|
|
131
|
+
session_id = sessions[idx]["id"]
|
|
132
|
+
title = get_session_title(sessions[idx])
|
|
133
|
+
print(_("view.interactive_viewing", title=title))
|
|
134
|
+
try:
|
|
135
|
+
info = get_session_info(db, session_id)
|
|
136
|
+
except SessionError as e:
|
|
137
|
+
print(e.message)
|
|
138
|
+
return 1
|
|
139
|
+
return _do_view(db, info, no_pager, raw)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ======================================================================
|
|
143
|
+
# Display engine
|
|
144
|
+
# ======================================================================
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _init_styles(raw=False):
|
|
148
|
+
"""Return style dict. Empty dict = no ANSI codes."""
|
|
149
|
+
if raw or not sys.stdout.isatty():
|
|
150
|
+
return {}
|
|
151
|
+
return {
|
|
152
|
+
"R": "\033[0m",
|
|
153
|
+
"B": "\033[1m",
|
|
154
|
+
"D": "\033[2m",
|
|
155
|
+
"G": "\033[32m",
|
|
156
|
+
"C": "\033[36m",
|
|
157
|
+
"Y": "\033[33m",
|
|
158
|
+
"M": "\033[90m",
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _do_view(db, info, no_pager, raw) -> int:
|
|
163
|
+
sty = _init_styles(raw)
|
|
164
|
+
messages = get_messages(db, info["id"])
|
|
165
|
+
text = _format_session(db, info, messages, sty)
|
|
166
|
+
|
|
167
|
+
use_pager = not no_pager and sys.stdout.isatty() and len(text) > 1000
|
|
168
|
+
if use_pager:
|
|
169
|
+
_page_text(text)
|
|
170
|
+
else:
|
|
171
|
+
sys.stdout.write(text)
|
|
172
|
+
if not text.endswith("\n"):
|
|
173
|
+
sys.stdout.write("\n")
|
|
174
|
+
sys.stdout.flush()
|
|
175
|
+
|
|
176
|
+
return 0
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _format_session(db, info, messages, sty) -> str:
|
|
180
|
+
"""Build the full formatted session view string."""
|
|
181
|
+
title = get_session_title(info)
|
|
182
|
+
model = parse_model(info)
|
|
183
|
+
created = format_ts(info["time_created"])
|
|
184
|
+
msg_count = len(messages)
|
|
185
|
+
|
|
186
|
+
duration = "—"
|
|
187
|
+
if info["time_updated"] and info["time_created"]:
|
|
188
|
+
duration_sec = (info["time_updated"] - info["time_created"]) / 1000
|
|
189
|
+
if duration_sec < 60:
|
|
190
|
+
duration = _("info.dur_sec", n=int(duration_sec))
|
|
191
|
+
elif duration_sec < 3600:
|
|
192
|
+
duration = _("info.dur_min", n=int(duration_sec / 60))
|
|
193
|
+
else:
|
|
194
|
+
hours = int(duration_sec / 3600)
|
|
195
|
+
mins = int((duration_sec % 3600) / 60)
|
|
196
|
+
duration = _("info.dur_hours", h=hours, m=mins)
|
|
197
|
+
|
|
198
|
+
ti = info["tokens_input"]
|
|
199
|
+
to = info["tokens_output"]
|
|
200
|
+
tr = info["tokens_reasoning"]
|
|
201
|
+
tokens_str = f"{format_tokens(ti)} in · {format_tokens(to)} out"
|
|
202
|
+
if tr:
|
|
203
|
+
tokens_str += f" · {format_tokens(tr)} reason"
|
|
204
|
+
|
|
205
|
+
cost = format_cost(info["cost"])
|
|
206
|
+
|
|
207
|
+
project_name = None
|
|
208
|
+
if info["project_id"]:
|
|
209
|
+
project_name = get_project_name(db, info["project_id"])
|
|
210
|
+
|
|
211
|
+
sep = f"{sty.get('M', '')}{'─' * 60}{sty.get('R', '')}"
|
|
212
|
+
lr = sty.get("R", "")
|
|
213
|
+
|
|
214
|
+
lines = []
|
|
215
|
+
lines.append("")
|
|
216
|
+
lines.append(f"{sty.get('B', '')}{sty.get('C', '')}🔍 {_('view.header')}{lr}")
|
|
217
|
+
lines.append(sep)
|
|
218
|
+
lines.append(f" {_('info.id')}: {info['id']}")
|
|
219
|
+
lines.append(f" {_('list.header.title')}: {title}")
|
|
220
|
+
lines.append(f" {_('info.model')}: {model}")
|
|
221
|
+
lines.append(f" {_('info.agent')}: {info['agent'] or '—'}")
|
|
222
|
+
lines.append(f" {_('info.created')}: {created}")
|
|
223
|
+
lines.append(f" {_('info.duration')}: {duration}")
|
|
224
|
+
lines.append(f" {_('info.tokens_total')}: {tokens_str}")
|
|
225
|
+
lines.append(f" {_('info.cost')}: {cost}")
|
|
226
|
+
if project_name:
|
|
227
|
+
lines.append(f" {_('info.project')}: {project_name}")
|
|
228
|
+
lines.append(f" {_('info.messages')}: {msg_count}")
|
|
229
|
+
lines.append(sep)
|
|
230
|
+
lines.append("")
|
|
231
|
+
|
|
232
|
+
if not messages:
|
|
233
|
+
lines.append(f" {sty.get('M', '')}{_('view.no_messages')}{lr}")
|
|
234
|
+
lines.append("")
|
|
235
|
+
lines.append(sep)
|
|
236
|
+
lines.append("")
|
|
237
|
+
else:
|
|
238
|
+
for msg in messages:
|
|
239
|
+
role = msg.get("role", "unknown")
|
|
240
|
+
ts = format_ts(msg.get("time"), "%Y-%m-%d %H:%M:%S") if msg.get("time") else ""
|
|
241
|
+
agent = msg.get("agent", "")
|
|
242
|
+
|
|
243
|
+
if role == "user":
|
|
244
|
+
role_str = f"{sty.get('G', '')}👤 {_('view.role_user')} [{ts}]{lr}"
|
|
245
|
+
elif role == "assistant":
|
|
246
|
+
agent_tag = f" ({agent})" if agent else ""
|
|
247
|
+
role_str = f"{sty.get('C', '')}🤖 {_('view.role_assistant')}{agent_tag} [{ts}]{lr}"
|
|
248
|
+
else:
|
|
249
|
+
role_str = f"{sty.get('Y', '')}⚙️ {role} [{ts}]{lr}"
|
|
250
|
+
|
|
251
|
+
lines.append(sep)
|
|
252
|
+
lines.append(role_str)
|
|
253
|
+
lines.append(sep)
|
|
254
|
+
|
|
255
|
+
for i, part in enumerate(msg.get("parts", [])):
|
|
256
|
+
formatted = _format_part_ansi(part, sty)
|
|
257
|
+
if formatted:
|
|
258
|
+
if i > 0:
|
|
259
|
+
lines.append("")
|
|
260
|
+
lines.append(formatted)
|
|
261
|
+
|
|
262
|
+
lines.append(f"{sty.get('M', '')}─── {_('view.footer')} ───{lr}")
|
|
263
|
+
lines.append("")
|
|
264
|
+
|
|
265
|
+
return "\n".join(lines)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
# ======================================================================
|
|
269
|
+
# Part formatting (ANSI version of format_part_to_md)
|
|
270
|
+
# ======================================================================
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _format_part_ansi(part, sty) -> str:
|
|
274
|
+
"""Форматирует часть сообщения с ANSI-цветами."""
|
|
275
|
+
t = part.get("type", "")
|
|
276
|
+
|
|
277
|
+
if t == "text":
|
|
278
|
+
text = part.get("text", "").strip()
|
|
279
|
+
if not text:
|
|
280
|
+
return ""
|
|
281
|
+
return md_render(text, sty, plain=not sty)
|
|
282
|
+
|
|
283
|
+
if t == "reasoning":
|
|
284
|
+
text = part.get("text", "").strip()
|
|
285
|
+
if not text:
|
|
286
|
+
return ""
|
|
287
|
+
title = f"{sty.get('Y', '')}{sty.get('D', '')}💭 {_('md.reasoning')}:{sty.get('R', '')}"
|
|
288
|
+
body = f"{sty.get('D', '')}{text}{sty.get('R', '')}"
|
|
289
|
+
return f"{title}\n{body}"
|
|
290
|
+
|
|
291
|
+
if t == "sound":
|
|
292
|
+
return f"{sty.get('Y', '')}*🔊 {_('md.sound')}*{sty.get('R', '')}"
|
|
293
|
+
|
|
294
|
+
if t == "tool":
|
|
295
|
+
tool_name = part.get("tool", "?")
|
|
296
|
+
t_input = part.get("input", {})
|
|
297
|
+
t_output = part.get("output", "")
|
|
298
|
+
t_status = part.get("status", "")
|
|
299
|
+
|
|
300
|
+
icon = {"completed": "✅", "error": "❌", "running": "⏳"}.get(t_status, "🔧")
|
|
301
|
+
y = sty.get("Y", "")
|
|
302
|
+
r = sty.get("R", "")
|
|
303
|
+
|
|
304
|
+
if tool_name == "read":
|
|
305
|
+
fp = t_input.get("filePath", "")
|
|
306
|
+
return f"{y}{icon} read:{r} `{fp}`"
|
|
307
|
+
if tool_name == "write":
|
|
308
|
+
fp = t_input.get("filePath", "")
|
|
309
|
+
return f"{y}{icon} write:{r} `{fp}`"
|
|
310
|
+
if tool_name == "edit":
|
|
311
|
+
fp = t_input.get("filePath", "")
|
|
312
|
+
return f"{y}{icon} edit:{r} `{fp}`"
|
|
313
|
+
if tool_name == "glob":
|
|
314
|
+
pat = t_input.get("pattern", "")
|
|
315
|
+
pth = t_input.get("path", "")
|
|
316
|
+
if pth:
|
|
317
|
+
return f"{y}{icon} glob:{r} `{pat}` in `{pth}`"
|
|
318
|
+
return f"{y}{icon} glob:{r} `{pat}`"
|
|
319
|
+
if tool_name == "grep":
|
|
320
|
+
pat = t_input.get("pattern", "")
|
|
321
|
+
return f"{y}{icon} grep:{r} `{pat}`"
|
|
322
|
+
|
|
323
|
+
lines = [f"{y}{icon} {tool_name}{r}"]
|
|
324
|
+
if t_input:
|
|
325
|
+
for k, v in t_input.items():
|
|
326
|
+
s = str(v)
|
|
327
|
+
if len(s) > 200:
|
|
328
|
+
s = s[:200] + "..."
|
|
329
|
+
lines.append(f" {k}: {s}")
|
|
330
|
+
if t_output:
|
|
331
|
+
out = str(t_output).strip()
|
|
332
|
+
if out:
|
|
333
|
+
if len(out) > 300:
|
|
334
|
+
out = out[:300] + "..."
|
|
335
|
+
lines.append(f" → {out}")
|
|
336
|
+
return "\n".join(lines)
|
|
337
|
+
|
|
338
|
+
return ""
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
# ======================================================================
|
|
342
|
+
# Pager
|
|
343
|
+
# ======================================================================
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _page_text(text: str) -> None:
|
|
347
|
+
"""Pipe text through $PAGER or less -R."""
|
|
348
|
+
pager_cmd = os.environ.get("PAGER", "")
|
|
349
|
+
try:
|
|
350
|
+
if pager_cmd:
|
|
351
|
+
subprocess.run(pager_cmd, input=text, text=True, shell=True)
|
|
352
|
+
elif shutil.which("less"):
|
|
353
|
+
subprocess.run(["less", "-R"], input=text, text=True)
|
|
354
|
+
else:
|
|
355
|
+
sys.stdout.write(text)
|
|
356
|
+
if not text.endswith("\n"):
|
|
357
|
+
sys.stdout.write("\n")
|
|
358
|
+
sys.stdout.flush()
|
|
359
|
+
except OSError:
|
|
360
|
+
sys.stdout.write(text)
|
|
361
|
+
if not text.endswith("\n"):
|
|
362
|
+
sys.stdout.write("\n")
|
|
363
|
+
sys.stdout.flush()
|