opencode-db 0.3.0__py3-none-any.whl
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.
- cli.py +70 -0
- cmd_costs.py +197 -0
- cmd_delete.py +58 -0
- cmd_export.py +189 -0
- cmd_help.py +210 -0
- cmd_info.py +152 -0
- cmd_list.py +124 -0
- cmd_projects.py +82 -0
- cmd_prune.py +127 -0
- cmd_search.py +111 -0
- cmd_stats.py +115 -0
- cmd_todos.py +90 -0
- cmd_tree.py +95 -0
- cmd_vacuum.py +67 -0
- commands/__init__.py +51 -0
- config.py +14 -0
- db.py +374 -0
- formatters.py +208 -0
- i18n.py +532 -0
- opencode_db-0.3.0.dist-info/METADATA +7 -0
- opencode_db-0.3.0.dist-info/RECORD +26 -0
- opencode_db-0.3.0.dist-info/WHEEL +5 -0
- opencode_db-0.3.0.dist-info/entry_points.txt +2 -0
- opencode_db-0.3.0.dist-info/licenses/LICENSE +25 -0
- opencode_db-0.3.0.dist-info/top_level.txt +20 -0
- utils.py +115 -0
cmd_help.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Команда help: подробная справка по opencode-db."""
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
from i18n import _
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def register(subparsers) -> None:
|
|
9
|
+
p = subparsers.add_parser("help", help=_("help.cmd.help"))
|
|
10
|
+
p.add_argument("topic", nargs="?", help="Command name")
|
|
11
|
+
p.add_argument("--json", action="store_true", help="JSON output")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
_COMMANDS = [
|
|
15
|
+
(
|
|
16
|
+
"list",
|
|
17
|
+
"help.cmd.list",
|
|
18
|
+
[
|
|
19
|
+
("", "help.list.e0"),
|
|
20
|
+
("--limit 10", "help.list.e1"),
|
|
21
|
+
("--sort cost", "help.list.e2"),
|
|
22
|
+
("--project proj_xxx", "help.list.e3"),
|
|
23
|
+
("--json", "help.list.e4"),
|
|
24
|
+
],
|
|
25
|
+
),
|
|
26
|
+
(
|
|
27
|
+
"info",
|
|
28
|
+
"help.cmd.info",
|
|
29
|
+
[
|
|
30
|
+
("<session_id>", "help.info.e0"),
|
|
31
|
+
("<session_id> --json", "help.info.e1"),
|
|
32
|
+
],
|
|
33
|
+
),
|
|
34
|
+
(
|
|
35
|
+
"export",
|
|
36
|
+
"help.cmd.export",
|
|
37
|
+
[
|
|
38
|
+
("<session_id>", "help.export.e0"),
|
|
39
|
+
("--latest", "help.export.e1"),
|
|
40
|
+
("", "help.export.e2"),
|
|
41
|
+
("--full", "help.export.e3"),
|
|
42
|
+
("--force", "help.export.e4"),
|
|
43
|
+
('--note "text"', "help.export.e5"),
|
|
44
|
+
],
|
|
45
|
+
),
|
|
46
|
+
(
|
|
47
|
+
"delete",
|
|
48
|
+
"help.cmd.delete",
|
|
49
|
+
[
|
|
50
|
+
("<session_id>", "help.delete.e0"),
|
|
51
|
+
("<session_id> --dry-run", "help.delete.e1"),
|
|
52
|
+
("<session_id> --force", "help.delete.e2"),
|
|
53
|
+
],
|
|
54
|
+
),
|
|
55
|
+
(
|
|
56
|
+
"costs",
|
|
57
|
+
"help.cmd.costs",
|
|
58
|
+
[
|
|
59
|
+
("", "help.costs.e0"),
|
|
60
|
+
("<session_id>", "help.costs.e1"),
|
|
61
|
+
("--total", "help.costs.e2"),
|
|
62
|
+
("--project proj_xxx", "help.costs.e3"),
|
|
63
|
+
("--json", "help.costs.e4"),
|
|
64
|
+
],
|
|
65
|
+
),
|
|
66
|
+
(
|
|
67
|
+
"prune",
|
|
68
|
+
"help.cmd.prune",
|
|
69
|
+
[
|
|
70
|
+
("--older-than 90d", "help.prune.e0"),
|
|
71
|
+
("--keep-last 20", "help.prune.e1"),
|
|
72
|
+
("--dry-run", "help.prune.e2"),
|
|
73
|
+
("--force", "help.prune.e3"),
|
|
74
|
+
],
|
|
75
|
+
),
|
|
76
|
+
(
|
|
77
|
+
"search",
|
|
78
|
+
"help.cmd.search",
|
|
79
|
+
[
|
|
80
|
+
('"text"', "help.search.e0"),
|
|
81
|
+
('"text" --session ses_1c9d', "help.search.e1"),
|
|
82
|
+
('"text" --json', "help.search.e2"),
|
|
83
|
+
],
|
|
84
|
+
),
|
|
85
|
+
(
|
|
86
|
+
"tree",
|
|
87
|
+
"help.cmd.tree",
|
|
88
|
+
[
|
|
89
|
+
("", "help.tree.e0"),
|
|
90
|
+
("<session_id>", "help.tree.e1"),
|
|
91
|
+
("--depth 3", "help.tree.e2"),
|
|
92
|
+
],
|
|
93
|
+
),
|
|
94
|
+
(
|
|
95
|
+
"projects",
|
|
96
|
+
"help.cmd.projects",
|
|
97
|
+
[
|
|
98
|
+
("", "help.projects.e0"),
|
|
99
|
+
("--json", "help.projects.e1"),
|
|
100
|
+
],
|
|
101
|
+
),
|
|
102
|
+
(
|
|
103
|
+
"stats",
|
|
104
|
+
"help.cmd.stats",
|
|
105
|
+
[
|
|
106
|
+
("", "help.stats.e0"),
|
|
107
|
+
("--json", "help.stats.e1"),
|
|
108
|
+
],
|
|
109
|
+
),
|
|
110
|
+
(
|
|
111
|
+
"todos",
|
|
112
|
+
"help.cmd.todos",
|
|
113
|
+
[
|
|
114
|
+
("", "help.todos.e0"),
|
|
115
|
+
("<session_id>", "help.todos.e1"),
|
|
116
|
+
("--status pending", "help.todos.e2"),
|
|
117
|
+
("--json", "help.todos.e3"),
|
|
118
|
+
],
|
|
119
|
+
),
|
|
120
|
+
(
|
|
121
|
+
"vacuum",
|
|
122
|
+
"help.cmd.vacuum",
|
|
123
|
+
[
|
|
124
|
+
("", "help.vacuum.e0"),
|
|
125
|
+
("--force", "help.vacuum.e1"),
|
|
126
|
+
],
|
|
127
|
+
),
|
|
128
|
+
]
|
|
129
|
+
|
|
130
|
+
_COMMAND_MAP: dict[str, tuple[str, list]] = {}
|
|
131
|
+
for name, desc_key, examples in _COMMANDS:
|
|
132
|
+
_COMMAND_MAP[name] = (desc_key, examples)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _print_general_help() -> None:
|
|
136
|
+
"""Вывод полной справки."""
|
|
137
|
+
print()
|
|
138
|
+
print(f" {_('help.header_box_top')}")
|
|
139
|
+
print(f" {_('help.header_title')}")
|
|
140
|
+
print(f" {_('help.header_box_bottom')}")
|
|
141
|
+
print()
|
|
142
|
+
print(f" {_('help.intro')}")
|
|
143
|
+
print(f" {_('help.intro2')}")
|
|
144
|
+
print()
|
|
145
|
+
print(f" {_('help.usage')}")
|
|
146
|
+
print()
|
|
147
|
+
|
|
148
|
+
for name, desc_key, examples in _COMMANDS:
|
|
149
|
+
desc = _(desc_key)
|
|
150
|
+
print(f" {name:12s} {desc}")
|
|
151
|
+
for flag, example_key in examples:
|
|
152
|
+
example_desc = _(example_key)
|
|
153
|
+
if flag:
|
|
154
|
+
print(f" {'':12s} opencode-db {name} {flag:30s} # {example_desc}")
|
|
155
|
+
else:
|
|
156
|
+
print(f" {'':12s} opencode-db {name:33s} # {example_desc}")
|
|
157
|
+
print()
|
|
158
|
+
|
|
159
|
+
print(f" {'─' * 58}")
|
|
160
|
+
print(f" {_('help.footer')}")
|
|
161
|
+
print(f" {_('help.footer2')}")
|
|
162
|
+
print(f" {_('help.footer3')}")
|
|
163
|
+
print()
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _print_command_help(cmd_name) -> Literal[1] | Literal[0]:
|
|
167
|
+
"""Вывод справки по одной команде."""
|
|
168
|
+
entry = _COMMAND_MAP.get(cmd_name)
|
|
169
|
+
if not entry:
|
|
170
|
+
print(f"{_('help.unknown', cmd=cmd_name)}")
|
|
171
|
+
print(f" {_('help.available', cmds=', '.join(_COMMAND_MAP))}")
|
|
172
|
+
return 1
|
|
173
|
+
|
|
174
|
+
desc_key, examples = entry
|
|
175
|
+
print()
|
|
176
|
+
print(_("help.cmd_header", cmd=cmd_name, desc=_(desc_key)))
|
|
177
|
+
print()
|
|
178
|
+
for flag, example_key in examples:
|
|
179
|
+
example_desc = _(example_key)
|
|
180
|
+
if flag:
|
|
181
|
+
print(f" opencode-db {cmd_name} {flag:30s} # {example_desc}")
|
|
182
|
+
else:
|
|
183
|
+
print(f" opencode-db {cmd_name:33s} # {example_desc}")
|
|
184
|
+
print()
|
|
185
|
+
return 0
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def run(args, db) -> Literal[1] | Literal[0]:
|
|
189
|
+
import json
|
|
190
|
+
|
|
191
|
+
if args.topic:
|
|
192
|
+
return _print_command_help(args.topic)
|
|
193
|
+
|
|
194
|
+
if args.json:
|
|
195
|
+
data = []
|
|
196
|
+
for name, desc_key, examples in _COMMANDS:
|
|
197
|
+
data.append(
|
|
198
|
+
{
|
|
199
|
+
"name": name,
|
|
200
|
+
"description": _(desc_key),
|
|
201
|
+
"examples": [
|
|
202
|
+
{"args": flag, "comment": _(example_key)} for flag, example_key in examples
|
|
203
|
+
],
|
|
204
|
+
}
|
|
205
|
+
)
|
|
206
|
+
print(json.dumps(data, ensure_ascii=False, indent=2))
|
|
207
|
+
return 0
|
|
208
|
+
|
|
209
|
+
_print_general_help()
|
|
210
|
+
return 0
|
cmd_info.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Команда info: детальная информация о сессии."""
|
|
2
|
+
|
|
3
|
+
# import db as db_module
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
from db import (
|
|
7
|
+
get_children_sessions,
|
|
8
|
+
get_message_count,
|
|
9
|
+
get_parent_session,
|
|
10
|
+
get_part_count,
|
|
11
|
+
get_project_name,
|
|
12
|
+
get_session_info,
|
|
13
|
+
get_session_title,
|
|
14
|
+
get_session_todos,
|
|
15
|
+
parse_model,
|
|
16
|
+
)
|
|
17
|
+
from i18n import _
|
|
18
|
+
from utils import format_cost, format_tokens, format_ts
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def register(subparsers) -> None:
|
|
22
|
+
p = subparsers.add_parser("info", help=_("help.cmd.info"))
|
|
23
|
+
p.add_argument("session_id", help="Session ID")
|
|
24
|
+
p.add_argument("--json", action="store_true", help="JSON output")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def run(args, db) -> Literal[0]:
|
|
28
|
+
info = get_session_info(db, args.session_id)
|
|
29
|
+
session_id = info["id"]
|
|
30
|
+
msg_count = get_message_count(db, session_id)
|
|
31
|
+
part_count = get_part_count(db, session_id)
|
|
32
|
+
children = get_children_sessions(db, session_id)
|
|
33
|
+
parent = get_parent_session(db, session_id)
|
|
34
|
+
todos = get_session_todos(db, session_id)
|
|
35
|
+
project_name = get_project_name(db, info["project_id"]) if info["project_id"] else "—"
|
|
36
|
+
|
|
37
|
+
model = parse_model(info)
|
|
38
|
+
title = get_session_title(info)
|
|
39
|
+
created = format_ts(info["time_created"])
|
|
40
|
+
updated = format_ts(info["time_updated"])
|
|
41
|
+
|
|
42
|
+
if info["time_updated"] and info["time_created"]:
|
|
43
|
+
duration_sec = (info["time_updated"] - info["time_created"]) / 1000
|
|
44
|
+
if duration_sec < 60:
|
|
45
|
+
duration = _("info.dur_sec", n=int(duration_sec))
|
|
46
|
+
elif duration_sec < 3600:
|
|
47
|
+
duration = _("info.dur_min", n=int(duration_sec / 60))
|
|
48
|
+
else:
|
|
49
|
+
hours = int(duration_sec / 3600)
|
|
50
|
+
mins = int((duration_sec % 3600) / 60)
|
|
51
|
+
duration = _("info.dur_hours", h=hours, m=mins)
|
|
52
|
+
else:
|
|
53
|
+
duration = "—"
|
|
54
|
+
|
|
55
|
+
if args.json:
|
|
56
|
+
data = {
|
|
57
|
+
"id": info["id"],
|
|
58
|
+
"title": title,
|
|
59
|
+
"project": {"id": info["project_id"], "name": project_name},
|
|
60
|
+
"model": model,
|
|
61
|
+
"agent": info["agent"],
|
|
62
|
+
"version": info["version"],
|
|
63
|
+
"directory": info["directory"],
|
|
64
|
+
"parent_id": info["parent_id"],
|
|
65
|
+
"time_created": created,
|
|
66
|
+
"time_updated": updated,
|
|
67
|
+
"duration": duration,
|
|
68
|
+
"messages": msg_count,
|
|
69
|
+
"parts": part_count,
|
|
70
|
+
"cost": info["cost"],
|
|
71
|
+
"tokens": {
|
|
72
|
+
"input": info["tokens_input"],
|
|
73
|
+
"output": info["tokens_output"],
|
|
74
|
+
"reasoning": info["tokens_reasoning"],
|
|
75
|
+
"cache_read": info["tokens_cache_read"],
|
|
76
|
+
"cache_write": info["tokens_cache_write"],
|
|
77
|
+
},
|
|
78
|
+
"code_changes": {
|
|
79
|
+
"additions": info["summary_additions"],
|
|
80
|
+
"deletions": info["summary_deletions"],
|
|
81
|
+
"files": info["summary_files"],
|
|
82
|
+
},
|
|
83
|
+
"children": [{"id": c["id"], "title": c["title"]} for c in children]
|
|
84
|
+
if children
|
|
85
|
+
else [],
|
|
86
|
+
"todos": [{"content": t["content"], "status": t["status"]} for t in todos]
|
|
87
|
+
if todos
|
|
88
|
+
else [],
|
|
89
|
+
}
|
|
90
|
+
if parent:
|
|
91
|
+
data["parent"] = {"id": parent["id"], "title": parent["title"]}
|
|
92
|
+
|
|
93
|
+
from formatters import print_json
|
|
94
|
+
|
|
95
|
+
print_json(data)
|
|
96
|
+
return 0
|
|
97
|
+
|
|
98
|
+
print(f"\n{'=' * 60}")
|
|
99
|
+
print(f" {title}")
|
|
100
|
+
print(f"{'=' * 60}")
|
|
101
|
+
print(f" {_('info.id')}: {session_id}")
|
|
102
|
+
print(f" {_('info.project')}: {project_name} ({info['project_id'][:24]})")
|
|
103
|
+
print(f" {_('info.model')}: {model}")
|
|
104
|
+
print(f" {_('info.agent')}: {info['agent'] or '—'}")
|
|
105
|
+
print(f" {_('info.version')}: {info['version'] or '—'}")
|
|
106
|
+
print(f" {_('info.created')}: {created}")
|
|
107
|
+
print(f" {_('info.updated')}: {updated}")
|
|
108
|
+
print(f" {_('info.duration')}: {duration}")
|
|
109
|
+
|
|
110
|
+
if parent:
|
|
111
|
+
parent_title = parent["title"] or _("info.unnamed")
|
|
112
|
+
print(f" {_('info.parent')}: {parent['id'][:24]} — {parent_title}")
|
|
113
|
+
|
|
114
|
+
if children:
|
|
115
|
+
print(f" {_('info.branches')}: {len(children)}")
|
|
116
|
+
for c in children:
|
|
117
|
+
c_title = c["title"] or _("info.unnamed")
|
|
118
|
+
print(_("info.children_prefix", id=c["id"][:24], title=c_title))
|
|
119
|
+
|
|
120
|
+
print(f"\n {'─' * 56}")
|
|
121
|
+
print(f" {_('info.messages')}: {msg_count}")
|
|
122
|
+
print(f" {_('info.parts')}: {part_count}")
|
|
123
|
+
print(f" {'─' * 56}")
|
|
124
|
+
|
|
125
|
+
print(f" {_('info.tokens_input')}: {format_tokens(info['tokens_input'])}")
|
|
126
|
+
print(f" {_('info.tokens_output')}: {format_tokens(info['tokens_output'])}")
|
|
127
|
+
print(f" {_('info.tokens_reasoning')}: {format_tokens(info['tokens_reasoning'])}")
|
|
128
|
+
if info["tokens_cache_read"]:
|
|
129
|
+
print(f" {_('info.cache_read')}: {format_tokens(info['tokens_cache_read'])}")
|
|
130
|
+
if info["tokens_cache_write"]:
|
|
131
|
+
print(f" {_('info.cache_write')}: {format_tokens(info['tokens_cache_write'])}")
|
|
132
|
+
total_tokens = (info["tokens_input"] or 0) + (info["tokens_output"] or 0)
|
|
133
|
+
print(f" {_('info.tokens_total')}: {format_tokens(total_tokens)}")
|
|
134
|
+
print(f" {_('info.cost')}: {format_cost(info['cost'])}")
|
|
135
|
+
|
|
136
|
+
if info["summary_files"]:
|
|
137
|
+
print(f"\n {'─' * 56}")
|
|
138
|
+
print(f" {_('info.code_changes')}:")
|
|
139
|
+
print(f" {_('info.files_changed')}: {info['summary_files']}")
|
|
140
|
+
print(f" {_('info.lines_added')}: {info['summary_additions'] or 0}")
|
|
141
|
+
print(f" {_('info.lines_deleted')}: {info['summary_deletions'] or 0}")
|
|
142
|
+
|
|
143
|
+
if todos:
|
|
144
|
+
print(f"\n {'─' * 56}")
|
|
145
|
+
print(f" {_('info.todos')}:")
|
|
146
|
+
status_icon = {"pending": "⏳", "completed": "✅", "cancelled": "❌"}
|
|
147
|
+
for t in todos:
|
|
148
|
+
icon = status_icon.get(t["status"], "📝")
|
|
149
|
+
print(f" {icon} {t['content'][:60]}")
|
|
150
|
+
|
|
151
|
+
print()
|
|
152
|
+
return 0
|
cmd_list.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Команда list: список сессий с фильтрацией и сортировкой."""
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
import db as db_module
|
|
6
|
+
from db import get_session_title, parse_model
|
|
7
|
+
from formatters import print_json, print_table_simple
|
|
8
|
+
from i18n import _
|
|
9
|
+
from utils import format_ts
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def register(subparsers) -> None:
|
|
13
|
+
p = subparsers.add_parser("list", help=_("help.cmd.list"))
|
|
14
|
+
p.add_argument("--limit", type=int, default=25, help="Max results (default 25)")
|
|
15
|
+
p.add_argument("--offset", type=int, default=0, help="Offset")
|
|
16
|
+
p.add_argument("--project", type=str, help="Filter by project ID")
|
|
17
|
+
p.add_argument("--agent", type=str, help="Filter by agent type")
|
|
18
|
+
p.add_argument("--model", type=str, help="Filter by model name")
|
|
19
|
+
p.add_argument(
|
|
20
|
+
"--sort",
|
|
21
|
+
choices=["date", "cost", "tokens", "messages"],
|
|
22
|
+
default="date",
|
|
23
|
+
help="Sort order (default: date)",
|
|
24
|
+
)
|
|
25
|
+
p.add_argument("--json", action="store_true", help="JSON output")
|
|
26
|
+
p.add_argument("--all", action="store_true", help="Show all (no limit)")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def run(args, db) -> Literal[0]:
|
|
30
|
+
conditions = []
|
|
31
|
+
params = []
|
|
32
|
+
|
|
33
|
+
if args.project:
|
|
34
|
+
conditions.append("s.project_id = ?")
|
|
35
|
+
params.append(args.project)
|
|
36
|
+
|
|
37
|
+
if args.agent:
|
|
38
|
+
conditions.append("s.agent = ?")
|
|
39
|
+
params.append(args.agent)
|
|
40
|
+
|
|
41
|
+
if args.model:
|
|
42
|
+
conditions.append("s.model LIKE ?")
|
|
43
|
+
params.append(f"%{args.model}%")
|
|
44
|
+
|
|
45
|
+
where = " AND ".join(conditions) if conditions else "1=1"
|
|
46
|
+
|
|
47
|
+
sort_map = {
|
|
48
|
+
"date": "COALESCE(MAX(m.time_created), s.time_created) DESC",
|
|
49
|
+
"cost": "s.cost DESC",
|
|
50
|
+
"tokens": "(s.tokens_input + s.tokens_output) DESC",
|
|
51
|
+
"messages": "msg_count DESC",
|
|
52
|
+
}
|
|
53
|
+
order = sort_map.get(args.sort, "COALESCE(MAX(m.time_created), s.time_created) DESC")
|
|
54
|
+
|
|
55
|
+
limit = 999999 if args.all else args.limit
|
|
56
|
+
|
|
57
|
+
rows = db.execute(
|
|
58
|
+
f"""
|
|
59
|
+
SELECT s.id, s.title, s.model, s.project_id, s.agent,
|
|
60
|
+
s.cost, s.tokens_input, s.tokens_output,
|
|
61
|
+
MAX(m.time_created) as last_time,
|
|
62
|
+
COUNT(DISTINCT m.id) as msg_count
|
|
63
|
+
FROM session s
|
|
64
|
+
LEFT JOIN message m ON m.session_id = s.id
|
|
65
|
+
WHERE {where}
|
|
66
|
+
GROUP BY s.id
|
|
67
|
+
ORDER BY {order}
|
|
68
|
+
LIMIT ? OFFSET ?
|
|
69
|
+
""",
|
|
70
|
+
(*params, limit, args.offset),
|
|
71
|
+
).fetchall()
|
|
72
|
+
|
|
73
|
+
if args.json:
|
|
74
|
+
data = []
|
|
75
|
+
for r in rows:
|
|
76
|
+
data.append(
|
|
77
|
+
{
|
|
78
|
+
"id": r["id"],
|
|
79
|
+
"title": get_session_title(r),
|
|
80
|
+
"project_id": r["project_id"],
|
|
81
|
+
"model": parse_model(r),
|
|
82
|
+
"agent": r["agent"],
|
|
83
|
+
"cost": r["cost"],
|
|
84
|
+
"tokens_input": r["tokens_input"],
|
|
85
|
+
"tokens_output": r["tokens_output"],
|
|
86
|
+
"messages": r["msg_count"],
|
|
87
|
+
"last_activity": format_ts(r["last_time"]) if r["last_time"] else None,
|
|
88
|
+
}
|
|
89
|
+
)
|
|
90
|
+
print_json(data)
|
|
91
|
+
return 0
|
|
92
|
+
|
|
93
|
+
table_rows = []
|
|
94
|
+
for r in rows:
|
|
95
|
+
project_name = db_module.get_project_name(db, r["project_id"]) if r["project_id"] else "—"
|
|
96
|
+
model_name = parse_model(r)
|
|
97
|
+
last_act = format_ts(r["last_time"], "%Y-%m-%d %H:%M") if r["last_time"] else "—"
|
|
98
|
+
|
|
99
|
+
table_rows.append(
|
|
100
|
+
[
|
|
101
|
+
r["id"][:24],
|
|
102
|
+
get_session_title(r)[:32],
|
|
103
|
+
project_name[:16],
|
|
104
|
+
model_name[:20],
|
|
105
|
+
last_act,
|
|
106
|
+
str(r["msg_count"]),
|
|
107
|
+
f"${r['cost']:.4f}" if r["cost"] else "—",
|
|
108
|
+
]
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
print_table_simple(
|
|
112
|
+
[
|
|
113
|
+
_("list.header.id"),
|
|
114
|
+
_("list.header.title"),
|
|
115
|
+
_("list.header.project"),
|
|
116
|
+
_("list.header.model"),
|
|
117
|
+
_("list.header.activity"),
|
|
118
|
+
_("list.header.messages"),
|
|
119
|
+
_("list.header.cost"),
|
|
120
|
+
],
|
|
121
|
+
table_rows,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
return 0
|
cmd_projects.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Команда projects: список проектов со статистикой."""
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
from i18n import _
|
|
6
|
+
from utils import format_cost, format_tokens, format_ts
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def register(subparsers) -> None:
|
|
10
|
+
p = subparsers.add_parser("projects", help=_("help.cmd.projects"))
|
|
11
|
+
p.add_argument("--json", action="store_true", help="JSON output")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def run(args, db) -> Literal[0]:
|
|
15
|
+
rows = db.execute("""
|
|
16
|
+
SELECT p.id, p.name, p.worktree, p.time_created, p.time_updated,
|
|
17
|
+
COUNT(DISTINCT s.id) as session_count,
|
|
18
|
+
COALESCE(SUM(s.cost), 0) as total_cost,
|
|
19
|
+
COALESCE(SUM(s.tokens_input), 0) as total_ti,
|
|
20
|
+
COALESCE(SUM(s.tokens_output), 0) as total_to,
|
|
21
|
+
COALESCE(SUM(s.tokens_reasoning), 0) as total_tr
|
|
22
|
+
FROM project p
|
|
23
|
+
LEFT JOIN session s ON s.project_id = p.id
|
|
24
|
+
GROUP BY p.id
|
|
25
|
+
ORDER BY p.time_updated DESC
|
|
26
|
+
""").fetchall()
|
|
27
|
+
|
|
28
|
+
if args.json:
|
|
29
|
+
from formatters import print_json
|
|
30
|
+
|
|
31
|
+
data = []
|
|
32
|
+
for r in rows:
|
|
33
|
+
data.append(
|
|
34
|
+
{
|
|
35
|
+
"id": r["id"],
|
|
36
|
+
"name": r["name"],
|
|
37
|
+
"worktree": r["worktree"],
|
|
38
|
+
"time_created": format_ts(r["time_created"]) if r["time_created"] else None,
|
|
39
|
+
"time_updated": format_ts(r["time_updated"]) if r["time_updated"] else None,
|
|
40
|
+
"sessions": r["session_count"],
|
|
41
|
+
"cost": r["total_cost"],
|
|
42
|
+
"tokens_input": r["total_ti"],
|
|
43
|
+
"tokens_output": r["total_to"],
|
|
44
|
+
"tokens_reasoning": r["total_tr"],
|
|
45
|
+
"tokens_total": r["total_ti"] + r["total_to"] + r["total_tr"],
|
|
46
|
+
}
|
|
47
|
+
)
|
|
48
|
+
print_json(data)
|
|
49
|
+
return 0
|
|
50
|
+
|
|
51
|
+
from formatters import print_table_simple
|
|
52
|
+
|
|
53
|
+
table = []
|
|
54
|
+
for r in rows:
|
|
55
|
+
name = r["name"] or "—"
|
|
56
|
+
updated = format_ts(r["time_updated"], "%Y-%m-%d") if r["time_updated"] else "—"
|
|
57
|
+
total_tokens = r["total_ti"] + r["total_to"] + r["total_tr"]
|
|
58
|
+
|
|
59
|
+
table.append(
|
|
60
|
+
[
|
|
61
|
+
r["id"][:12],
|
|
62
|
+
name[:20],
|
|
63
|
+
updated,
|
|
64
|
+
str(r["session_count"]),
|
|
65
|
+
format_tokens(total_tokens),
|
|
66
|
+
format_cost(r["total_cost"]),
|
|
67
|
+
]
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
print_table_simple(
|
|
71
|
+
[
|
|
72
|
+
_("projects.header.id"),
|
|
73
|
+
_("projects.header.name"),
|
|
74
|
+
_("projects.header.activity"),
|
|
75
|
+
_("projects.header.sessions"),
|
|
76
|
+
_("projects.header.tokens"),
|
|
77
|
+
_("projects.header.cost"),
|
|
78
|
+
],
|
|
79
|
+
table,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
return 0
|
cmd_prune.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Команда prune: массовая очистка старых сессий."""
|
|
2
|
+
|
|
3
|
+
# from db import get_session_info, get_session_title, parse_model, \
|
|
4
|
+
# get_message_count, get_part_count
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
from i18n import _
|
|
8
|
+
from utils import confirm, format_ts, parse_time_spec
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def register(subparsers) -> None:
|
|
12
|
+
p = subparsers.add_parser("prune", help=_("help.cmd.prune"))
|
|
13
|
+
p.add_argument("--older-than", type=str, help="Delete sessions older than (30d, 6m, 1y)")
|
|
14
|
+
p.add_argument("--keep-last", type=int, help="Keep N most recent sessions")
|
|
15
|
+
p.add_argument("--project", type=str, help="Limit to project")
|
|
16
|
+
p.add_argument("--dry-run", action="store_true", help="Preview without deleting")
|
|
17
|
+
p.add_argument("--force", "-f", action="store_true", help="Skip confirmation")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def run(args, db) -> Literal[1] | Literal[0]:
|
|
21
|
+
if not args.older_than and not args.keep_last and not args.project:
|
|
22
|
+
print(_("prune.filter_required"))
|
|
23
|
+
print(_("prune.filter_example"))
|
|
24
|
+
return 1
|
|
25
|
+
|
|
26
|
+
conditions = []
|
|
27
|
+
params = []
|
|
28
|
+
|
|
29
|
+
if args.project:
|
|
30
|
+
conditions.append("s.project_id = ?")
|
|
31
|
+
params.append(args.project)
|
|
32
|
+
|
|
33
|
+
if args.older_than:
|
|
34
|
+
cutoff = parse_time_spec(args.older_than)
|
|
35
|
+
if cutoff is None:
|
|
36
|
+
print(_("prune.bad_spec", spec=args.older_than))
|
|
37
|
+
print(_("prune.bad_spec_hint"))
|
|
38
|
+
return 1
|
|
39
|
+
cutoff_ms = int(cutoff.timestamp() * 1000)
|
|
40
|
+
conditions.append("s.time_created < ?")
|
|
41
|
+
params.append(cutoff_ms)
|
|
42
|
+
|
|
43
|
+
exclude_ids = set()
|
|
44
|
+
if args.keep_last:
|
|
45
|
+
inner_where = " AND ".join(conditions) if conditions else "1=1"
|
|
46
|
+
keep_rows = db.execute(
|
|
47
|
+
f"""
|
|
48
|
+
SELECT s.id FROM session s
|
|
49
|
+
WHERE {inner_where}
|
|
50
|
+
ORDER BY s.time_created DESC
|
|
51
|
+
LIMIT ?
|
|
52
|
+
""",
|
|
53
|
+
(*params, args.keep_last),
|
|
54
|
+
).fetchall()
|
|
55
|
+
exclude_ids = {r["id"] for r in keep_rows}
|
|
56
|
+
|
|
57
|
+
where = " AND ".join(conditions) if conditions else "1=1"
|
|
58
|
+
|
|
59
|
+
if exclude_ids:
|
|
60
|
+
placeholders = ",".join("?" for _ in exclude_ids)
|
|
61
|
+
target_rows = db.execute(
|
|
62
|
+
f"""
|
|
63
|
+
SELECT s.id, s.title, s.model, s.time_created, s.cost
|
|
64
|
+
FROM session s
|
|
65
|
+
WHERE {where}
|
|
66
|
+
AND s.id NOT IN ({placeholders})
|
|
67
|
+
ORDER BY s.time_created DESC
|
|
68
|
+
""",
|
|
69
|
+
(*params, *exclude_ids),
|
|
70
|
+
).fetchall()
|
|
71
|
+
else:
|
|
72
|
+
target_rows = db.execute(
|
|
73
|
+
f"""
|
|
74
|
+
SELECT s.id, s.title, s.model, s.time_created, s.cost
|
|
75
|
+
FROM session s
|
|
76
|
+
WHERE {where}
|
|
77
|
+
ORDER BY s.time_created DESC
|
|
78
|
+
""",
|
|
79
|
+
params,
|
|
80
|
+
).fetchall()
|
|
81
|
+
|
|
82
|
+
if not target_rows:
|
|
83
|
+
print(_("prune.none"))
|
|
84
|
+
return 0
|
|
85
|
+
|
|
86
|
+
total_cost = sum(r["cost"] or 0 for r in target_rows)
|
|
87
|
+
total_tokens = sum(
|
|
88
|
+
db.execute(
|
|
89
|
+
"SELECT COALESCE(SUM(tokens_input + tokens_output), 0) FROM session WHERE id = ?",
|
|
90
|
+
(r["id"],),
|
|
91
|
+
).fetchone()[0]
|
|
92
|
+
for r in target_rows
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
print(f"\n {'=' * 55}")
|
|
96
|
+
print(f" {_('prune.header', n=len(target_rows))}")
|
|
97
|
+
print(f" {'=' * 55}")
|
|
98
|
+
|
|
99
|
+
for r in target_rows[:20]:
|
|
100
|
+
created = format_ts(r["time_created"])
|
|
101
|
+
print(f" • {r['id'][:24]} {created} {r['title'] or '—'}")
|
|
102
|
+
|
|
103
|
+
if len(target_rows) > 20:
|
|
104
|
+
print(f" … и ещё {len(target_rows) - 20}")
|
|
105
|
+
|
|
106
|
+
print(f"\n {_('prune.cost_total', cost=f'${total_cost:.4f}')}")
|
|
107
|
+
print(f" {_('prune.tokens_total', n=str(total_tokens))}")
|
|
108
|
+
|
|
109
|
+
if args.dry_run:
|
|
110
|
+
print(f"\n{_('prune.dry_run')}")
|
|
111
|
+
return 0
|
|
112
|
+
|
|
113
|
+
if not args.force and not confirm(_("prune.confirm", n=len(target_rows))):
|
|
114
|
+
print(_("canceled"))
|
|
115
|
+
return 0
|
|
116
|
+
|
|
117
|
+
ids_to_delete = [r["id"] for r in target_rows]
|
|
118
|
+
placeholders = ",".join("?" for _ in ids_to_delete)
|
|
119
|
+
|
|
120
|
+
db.execute(f"DELETE FROM part WHERE session_id IN ({placeholders})", ids_to_delete)
|
|
121
|
+
db.execute(f"DELETE FROM message WHERE session_id IN ({placeholders})", ids_to_delete)
|
|
122
|
+
db.execute(f"DELETE FROM todo WHERE session_id IN ({placeholders})", ids_to_delete)
|
|
123
|
+
db.execute(f"DELETE FROM session WHERE id IN ({placeholders})", ids_to_delete)
|
|
124
|
+
db.commit()
|
|
125
|
+
|
|
126
|
+
print(f"\n{_('prune.done', n=len(ids_to_delete))}")
|
|
127
|
+
return 0
|