opencode-db 0.4.6__tar.gz → 0.4.8__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.4.6 → opencode_db-0.4.8}/PKG-INFO +4 -1
- {opencode_db-0.4.6 → opencode_db-0.4.8}/README_PYPI.md +3 -0
- {opencode_db-0.4.6 → opencode_db-0.4.8}/cmd_costs.py +10 -8
- {opencode_db-0.4.6 → opencode_db-0.4.8}/cmd_delete.py +7 -4
- {opencode_db-0.4.6 → opencode_db-0.4.8}/cmd_export.py +22 -10
- {opencode_db-0.4.6 → opencode_db-0.4.8}/cmd_info.py +7 -5
- {opencode_db-0.4.6 → opencode_db-0.4.8}/cmd_search.py +8 -7
- {opencode_db-0.4.6 → opencode_db-0.4.8}/cmd_todos.py +7 -5
- {opencode_db-0.4.6 → opencode_db-0.4.8}/cmd_tree.py +7 -5
- {opencode_db-0.4.6 → opencode_db-0.4.8}/db.py +16 -10
- {opencode_db-0.4.6 → opencode_db-0.4.8}/opencode_db.egg-info/PKG-INFO +4 -1
- {opencode_db-0.4.6 → opencode_db-0.4.8}/opencode_db.egg-info/SOURCES.txt +1 -0
- {opencode_db-0.4.6 → opencode_db-0.4.8}/pyproject.toml +1 -1
- {opencode_db-0.4.6 → opencode_db-0.4.8}/tests/test_commands.py +89 -0
- {opencode_db-0.4.6 → opencode_db-0.4.8}/tests/test_db.py +125 -0
- opencode_db-0.4.8/tests/test_formatters.py +173 -0
- {opencode_db-0.4.6 → opencode_db-0.4.8}/LICENSE +0 -0
- {opencode_db-0.4.6 → opencode_db-0.4.8}/README.md +0 -0
- {opencode_db-0.4.6 → opencode_db-0.4.8}/cli.py +0 -0
- {opencode_db-0.4.6 → opencode_db-0.4.8}/cmd_help.py +0 -0
- {opencode_db-0.4.6 → opencode_db-0.4.8}/cmd_list.py +0 -0
- {opencode_db-0.4.6 → opencode_db-0.4.8}/cmd_projects.py +0 -0
- {opencode_db-0.4.6 → opencode_db-0.4.8}/cmd_prune.py +0 -0
- {opencode_db-0.4.6 → opencode_db-0.4.8}/cmd_stats.py +0 -0
- {opencode_db-0.4.6 → opencode_db-0.4.8}/cmd_vacuum.py +0 -0
- {opencode_db-0.4.6 → opencode_db-0.4.8}/commands/__init__.py +0 -0
- {opencode_db-0.4.6 → opencode_db-0.4.8}/config.py +0 -0
- {opencode_db-0.4.6 → opencode_db-0.4.8}/formatters.py +0 -0
- {opencode_db-0.4.6 → opencode_db-0.4.8}/i18n.py +0 -0
- {opencode_db-0.4.6 → opencode_db-0.4.8}/opencode_db.egg-info/dependency_links.txt +0 -0
- {opencode_db-0.4.6 → opencode_db-0.4.8}/opencode_db.egg-info/entry_points.txt +0 -0
- {opencode_db-0.4.6 → opencode_db-0.4.8}/opencode_db.egg-info/top_level.txt +0 -0
- {opencode_db-0.4.6 → opencode_db-0.4.8}/setup.cfg +0 -0
- {opencode_db-0.4.6 → opencode_db-0.4.8}/tests/test_i18n.py +0 -0
- {opencode_db-0.4.6 → opencode_db-0.4.8}/tests/test_utils.py +0 -0
- {opencode_db-0.4.6 → opencode_db-0.4.8}/utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: opencode-db
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.8
|
|
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
|
|
@@ -12,6 +12,9 @@ Dynamic: license-file
|
|
|
12
12
|
|
|
13
13
|
# opencode-db
|
|
14
14
|
|
|
15
|
+
[](https://www.python.org/downloads/)
|
|
16
|
+
[](LICENSE)
|
|
17
|
+
|
|
15
18
|
**OpenCode database CLI manager** — browse, export, analyze, and clean up agent sessions.
|
|
16
19
|
|
|
17
20
|
```bash
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# opencode-db
|
|
2
2
|
|
|
3
|
+
[](https://www.python.org/downloads/)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
|
|
3
6
|
**OpenCode database CLI manager** — browse, export, analyze, and clean up agent sessions.
|
|
4
7
|
|
|
5
8
|
```bash
|
|
@@ -3,9 +3,7 @@
|
|
|
3
3
|
Стоимость берётся напрямую из поля session.cost БД OpenCode.
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
|
-
from
|
|
7
|
-
|
|
8
|
-
from db import get_session_info, get_session_title, parse_model
|
|
6
|
+
from db import SessionError, get_session_info, get_session_title, parse_model
|
|
9
7
|
from i18n import _
|
|
10
8
|
from utils import format_cost, format_tokens
|
|
11
9
|
|
|
@@ -19,7 +17,7 @@ def register(subparsers) -> None:
|
|
|
19
17
|
p.add_argument("--limit", type=int, default=30, help="Max rows")
|
|
20
18
|
|
|
21
19
|
|
|
22
|
-
def run(args, db) ->
|
|
20
|
+
def run(args, db) -> int:
|
|
23
21
|
if args.session_id:
|
|
24
22
|
return _show_session(db, args.session_id, args.json)
|
|
25
23
|
|
|
@@ -29,8 +27,12 @@ def run(args, db) -> Literal[0]:
|
|
|
29
27
|
return _show_list(db, args.project, args.limit, args.json)
|
|
30
28
|
|
|
31
29
|
|
|
32
|
-
def _show_session(db, session_id, as_json) ->
|
|
33
|
-
|
|
30
|
+
def _show_session(db, session_id, as_json) -> int:
|
|
31
|
+
try:
|
|
32
|
+
info = get_session_info(db, session_id)
|
|
33
|
+
except SessionError as e:
|
|
34
|
+
print(e.message)
|
|
35
|
+
return 1
|
|
34
36
|
title = get_session_title(info)
|
|
35
37
|
model = parse_model(info)
|
|
36
38
|
|
|
@@ -73,7 +75,7 @@ def _show_session(db, session_id, as_json) -> Literal[0]:
|
|
|
73
75
|
return 0
|
|
74
76
|
|
|
75
77
|
|
|
76
|
-
def _show_total(db, project_id, as_json) ->
|
|
78
|
+
def _show_total(db, project_id, as_json) -> int:
|
|
77
79
|
where = "1=1"
|
|
78
80
|
params = []
|
|
79
81
|
|
|
@@ -126,7 +128,7 @@ def _show_total(db, project_id, as_json) -> Literal[0]:
|
|
|
126
128
|
return 0
|
|
127
129
|
|
|
128
130
|
|
|
129
|
-
def _show_list(db, project_id, limit, as_json) ->
|
|
131
|
+
def _show_list(db, project_id, limit, as_json) -> int:
|
|
130
132
|
where = "1=1"
|
|
131
133
|
params = []
|
|
132
134
|
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
"""Команда delete: удаление сессии."""
|
|
2
2
|
|
|
3
|
-
from typing import Literal
|
|
4
|
-
|
|
5
3
|
from db import (
|
|
4
|
+
SessionError,
|
|
6
5
|
get_message_count,
|
|
7
6
|
get_part_count,
|
|
8
7
|
get_session_info,
|
|
@@ -20,8 +19,12 @@ def register(subparsers) -> None:
|
|
|
20
19
|
p.add_argument("--force", "-f", action="store_true", help="Skip confirmation")
|
|
21
20
|
|
|
22
21
|
|
|
23
|
-
def run(args, db) ->
|
|
24
|
-
|
|
22
|
+
def run(args, db) -> int:
|
|
23
|
+
try:
|
|
24
|
+
info = get_session_info(db, args.session_id)
|
|
25
|
+
except SessionError as e:
|
|
26
|
+
print(e.message)
|
|
27
|
+
return 1
|
|
25
28
|
session_id = info["id"]
|
|
26
29
|
title = get_session_title(info)
|
|
27
30
|
model = parse_model(info)
|
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
9
|
import os
|
|
10
|
-
from typing import Literal
|
|
11
10
|
|
|
12
11
|
from db import (
|
|
12
|
+
SessionError,
|
|
13
13
|
get_latest_session,
|
|
14
14
|
get_messages,
|
|
15
15
|
get_recent_sessions,
|
|
@@ -47,20 +47,28 @@ def register(subparsers) -> None:
|
|
|
47
47
|
)
|
|
48
48
|
|
|
49
49
|
|
|
50
|
-
def _export_by_id(db, session_id, output_dir, force, note, full) ->
|
|
51
|
-
|
|
50
|
+
def _export_by_id(db, session_id, output_dir, force, note, full) -> int:
|
|
51
|
+
try:
|
|
52
|
+
session_info = get_session_info(db, session_id)
|
|
53
|
+
except SessionError as e:
|
|
54
|
+
print(e.message)
|
|
55
|
+
return 1
|
|
52
56
|
return _do_export(db, session_info, output_dir, force, note, full)
|
|
53
57
|
|
|
54
58
|
|
|
55
|
-
def _export_latest(db, output_dir, force, note, full) ->
|
|
56
|
-
|
|
57
|
-
|
|
59
|
+
def _export_latest(db, output_dir, force, note, full) -> int:
|
|
60
|
+
try:
|
|
61
|
+
session_id = get_latest_session(db)
|
|
62
|
+
session_info = get_session_info(db, session_id)
|
|
63
|
+
except SessionError as e:
|
|
64
|
+
print(e.message)
|
|
65
|
+
return 1
|
|
58
66
|
title = get_session_title(session_info)
|
|
59
67
|
print(_("session.latest", title=title))
|
|
60
68
|
return _do_export(db, session_info, output_dir, force, note, full)
|
|
61
69
|
|
|
62
70
|
|
|
63
|
-
def _export_interactive(db, output_dir, force, note, full) ->
|
|
71
|
+
def _export_interactive(db, output_dir, force, note, full) -> int:
|
|
64
72
|
sessions = get_recent_sessions(db)
|
|
65
73
|
|
|
66
74
|
if not sessions:
|
|
@@ -102,11 +110,15 @@ def _export_interactive(db, output_dir, force, note, full) -> Literal[1] | Liter
|
|
|
102
110
|
session_id = sessions[idx]["id"]
|
|
103
111
|
title = get_session_title(sessions[idx])
|
|
104
112
|
print(_("export.interactive_exporting", title=title))
|
|
105
|
-
|
|
113
|
+
try:
|
|
114
|
+
session_info = get_session_info(db, session_id)
|
|
115
|
+
except SessionError as e:
|
|
116
|
+
print(e.message)
|
|
117
|
+
return 1
|
|
106
118
|
return _do_export(db, session_info, output_dir, force, note, full)
|
|
107
119
|
|
|
108
120
|
|
|
109
|
-
def _do_export(db, session_info, output_dir, force, note, full=False) ->
|
|
121
|
+
def _do_export(db, session_info, output_dir, force, note, full=False) -> int:
|
|
110
122
|
"""Внутренняя функция: экспорт диалога."""
|
|
111
123
|
title = get_session_title(session_info)
|
|
112
124
|
model = parse_model(session_info)
|
|
@@ -176,7 +188,7 @@ def _do_export(db, session_info, output_dir, force, note, full=False) -> Literal
|
|
|
176
188
|
return 0
|
|
177
189
|
|
|
178
190
|
|
|
179
|
-
def run(args, db) ->
|
|
191
|
+
def run(args, db) -> int:
|
|
180
192
|
output_dir = args.output or os.getcwd()
|
|
181
193
|
full = args.full
|
|
182
194
|
|
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
"""Команда info: детальная информация о сессии."""
|
|
2
2
|
|
|
3
|
-
# import db as db_module
|
|
4
|
-
from typing import Literal
|
|
5
|
-
|
|
6
3
|
from db import (
|
|
4
|
+
SessionError,
|
|
7
5
|
get_children_sessions,
|
|
8
6
|
get_message_count,
|
|
9
7
|
get_parent_session,
|
|
@@ -24,8 +22,12 @@ def register(subparsers) -> None:
|
|
|
24
22
|
p.add_argument("--json", action="store_true", help="JSON output")
|
|
25
23
|
|
|
26
24
|
|
|
27
|
-
def run(args, db) ->
|
|
28
|
-
|
|
25
|
+
def run(args, db) -> int:
|
|
26
|
+
try:
|
|
27
|
+
info = get_session_info(db, args.session_id)
|
|
28
|
+
except SessionError as e:
|
|
29
|
+
print(e.message)
|
|
30
|
+
return 1
|
|
29
31
|
session_id = info["id"]
|
|
30
32
|
msg_count = get_message_count(db, session_id)
|
|
31
33
|
part_count = get_part_count(db, session_id)
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
"""Команда search: поиск по тексту сообщений и частей."""
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
-
from typing import Literal
|
|
5
4
|
|
|
6
|
-
from db import resolve_session_id
|
|
5
|
+
from db import SessionError, resolve_session_id
|
|
7
6
|
from i18n import _
|
|
8
7
|
from utils import format_ts
|
|
9
8
|
|
|
@@ -16,9 +15,7 @@ def register(subparsers) -> None:
|
|
|
16
15
|
p.add_argument("--json", action="store_true", help="JSON output")
|
|
17
16
|
|
|
18
17
|
|
|
19
|
-
def _search_in_part_data(
|
|
20
|
-
data, query_lower
|
|
21
|
-
) -> tuple[Literal[False], Literal[""]] | tuple[Literal[True], str]:
|
|
18
|
+
def _search_in_part_data(data, query_lower) -> tuple[bool, str]:
|
|
22
19
|
"""Ищет query в part.data (JSON), проверяя текстовые поля."""
|
|
23
20
|
if not data:
|
|
24
21
|
return False, ""
|
|
@@ -45,14 +42,18 @@ def _search_in_part_data(
|
|
|
45
42
|
return False, ""
|
|
46
43
|
|
|
47
44
|
|
|
48
|
-
def run(args, db) ->
|
|
45
|
+
def run(args, db) -> int:
|
|
49
46
|
query_lower = args.query.lower()
|
|
50
47
|
|
|
51
48
|
conditions = []
|
|
52
49
|
params = []
|
|
53
50
|
|
|
54
51
|
if args.session:
|
|
55
|
-
|
|
52
|
+
try:
|
|
53
|
+
full_id = resolve_session_id(db, args.session)
|
|
54
|
+
except SessionError as e:
|
|
55
|
+
print(e.message)
|
|
56
|
+
return 1
|
|
56
57
|
conditions.append("p.session_id = ?")
|
|
57
58
|
params.append(full_id)
|
|
58
59
|
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
"""Команда todos: просмотр задач (todo) из сессий."""
|
|
2
2
|
|
|
3
|
-
from
|
|
4
|
-
|
|
5
|
-
from db import resolve_session_id
|
|
3
|
+
from db import SessionError, resolve_session_id
|
|
6
4
|
from i18n import _
|
|
7
5
|
from utils import format_ts
|
|
8
6
|
|
|
@@ -19,12 +17,16 @@ def register(subparsers) -> None:
|
|
|
19
17
|
p.add_argument("--json", action="store_true", help="JSON output")
|
|
20
18
|
|
|
21
19
|
|
|
22
|
-
def run(args, db) ->
|
|
20
|
+
def run(args, db) -> int:
|
|
23
21
|
conditions = []
|
|
24
22
|
params = []
|
|
25
23
|
|
|
26
24
|
if args.session_id:
|
|
27
|
-
|
|
25
|
+
try:
|
|
26
|
+
full_id = resolve_session_id(db, args.session_id)
|
|
27
|
+
except SessionError as e:
|
|
28
|
+
print(e.message)
|
|
29
|
+
return 1
|
|
28
30
|
conditions.append("t.session_id = ?")
|
|
29
31
|
params.append(full_id)
|
|
30
32
|
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
"""Команда tree: дерево ветвления сессий."""
|
|
2
2
|
|
|
3
|
-
from
|
|
4
|
-
|
|
5
|
-
from db import get_session_title, resolve_session_id
|
|
3
|
+
from db import SessionError, get_session_title, resolve_session_id
|
|
6
4
|
from i18n import _
|
|
7
5
|
from utils import format_ts
|
|
8
6
|
|
|
@@ -55,9 +53,13 @@ def _build_tree(db, parent_id, depth, max_depth, indent=0, visited=None):
|
|
|
55
53
|
return lines
|
|
56
54
|
|
|
57
55
|
|
|
58
|
-
def run(args, db) ->
|
|
56
|
+
def run(args, db) -> int:
|
|
59
57
|
if args.session_id:
|
|
60
|
-
|
|
58
|
+
try:
|
|
59
|
+
full_id = resolve_session_id(db, args.session_id)
|
|
60
|
+
except SessionError as e:
|
|
61
|
+
print(e.message)
|
|
62
|
+
return 1
|
|
61
63
|
info = db.execute(
|
|
62
64
|
"SELECT id, title, time_created FROM session WHERE id = ?", (full_id,)
|
|
63
65
|
).fetchone()
|
|
@@ -15,6 +15,15 @@ import sys
|
|
|
15
15
|
from config import OPENCODE_DB
|
|
16
16
|
from i18n import _
|
|
17
17
|
|
|
18
|
+
|
|
19
|
+
class SessionError(Exception):
|
|
20
|
+
"""Ошибка, связанная с сессией: не найдена, неоднозначный ID и т.п."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, message: str):
|
|
23
|
+
self.message = message
|
|
24
|
+
super().__init__(message)
|
|
25
|
+
|
|
26
|
+
|
|
18
27
|
# Ожидаемая схема БД: таблица → список обязательных колонок
|
|
19
28
|
_EXPECTED_SCHEMA = {
|
|
20
29
|
"session": [
|
|
@@ -95,15 +104,14 @@ def resolve_session_id(db, session_id):
|
|
|
95
104
|
).fetchall()
|
|
96
105
|
|
|
97
106
|
if not rows:
|
|
98
|
-
|
|
99
|
-
sys.exit(1)
|
|
107
|
+
raise SessionError(_("session.not_found", session_id=session_id))
|
|
100
108
|
|
|
101
109
|
if len(rows) > 1:
|
|
102
|
-
|
|
110
|
+
msg = [_("session.multiple_match", session_id=session_id)]
|
|
103
111
|
for r in rows:
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
112
|
+
msg.append(f" {r['id']}")
|
|
113
|
+
msg.append(_("session.specify_id"))
|
|
114
|
+
raise SessionError("\n".join(msg))
|
|
107
115
|
|
|
108
116
|
return rows[0]["id"]
|
|
109
117
|
|
|
@@ -127,8 +135,7 @@ def get_session_info(db, session_id):
|
|
|
127
135
|
).fetchone()
|
|
128
136
|
|
|
129
137
|
if not row:
|
|
130
|
-
|
|
131
|
-
sys.exit(1)
|
|
138
|
+
raise SessionError(_("session.not_found", session_id=session_id))
|
|
132
139
|
|
|
133
140
|
return row
|
|
134
141
|
|
|
@@ -338,8 +345,7 @@ def get_latest_session(db):
|
|
|
338
345
|
""").fetchone()
|
|
339
346
|
|
|
340
347
|
if not row:
|
|
341
|
-
|
|
342
|
-
sys.exit(1)
|
|
348
|
+
raise SessionError(_("session.no_sessions"))
|
|
343
349
|
|
|
344
350
|
return row["id"]
|
|
345
351
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: opencode-db
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.8
|
|
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
|
|
@@ -12,6 +12,9 @@ Dynamic: license-file
|
|
|
12
12
|
|
|
13
13
|
# opencode-db
|
|
14
14
|
|
|
15
|
+
[](https://www.python.org/downloads/)
|
|
16
|
+
[](LICENSE)
|
|
17
|
+
|
|
15
18
|
**OpenCode database CLI manager** — browse, export, analyze, and clean up agent sessions.
|
|
16
19
|
|
|
17
20
|
```bash
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "opencode-db"
|
|
7
|
-
version = "0.4.
|
|
7
|
+
version = "0.4.8"
|
|
8
8
|
description = "OpenCode database CLI manager — browse, export, analyze, clean up sessions"
|
|
9
9
|
readme = "README_PYPI.md"
|
|
10
10
|
requires-python = ">=3.12"
|
|
@@ -6,6 +6,18 @@ def _ns(**kwargs) -> argparse.Namespace:
|
|
|
6
6
|
return argparse.Namespace(**kwargs)
|
|
7
7
|
|
|
8
8
|
|
|
9
|
+
def _count_sessions(db) -> int:
|
|
10
|
+
return db.execute("SELECT COUNT(*) FROM session").fetchone()[0]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _count_messages(db) -> int:
|
|
14
|
+
return db.execute("SELECT COUNT(*) FROM message").fetchone()[0]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _count_parts(db) -> int:
|
|
18
|
+
return db.execute("SELECT COUNT(*) FROM part").fetchone()[0]
|
|
19
|
+
|
|
20
|
+
|
|
9
21
|
class TestListCommand:
|
|
10
22
|
def test_list_default(self, db):
|
|
11
23
|
from cmd_list import run
|
|
@@ -418,3 +430,80 @@ class TestExportCommand:
|
|
|
418
430
|
output=str(tmp_path),
|
|
419
431
|
)
|
|
420
432
|
assert run(args, db) == 0
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
class TestPruneCommand:
|
|
436
|
+
def test_prune_filter_required(self, db) -> None:
|
|
437
|
+
from cmd_prune import run
|
|
438
|
+
|
|
439
|
+
args = _ns(older_than=None, keep_last=None, project=None, dry_run=False, force=False)
|
|
440
|
+
assert run(args, db) == 1
|
|
441
|
+
|
|
442
|
+
def test_prune_dry_run_older_than(self, db) -> None:
|
|
443
|
+
from cmd_prune import run
|
|
444
|
+
|
|
445
|
+
args = _ns(older_than="30d", keep_last=None, project=None, dry_run=True, force=True)
|
|
446
|
+
assert run(args, db) == 0
|
|
447
|
+
|
|
448
|
+
def test_prune_dry_run_keep_last(self, db) -> None:
|
|
449
|
+
from cmd_prune import run
|
|
450
|
+
|
|
451
|
+
args = _ns(older_than=None, keep_last=2, project=None, dry_run=True, force=True)
|
|
452
|
+
assert run(args, db) == 0
|
|
453
|
+
assert _count_sessions(db) == 4
|
|
454
|
+
|
|
455
|
+
def test_prune_force_older_than(self, db) -> None:
|
|
456
|
+
from cmd_prune import run
|
|
457
|
+
|
|
458
|
+
args = _ns(older_than="1d", keep_last=None, project=None, dry_run=False, force=True)
|
|
459
|
+
result = run(args, db)
|
|
460
|
+
assert result == 0
|
|
461
|
+
remaining = _count_sessions(db)
|
|
462
|
+
assert remaining < 4
|
|
463
|
+
|
|
464
|
+
def test_prune_by_project(self, db) -> None:
|
|
465
|
+
from cmd_prune import run
|
|
466
|
+
|
|
467
|
+
args = _ns(older_than=None, keep_last=None, project="proj_002", dry_run=True, force=True)
|
|
468
|
+
assert run(args, db) == 0
|
|
469
|
+
|
|
470
|
+
def test_prune_bad_spec(self, db) -> None:
|
|
471
|
+
from cmd_prune import run
|
|
472
|
+
|
|
473
|
+
args = _ns(older_than="invalid", keep_last=None, project=None, dry_run=False, force=True)
|
|
474
|
+
assert run(args, db) == 1
|
|
475
|
+
|
|
476
|
+
def test_prune_canceled(self, db) -> None:
|
|
477
|
+
from cmd_prune import run
|
|
478
|
+
|
|
479
|
+
args = _ns(older_than="30d", keep_last=None, project=None, dry_run=False, force=False)
|
|
480
|
+
with patch("cmd_prune.confirm", return_value=False):
|
|
481
|
+
assert run(args, db) == 0
|
|
482
|
+
assert _count_sessions(db) == 4
|
|
483
|
+
|
|
484
|
+
def test_prune_deletes_messages_and_parts(self, db) -> None:
|
|
485
|
+
from cmd_prune import run
|
|
486
|
+
|
|
487
|
+
total_msg = _count_messages(db)
|
|
488
|
+
total_parts = _count_parts(db)
|
|
489
|
+
assert total_msg > 0
|
|
490
|
+
assert total_parts > 0
|
|
491
|
+
|
|
492
|
+
args = _ns(older_than="1d", keep_last=None, project=None, dry_run=False, force=True)
|
|
493
|
+
assert run(args, db) == 0
|
|
494
|
+
|
|
495
|
+
assert _count_messages(db) < total_msg
|
|
496
|
+
assert _count_parts(db) < total_parts
|
|
497
|
+
|
|
498
|
+
def test_prune_keep_last_preserves_recent(self, db) -> None:
|
|
499
|
+
from cmd_prune import run
|
|
500
|
+
|
|
501
|
+
args = _ns(older_than=None, keep_last=10, project=None, dry_run=True, force=True)
|
|
502
|
+
assert run(args, db) == 0
|
|
503
|
+
|
|
504
|
+
def test_prune_none_to_delete(self, db) -> None:
|
|
505
|
+
from cmd_prune import run
|
|
506
|
+
|
|
507
|
+
args = _ns(older_than="100y", keep_last=None, project=None, dry_run=False, force=True)
|
|
508
|
+
assert run(args, db) == 0
|
|
509
|
+
assert _count_sessions(db) == 4
|
|
@@ -1,7 +1,13 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
1
5
|
from db import (
|
|
6
|
+
SessionError,
|
|
2
7
|
get_children_sessions,
|
|
3
8
|
get_latest_session,
|
|
4
9
|
get_message_count,
|
|
10
|
+
get_messages,
|
|
5
11
|
get_parent_session,
|
|
6
12
|
get_part_count,
|
|
7
13
|
get_project_count,
|
|
@@ -12,6 +18,7 @@ from db import (
|
|
|
12
18
|
get_session_title,
|
|
13
19
|
get_session_todos,
|
|
14
20
|
parse_model,
|
|
21
|
+
resolve_session_id,
|
|
15
22
|
verify_schema,
|
|
16
23
|
)
|
|
17
24
|
|
|
@@ -150,3 +157,121 @@ class TestGetRecentSessions:
|
|
|
150
157
|
def test_custom_limit(self, db) -> None:
|
|
151
158
|
sessions = get_recent_sessions(db, limit=2)
|
|
152
159
|
assert len(sessions) == 2
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class TestResolveSessionId:
|
|
163
|
+
def test_empty_id_returns_none(self, db) -> None:
|
|
164
|
+
assert resolve_session_id(db, None) is None
|
|
165
|
+
assert resolve_session_id(db, "") is None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class TestSessionError:
|
|
169
|
+
def test_resolve_session_id_not_found(self, db) -> None:
|
|
170
|
+
with pytest.raises(SessionError):
|
|
171
|
+
get_session_info(db, "nonexistent")
|
|
172
|
+
|
|
173
|
+
def test_resolve_session_id_multiple(self, db) -> None:
|
|
174
|
+
db.execute(
|
|
175
|
+
"INSERT INTO session (id, time_created) VALUES (?, ?)",
|
|
176
|
+
("ses_test_abc", 1000),
|
|
177
|
+
)
|
|
178
|
+
db.execute(
|
|
179
|
+
"INSERT INTO session (id, time_created) VALUES (?, ?)",
|
|
180
|
+
("ses_test_xyz", 2000),
|
|
181
|
+
)
|
|
182
|
+
db.commit()
|
|
183
|
+
with pytest.raises(SessionError):
|
|
184
|
+
get_session_info(db, "ses_test")
|
|
185
|
+
|
|
186
|
+
def test_get_latest_session_no_sessions(self, db) -> None:
|
|
187
|
+
db.execute("DELETE FROM session")
|
|
188
|
+
db.commit()
|
|
189
|
+
with pytest.raises(SessionError):
|
|
190
|
+
get_latest_session(db)
|
|
191
|
+
|
|
192
|
+
def test_get_session_info_not_found(self, db) -> None:
|
|
193
|
+
with pytest.raises(SessionError):
|
|
194
|
+
get_session_info(db, "zzz_nope")
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class TestGetMessages:
|
|
198
|
+
def test_returns_messages_with_parts(self, db) -> None:
|
|
199
|
+
messages = get_messages(db, "ses_001")
|
|
200
|
+
assert len(messages) == 2
|
|
201
|
+
assert messages[0]["role"] == "user"
|
|
202
|
+
assert messages[1]["role"] == "assistant"
|
|
203
|
+
|
|
204
|
+
def test_parts_are_grouped_by_message(self, db) -> None:
|
|
205
|
+
messages = get_messages(db, "ses_001")
|
|
206
|
+
assert len(messages) == 2
|
|
207
|
+
assert len(messages[0]["parts"]) == 1
|
|
208
|
+
assert messages[0]["parts"][0]["type"] == "text"
|
|
209
|
+
|
|
210
|
+
def test_multiple_parts_per_message(self, db) -> None:
|
|
211
|
+
messages = get_messages(db, "ses_001")
|
|
212
|
+
parts = messages[1]["parts"]
|
|
213
|
+
assert len(parts) == 3
|
|
214
|
+
types = [p["type"] for p in parts]
|
|
215
|
+
assert types == ["text", "reasoning", "tool"]
|
|
216
|
+
|
|
217
|
+
def test_text_part_content(self, db) -> None:
|
|
218
|
+
messages = get_messages(db, "ses_001")
|
|
219
|
+
text_part = messages[0]["parts"][0]
|
|
220
|
+
assert text_part["type"] == "text"
|
|
221
|
+
assert "Hello" in text_part["text"]
|
|
222
|
+
|
|
223
|
+
def test_reasoning_part(self, db) -> None:
|
|
224
|
+
messages = get_messages(db, "ses_001")
|
|
225
|
+
parts = messages[1]["parts"]
|
|
226
|
+
reasoning = [p for p in parts if p["type"] == "reasoning"]
|
|
227
|
+
assert len(reasoning) == 1
|
|
228
|
+
assert "think" in reasoning[0]["text"].lower()
|
|
229
|
+
|
|
230
|
+
def test_tool_part(self, db) -> None:
|
|
231
|
+
messages = get_messages(db, "ses_001")
|
|
232
|
+
parts = messages[1]["parts"]
|
|
233
|
+
tool = [p for p in parts if p["type"] == "tool"]
|
|
234
|
+
assert len(tool) == 1
|
|
235
|
+
assert tool[0]["tool"] == "read"
|
|
236
|
+
assert tool[0]["status"] == "completed"
|
|
237
|
+
|
|
238
|
+
def test_sound_part(self, db) -> None:
|
|
239
|
+
messages = get_messages(db, "ses_003")
|
|
240
|
+
assert len(messages) == 2
|
|
241
|
+
parts = messages[0]["parts"]
|
|
242
|
+
assert len(parts) == 1
|
|
243
|
+
assert parts[0]["type"] == "sound"
|
|
244
|
+
|
|
245
|
+
def test_empty_session(self, db) -> None:
|
|
246
|
+
messages = get_messages(db, "ses_004")
|
|
247
|
+
assert messages == []
|
|
248
|
+
|
|
249
|
+
def test_bash_tool_part(self, db) -> None:
|
|
250
|
+
messages = get_messages(db, "ses_003")
|
|
251
|
+
tool_part = messages[1]["parts"][0]
|
|
252
|
+
assert tool_part["type"] == "tool"
|
|
253
|
+
assert tool_part["tool"] == "bash"
|
|
254
|
+
|
|
255
|
+
def test_skips_invalid_json_part(self, db) -> None:
|
|
256
|
+
db.execute(
|
|
257
|
+
"INSERT INTO message (id, session_id, time_created, data) VALUES (?, ?, ?, ?)",
|
|
258
|
+
("msg_bad", "ses_001", 100500, json.dumps({"role": "user"})),
|
|
259
|
+
)
|
|
260
|
+
db.execute(
|
|
261
|
+
"INSERT INTO part (id, message_id, session_id, time_created, data) VALUES (?, ?, ?, ?, ?)",
|
|
262
|
+
("part_bad", "msg_bad", "ses_001", 100500, "not valid json {"),
|
|
263
|
+
)
|
|
264
|
+
messages = get_messages(db, "ses_001")
|
|
265
|
+
assert len(messages) == 2
|
|
266
|
+
|
|
267
|
+
def test_skips_empty_part_data(self, db) -> None:
|
|
268
|
+
db.execute(
|
|
269
|
+
"INSERT INTO message (id, session_id, time_created, data) VALUES (?, ?, ?, ?)",
|
|
270
|
+
("msg_empty", "ses_001", 100600, json.dumps({"role": "user"})),
|
|
271
|
+
)
|
|
272
|
+
db.execute(
|
|
273
|
+
"INSERT INTO part (id, message_id, session_id, time_created, data) VALUES (?, ?, ?, ?, ?)",
|
|
274
|
+
("part_empty", "msg_empty", "ses_001", 100600, None),
|
|
275
|
+
)
|
|
276
|
+
messages = get_messages(db, "ses_001")
|
|
277
|
+
assert len(messages) == 2
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
from formatters import format_part_to_md
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class TestFormatPartToMd:
|
|
5
|
+
def test_text_part(self):
|
|
6
|
+
result = format_part_to_md({"type": "text", "text": "Hello world"})
|
|
7
|
+
assert result == "Hello world"
|
|
8
|
+
|
|
9
|
+
def test_text_part_strips_whitespace(self):
|
|
10
|
+
result = format_part_to_md({"type": "text", "text": " hello "})
|
|
11
|
+
assert result == "hello"
|
|
12
|
+
|
|
13
|
+
def test_text_part_empty_returns_empty(self):
|
|
14
|
+
result = format_part_to_md({"type": "text", "text": ""})
|
|
15
|
+
assert result == ""
|
|
16
|
+
|
|
17
|
+
def test_text_part_missing_text(self):
|
|
18
|
+
result = format_part_to_md({"type": "text"})
|
|
19
|
+
assert result == ""
|
|
20
|
+
|
|
21
|
+
def test_reasoning_part(self):
|
|
22
|
+
result = format_part_to_md({"type": "reasoning", "text": "Let me think..."})
|
|
23
|
+
assert "Let me think..." in result
|
|
24
|
+
assert "reasoning" in result.lower() or "💭" in result
|
|
25
|
+
|
|
26
|
+
def test_reasoning_empty_returns_empty(self):
|
|
27
|
+
result = format_part_to_md({"type": "reasoning", "text": " "})
|
|
28
|
+
assert result == ""
|
|
29
|
+
|
|
30
|
+
def test_sound_part(self):
|
|
31
|
+
result = format_part_to_md({"type": "sound"})
|
|
32
|
+
assert result != ""
|
|
33
|
+
|
|
34
|
+
def test_tool_read_part(self):
|
|
35
|
+
result = format_part_to_md(
|
|
36
|
+
{
|
|
37
|
+
"type": "tool",
|
|
38
|
+
"tool": "read",
|
|
39
|
+
"input": {"filePath": "/home/user/file.py"},
|
|
40
|
+
"output": "content",
|
|
41
|
+
"status": "completed",
|
|
42
|
+
}
|
|
43
|
+
)
|
|
44
|
+
assert "read" in result
|
|
45
|
+
assert "file.py" in result
|
|
46
|
+
|
|
47
|
+
def test_tool_write_part(self):
|
|
48
|
+
result = format_part_to_md(
|
|
49
|
+
{
|
|
50
|
+
"type": "tool",
|
|
51
|
+
"tool": "write",
|
|
52
|
+
"input": {"filePath": "/home/user/new.py"},
|
|
53
|
+
"output": "",
|
|
54
|
+
"status": "completed",
|
|
55
|
+
}
|
|
56
|
+
)
|
|
57
|
+
assert "write" in result
|
|
58
|
+
|
|
59
|
+
def test_tool_edit_part(self):
|
|
60
|
+
result = format_part_to_md(
|
|
61
|
+
{
|
|
62
|
+
"type": "tool",
|
|
63
|
+
"tool": "edit",
|
|
64
|
+
"input": {"filePath": "/home/user/file.py"},
|
|
65
|
+
"output": "",
|
|
66
|
+
"status": "completed",
|
|
67
|
+
}
|
|
68
|
+
)
|
|
69
|
+
assert "edit" in result
|
|
70
|
+
|
|
71
|
+
def test_tool_glob_part(self):
|
|
72
|
+
result = format_part_to_md(
|
|
73
|
+
{
|
|
74
|
+
"type": "tool",
|
|
75
|
+
"tool": "glob",
|
|
76
|
+
"input": {"pattern": "**/*.py", "path": "/home/user"},
|
|
77
|
+
"output": "",
|
|
78
|
+
"status": "completed",
|
|
79
|
+
}
|
|
80
|
+
)
|
|
81
|
+
assert "glob" in result
|
|
82
|
+
assert "**/*.py" in result
|
|
83
|
+
|
|
84
|
+
def test_tool_grep_part(self):
|
|
85
|
+
result = format_part_to_md(
|
|
86
|
+
{
|
|
87
|
+
"type": "tool",
|
|
88
|
+
"tool": "grep",
|
|
89
|
+
"input": {"pattern": "def hello"},
|
|
90
|
+
"output": "def hello():\n pass",
|
|
91
|
+
"status": "completed",
|
|
92
|
+
}
|
|
93
|
+
)
|
|
94
|
+
assert "grep" in result
|
|
95
|
+
assert "def hello" in result
|
|
96
|
+
|
|
97
|
+
def test_tool_custom_with_input_output(self):
|
|
98
|
+
result = format_part_to_md(
|
|
99
|
+
{
|
|
100
|
+
"type": "tool",
|
|
101
|
+
"tool": "bash",
|
|
102
|
+
"input": {"command": "ls -la"},
|
|
103
|
+
"output": "total 42\n-rw-r--r-- 1 user staff 1234 Jan 1 00:00 file.py",
|
|
104
|
+
"status": "completed",
|
|
105
|
+
}
|
|
106
|
+
)
|
|
107
|
+
assert "bash" in result
|
|
108
|
+
assert "ls -la" in result
|
|
109
|
+
assert "total 42" in result
|
|
110
|
+
|
|
111
|
+
def test_tool_error_status(self):
|
|
112
|
+
result = format_part_to_md(
|
|
113
|
+
{
|
|
114
|
+
"type": "tool",
|
|
115
|
+
"tool": "bash",
|
|
116
|
+
"input": {"command": "rm -rf /"},
|
|
117
|
+
"output": "permission denied",
|
|
118
|
+
"status": "error",
|
|
119
|
+
}
|
|
120
|
+
)
|
|
121
|
+
assert "❌" in result
|
|
122
|
+
|
|
123
|
+
def test_tool_running_status(self):
|
|
124
|
+
result = format_part_to_md(
|
|
125
|
+
{
|
|
126
|
+
"type": "tool",
|
|
127
|
+
"tool": "bash",
|
|
128
|
+
"input": {"command": "sleep 10"},
|
|
129
|
+
"output": "",
|
|
130
|
+
"status": "running",
|
|
131
|
+
}
|
|
132
|
+
)
|
|
133
|
+
assert "⏳" in result
|
|
134
|
+
|
|
135
|
+
def test_tool_no_output(self):
|
|
136
|
+
result = format_part_to_md(
|
|
137
|
+
{
|
|
138
|
+
"type": "tool",
|
|
139
|
+
"tool": "bash",
|
|
140
|
+
"input": {"command": "echo hi"},
|
|
141
|
+
"output": "",
|
|
142
|
+
"status": "completed",
|
|
143
|
+
}
|
|
144
|
+
)
|
|
145
|
+
assert "echo hi" in result
|
|
146
|
+
|
|
147
|
+
def test_full_mode_does_not_truncate_path(self):
|
|
148
|
+
long_path = "/a/" + "very/" * 50 + "file.py"
|
|
149
|
+
result = format_part_to_md(
|
|
150
|
+
{
|
|
151
|
+
"type": "tool",
|
|
152
|
+
"tool": "read",
|
|
153
|
+
"input": {"filePath": long_path},
|
|
154
|
+
"output": "",
|
|
155
|
+
"status": "completed",
|
|
156
|
+
},
|
|
157
|
+
full=True,
|
|
158
|
+
)
|
|
159
|
+
assert long_path in result
|
|
160
|
+
assert "..." not in result
|
|
161
|
+
|
|
162
|
+
def test_full_mode_shows_full_output(self):
|
|
163
|
+
result = format_part_to_md(
|
|
164
|
+
{
|
|
165
|
+
"type": "tool",
|
|
166
|
+
"tool": "bash",
|
|
167
|
+
"input": {"command": "cat bigfile.txt"},
|
|
168
|
+
"output": "line\n" * 500,
|
|
169
|
+
"status": "completed",
|
|
170
|
+
},
|
|
171
|
+
full=True,
|
|
172
|
+
)
|
|
173
|
+
assert "line" in result
|
|
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
|