opencode-db 0.5.0__tar.gz → 0.6.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {opencode_db-0.5.0/opencode_db.egg-info → opencode_db-0.6.0}/PKG-INFO +1 -1
- {opencode_db-0.5.0 → opencode_db-0.6.0}/README.md +25 -24
- {opencode_db-0.5.0 → opencode_db-0.6.0}/cmd_costs.py +17 -2
- opencode_db-0.6.0/cmd_delete.py +343 -0
- {opencode_db-0.5.0 → opencode_db-0.6.0}/cmd_export.py +17 -2
- {opencode_db-0.5.0 → opencode_db-0.6.0}/cmd_help.py +28 -2
- {opencode_db-0.5.0 → opencode_db-0.6.0}/cmd_info.py +14 -2
- {opencode_db-0.5.0 → opencode_db-0.6.0}/cmd_list.py +16 -2
- {opencode_db-0.5.0 → opencode_db-0.6.0}/cmd_projects.py +13 -2
- opencode_db-0.6.0/cmd_prune.py +32 -0
- {opencode_db-0.5.0 → opencode_db-0.6.0}/cmd_search.py +14 -2
- {opencode_db-0.5.0 → opencode_db-0.6.0}/cmd_stats.py +13 -2
- {opencode_db-0.5.0 → opencode_db-0.6.0}/cmd_todos.py +16 -2
- {opencode_db-0.5.0 → opencode_db-0.6.0}/cmd_tree.py +15 -2
- {opencode_db-0.5.0 → opencode_db-0.6.0}/cmd_vacuum.py +13 -2
- {opencode_db-0.5.0 → opencode_db-0.6.0}/i18n.py +74 -4
- {opencode_db-0.5.0 → opencode_db-0.6.0/opencode_db.egg-info}/PKG-INFO +1 -1
- {opencode_db-0.5.0 → opencode_db-0.6.0}/pyproject.toml +1 -1
- {opencode_db-0.5.0 → opencode_db-0.6.0}/tests/test_commands.py +1 -1
- {opencode_db-0.5.0 → opencode_db-0.6.0}/utils.py +34 -0
- opencode_db-0.5.0/cmd_delete.py +0 -61
- opencode_db-0.5.0/cmd_prune.py +0 -127
- {opencode_db-0.5.0 → opencode_db-0.6.0}/LICENSE +0 -0
- {opencode_db-0.5.0 → opencode_db-0.6.0}/README_PYPI.md +0 -0
- {opencode_db-0.5.0 → opencode_db-0.6.0}/cli.py +0 -0
- {opencode_db-0.5.0 → opencode_db-0.6.0}/commands/__init__.py +0 -0
- {opencode_db-0.5.0 → opencode_db-0.6.0}/config.py +0 -0
- {opencode_db-0.5.0 → opencode_db-0.6.0}/db.py +0 -0
- {opencode_db-0.5.0 → opencode_db-0.6.0}/formatters.py +0 -0
- {opencode_db-0.5.0 → opencode_db-0.6.0}/opencode_db.egg-info/SOURCES.txt +0 -0
- {opencode_db-0.5.0 → opencode_db-0.6.0}/opencode_db.egg-info/dependency_links.txt +0 -0
- {opencode_db-0.5.0 → opencode_db-0.6.0}/opencode_db.egg-info/entry_points.txt +0 -0
- {opencode_db-0.5.0 → opencode_db-0.6.0}/opencode_db.egg-info/top_level.txt +0 -0
- {opencode_db-0.5.0 → opencode_db-0.6.0}/setup.cfg +0 -0
- {opencode_db-0.5.0 → opencode_db-0.6.0}/tests/test_db.py +0 -0
- {opencode_db-0.5.0 → opencode_db-0.6.0}/tests/test_formatters.py +0 -0
- {opencode_db-0.5.0 → opencode_db-0.6.0}/tests/test_i18n.py +0 -0
- {opencode_db-0.5.0 → opencode_db-0.6.0}/tests/test_utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: opencode-db
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
4
|
Summary: OpenCode database CLI manager — browse, export, analyze, clean up sessions
|
|
5
5
|
Project-URL: Homepage, https://github.com/VasilevNStas/opencode-db
|
|
6
6
|
Project-URL: Source, https://github.com/VasilevNStas/opencode-db
|
|
@@ -210,20 +210,35 @@ If the project directory contains `.obsidian`, a `log.md` file is automatically
|
|
|
210
210
|
|
|
211
211
|
---
|
|
212
212
|
|
|
213
|
-
### `delete` — delete
|
|
213
|
+
### `delete` — delete sessions
|
|
214
214
|
|
|
215
|
-
Removes
|
|
215
|
+
Removes sessions and all related data (messages, parts, todos). Supports single deletion by ID or bulk deletion with filters.
|
|
216
216
|
|
|
217
217
|
```bash
|
|
218
|
-
opencode-db delete ses_1c9d
|
|
219
|
-
opencode-db delete ses_1c9d
|
|
220
|
-
opencode-db delete ses_1c9d --
|
|
218
|
+
opencode-db delete ses_1c9d # single session with confirmation
|
|
219
|
+
opencode-db delete ses_1c9d --force # single session, no confirmation
|
|
220
|
+
opencode-db delete ses_1c9d --dry-run # preview what would be deleted
|
|
221
|
+
opencode-db delete --older-than 90d # sessions older than 90 days
|
|
222
|
+
opencode-db delete --older-than 6m --dry-run # preview sessions older than 6 months
|
|
223
|
+
opencode-db delete --keep-last 30 # keep 30 most recent, delete the rest
|
|
224
|
+
opencode-db delete --before 2026-05-20 # sessions before a specific date
|
|
225
|
+
opencode-db delete --after 2026-01-01 # sessions after a specific date
|
|
226
|
+
opencode-db delete --project proj_xxx # sessions in a specific project
|
|
227
|
+
opencode-db delete --older-than 90d --keep-last 20 # combined filters
|
|
228
|
+
opencode-db delete --interactive # choose from a list interactively
|
|
221
229
|
```
|
|
222
230
|
|
|
223
231
|
| Flag | Description |
|
|
224
232
|
|---|---|
|
|
233
|
+
| `session_id` | Session ID (optional with `--interactive`) |
|
|
234
|
+
| `--older-than` | Delete sessions older than (e.g. `30d`, `6m`, `1y`) |
|
|
235
|
+
| `--before` | Delete sessions before date (`YYYY-MM-DD`) |
|
|
236
|
+
| `--after` | Delete sessions after date (`YYYY-MM-DD`) |
|
|
237
|
+
| `--keep-last` | Keep N most recent, delete the rest |
|
|
238
|
+
| `--project` | Limit to a specific project |
|
|
225
239
|
| `--dry-run` | Only show what would be deleted |
|
|
226
240
|
| `--force`, `-f` | Skip confirmation prompt |
|
|
241
|
+
| `--interactive` | Pick sessions from a list |
|
|
227
242
|
|
|
228
243
|
---
|
|
229
244
|
|
|
@@ -252,31 +267,17 @@ Costs are taken directly from the database (`session.cost`), which stores the ac
|
|
|
252
267
|
|
|
253
268
|
---
|
|
254
269
|
|
|
255
|
-
### `prune` —
|
|
270
|
+
### `prune` — alias for `delete` (bulk cleanup)
|
|
256
271
|
|
|
257
|
-
|
|
272
|
+
Alias for `delete` with the same flags: `--older-than`, `--keep-last`, `--project`, `--dry-run`, `--force`.
|
|
258
273
|
|
|
259
274
|
```bash
|
|
260
|
-
opencode-db prune --older-than
|
|
261
|
-
opencode-db prune --
|
|
262
|
-
opencode-db prune --older-than 6m # older than 6 months
|
|
263
|
-
opencode-db prune --older-than 1y # older than 1 year
|
|
264
|
-
opencode-db prune --keep-last 20 # keep the 20 most recent
|
|
265
|
-
opencode-db prune --older-than 30d --keep-last 10 # combined filters
|
|
266
|
-
opencode-db prune --project proj_xxx # per project
|
|
267
|
-
opencode-db prune --older-than 30d --force # skip confirmation
|
|
275
|
+
opencode-db prune --older-than 90d # same as opencode-db delete --older-than 90d
|
|
276
|
+
opencode-db prune --keep-last 20 # same as opencode-db delete --keep-last 20
|
|
268
277
|
```
|
|
269
278
|
|
|
270
279
|
Time spec formats: `30d` (days), `6m` (months), `1y` (years).
|
|
271
280
|
|
|
272
|
-
| Flag | Description |
|
|
273
|
-
|---|---|
|
|
274
|
-
| `--older-than SPEC` | Delete sessions older than the given duration |
|
|
275
|
-
| `--keep-last N` | Keep N most recent sessions |
|
|
276
|
-
| `--project ID` | Limit to a specific project |
|
|
277
|
-
| `--dry-run` | Only show what would be deleted |
|
|
278
|
-
| `--force`, `-f` | Skip confirmation prompt |
|
|
279
|
-
|
|
280
281
|
---
|
|
281
282
|
|
|
282
283
|
### `search` — search across messages
|
|
@@ -409,7 +410,7 @@ Example output:
|
|
|
409
410
|
|
|
410
411
|
```
|
|
411
412
|
╔══════════════════════════════════════════════════════════╗
|
|
412
|
-
║ opencode-db — справка
|
|
413
|
+
║ opencode-db — справка ║
|
|
413
414
|
╚══════════════════════════════════════════════════════════╝
|
|
414
415
|
|
|
415
416
|
Использование: opencode-db <команда> [флаги]
|
|
@@ -3,13 +3,28 @@
|
|
|
3
3
|
Стоимость берётся напрямую из поля session.cost БД OpenCode.
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
|
+
import argparse
|
|
7
|
+
|
|
6
8
|
from db import SessionError, get_session_info, get_session_title, parse_model
|
|
7
9
|
from i18n import _
|
|
8
|
-
from utils import format_cost, format_tokens
|
|
10
|
+
from utils import build_help_epilog, format_cost, format_tokens
|
|
11
|
+
|
|
12
|
+
_COSTS_EXAMPLES = [
|
|
13
|
+
("", "help.costs.e0"),
|
|
14
|
+
("<session_id>", "help.costs.e1"),
|
|
15
|
+
("--total", "help.costs.e2"),
|
|
16
|
+
("--project proj_xxx", "help.costs.e3"),
|
|
17
|
+
("--json", "help.costs.e4"),
|
|
18
|
+
]
|
|
9
19
|
|
|
10
20
|
|
|
11
21
|
def register(subparsers) -> None:
|
|
12
|
-
p = subparsers.add_parser(
|
|
22
|
+
p = subparsers.add_parser(
|
|
23
|
+
"costs",
|
|
24
|
+
help=_("help.cmd.costs"),
|
|
25
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
26
|
+
epilog=build_help_epilog("costs", _COSTS_EXAMPLES),
|
|
27
|
+
)
|
|
13
28
|
p.add_argument("session_id", nargs="?", help="Session ID")
|
|
14
29
|
p.add_argument("--project", type=str, help="Filter by project")
|
|
15
30
|
p.add_argument("--total", action="store_true", help="Total across all sessions")
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
"""Команда delete: удаление сессии или массовая очистка."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
|
|
5
|
+
from db import (
|
|
6
|
+
SessionError,
|
|
7
|
+
get_message_count,
|
|
8
|
+
get_part_count,
|
|
9
|
+
get_recent_sessions,
|
|
10
|
+
get_session_info,
|
|
11
|
+
get_session_title,
|
|
12
|
+
parse_model,
|
|
13
|
+
)
|
|
14
|
+
from i18n import _
|
|
15
|
+
from utils import build_help_epilog, confirm, format_cost, format_ts, parse_date, parse_time_spec
|
|
16
|
+
|
|
17
|
+
_DELETE_EXAMPLES = [
|
|
18
|
+
("<session_id>", "help.delete.e0"),
|
|
19
|
+
("<session_id> --dry-run", "help.delete.e1"),
|
|
20
|
+
("<session_id> --force", "help.delete.e2"),
|
|
21
|
+
("--older-than 90d", "help.delete.e3"),
|
|
22
|
+
("--keep-last 30", "help.delete.e4"),
|
|
23
|
+
("--before 2026-05-20", "help.delete.e5"),
|
|
24
|
+
("--interactive", "help.delete.e6"),
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def register(subparsers) -> None:
|
|
29
|
+
p = subparsers.add_parser(
|
|
30
|
+
"delete",
|
|
31
|
+
help=_("help.cmd.delete"),
|
|
32
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
33
|
+
epilog=build_help_epilog("delete", _DELETE_EXAMPLES),
|
|
34
|
+
)
|
|
35
|
+
p.add_argument("session_id", nargs="?", help="Session ID (optional with --interactive)")
|
|
36
|
+
p.add_argument("--older-than", type=str, help="Delete sessions older than (30d, 6m, 1y)")
|
|
37
|
+
p.add_argument("--before", type=str, help="Delete sessions before date (YYYY-MM-DD)")
|
|
38
|
+
p.add_argument("--after", type=str, help="Delete sessions after date (YYYY-MM-DD)")
|
|
39
|
+
p.add_argument("--keep-last", type=int, help="Keep N most recent sessions")
|
|
40
|
+
p.add_argument("--project", type=str, help="Limit to project")
|
|
41
|
+
p.add_argument("--dry-run", action="store_true", help="Preview without deleting")
|
|
42
|
+
p.add_argument("--force", "-f", action="store_true", help="Skip confirmation")
|
|
43
|
+
p.add_argument("--interactive", action="store_true", help="Choose sessions from a list")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _delete_single(db, session_id, dry_run, force) -> int:
|
|
47
|
+
"""Удаление одной сессии по ID."""
|
|
48
|
+
try:
|
|
49
|
+
info = get_session_info(db, session_id)
|
|
50
|
+
except SessionError as e:
|
|
51
|
+
print(e.message)
|
|
52
|
+
return 1
|
|
53
|
+
sid = info["id"]
|
|
54
|
+
title = get_session_title(info)
|
|
55
|
+
model = parse_model(info)
|
|
56
|
+
msg_count = get_message_count(db, sid)
|
|
57
|
+
part_count = get_part_count(db, sid)
|
|
58
|
+
created = format_ts(info["time_created"])
|
|
59
|
+
|
|
60
|
+
print(f"\n{'=' * 50}")
|
|
61
|
+
print(f" {_('delete.header')}")
|
|
62
|
+
print(f"{'=' * 50}")
|
|
63
|
+
print(f" {_('delete.id')}: {sid}")
|
|
64
|
+
print(f" {_('delete.title')}: {title}")
|
|
65
|
+
print(f" {_('delete.model')}: {model}")
|
|
66
|
+
print(f" {_('delete.created')}: {created}")
|
|
67
|
+
print(f" {_('delete.messages')}: {msg_count}")
|
|
68
|
+
print(f" {_('delete.parts')}: {part_count}")
|
|
69
|
+
print(f" {_('delete.cost')}: {format_cost(info['cost'])}")
|
|
70
|
+
|
|
71
|
+
if dry_run:
|
|
72
|
+
print(f"\n{_('delete.dry_run')}")
|
|
73
|
+
print(f" {_('delete.dry_run_hint')}")
|
|
74
|
+
return 0
|
|
75
|
+
|
|
76
|
+
if not force and not confirm(_("delete.confirm", id=sid[:24])):
|
|
77
|
+
print(_("canceled"))
|
|
78
|
+
return 0
|
|
79
|
+
|
|
80
|
+
db.execute("DELETE FROM session WHERE id = ?", (sid,))
|
|
81
|
+
db.commit()
|
|
82
|
+
|
|
83
|
+
print(f"\n{_('delete.done', id=sid[:24])}")
|
|
84
|
+
print(_("delete.done_detail", msg=msg_count, parts=part_count))
|
|
85
|
+
return 0
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _build_mass_conditions(args):
|
|
89
|
+
"""Строит условия WHERE для массового удаления."""
|
|
90
|
+
conditions = []
|
|
91
|
+
params = []
|
|
92
|
+
project = _a(args, "project")
|
|
93
|
+
older_than = _a(args, "older_than")
|
|
94
|
+
before = _a(args, "before")
|
|
95
|
+
after = _a(args, "after")
|
|
96
|
+
|
|
97
|
+
if project:
|
|
98
|
+
conditions.append("s.project_id = ?")
|
|
99
|
+
params.append(project)
|
|
100
|
+
|
|
101
|
+
if older_than:
|
|
102
|
+
cutoff = parse_time_spec(older_than)
|
|
103
|
+
if cutoff is None:
|
|
104
|
+
return None, None, _("delete.bad_spec", spec=older_than)
|
|
105
|
+
conditions.append("s.time_created < ?")
|
|
106
|
+
params.append(int(cutoff.timestamp() * 1000))
|
|
107
|
+
|
|
108
|
+
if before:
|
|
109
|
+
dt = parse_date(before)
|
|
110
|
+
if dt is None:
|
|
111
|
+
return None, None, _("delete.bad_date", date=before)
|
|
112
|
+
conditions.append("s.time_created < ?")
|
|
113
|
+
params.append(int(dt.timestamp() * 1000))
|
|
114
|
+
|
|
115
|
+
if after:
|
|
116
|
+
dt = parse_date(after)
|
|
117
|
+
if dt is None:
|
|
118
|
+
return None, None, _("delete.bad_date", date=after)
|
|
119
|
+
conditions.append("s.time_created > ?")
|
|
120
|
+
params.append(int(dt.timestamp() * 1000))
|
|
121
|
+
|
|
122
|
+
return conditions, params, None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _collect_mass_targets(db, conditions, params, keep_last):
|
|
126
|
+
"""Собирает список сессий для массового удаления."""
|
|
127
|
+
exclude_ids = set()
|
|
128
|
+
if keep_last:
|
|
129
|
+
inner_where = " AND ".join(conditions) if conditions else "1=1"
|
|
130
|
+
keep_rows = db.execute(
|
|
131
|
+
f"""
|
|
132
|
+
SELECT s.id FROM session s
|
|
133
|
+
WHERE {inner_where}
|
|
134
|
+
ORDER BY s.time_created DESC
|
|
135
|
+
LIMIT ?
|
|
136
|
+
""",
|
|
137
|
+
(*params, keep_last),
|
|
138
|
+
).fetchall()
|
|
139
|
+
exclude_ids = {r["id"] for r in keep_rows}
|
|
140
|
+
|
|
141
|
+
where = " AND ".join(conditions) if conditions else "1=1"
|
|
142
|
+
|
|
143
|
+
if exclude_ids:
|
|
144
|
+
placeholders = ",".join("?" for _ in exclude_ids)
|
|
145
|
+
rows = db.execute(
|
|
146
|
+
f"""
|
|
147
|
+
SELECT s.id, s.title, s.model, s.time_created, s.cost
|
|
148
|
+
FROM session s
|
|
149
|
+
WHERE {where}
|
|
150
|
+
AND s.id NOT IN ({placeholders})
|
|
151
|
+
ORDER BY s.time_created DESC
|
|
152
|
+
""",
|
|
153
|
+
(*params, *exclude_ids),
|
|
154
|
+
).fetchall()
|
|
155
|
+
else:
|
|
156
|
+
rows = db.execute(
|
|
157
|
+
f"""
|
|
158
|
+
SELECT s.id, s.title, s.model, s.time_created, s.cost
|
|
159
|
+
FROM session s
|
|
160
|
+
WHERE {where}
|
|
161
|
+
ORDER BY s.time_created DESC
|
|
162
|
+
""",
|
|
163
|
+
params,
|
|
164
|
+
).fetchall()
|
|
165
|
+
|
|
166
|
+
return rows
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _delete_mass(db, args) -> int:
|
|
170
|
+
"""Массовое удаление сессий."""
|
|
171
|
+
keep_last = _a(args, "keep_last")
|
|
172
|
+
dry_run = _a(args, "dry_run", False)
|
|
173
|
+
force = _a(args, "force", False)
|
|
174
|
+
|
|
175
|
+
conditions, params, err = _build_mass_conditions(args)
|
|
176
|
+
if err:
|
|
177
|
+
print(err)
|
|
178
|
+
return 1
|
|
179
|
+
|
|
180
|
+
if not conditions and not keep_last:
|
|
181
|
+
print(_("delete.filter_required"))
|
|
182
|
+
return 1
|
|
183
|
+
|
|
184
|
+
target_rows = _collect_mass_targets(db, conditions, params, keep_last)
|
|
185
|
+
|
|
186
|
+
if not target_rows:
|
|
187
|
+
print(_("delete.none"))
|
|
188
|
+
return 0
|
|
189
|
+
|
|
190
|
+
total_cost = sum(r["cost"] or 0 for r in target_rows)
|
|
191
|
+
total_tokens = sum(
|
|
192
|
+
db.execute(
|
|
193
|
+
"SELECT COALESCE(SUM(tokens_input + tokens_output), 0) FROM session WHERE id = ?",
|
|
194
|
+
(r["id"],),
|
|
195
|
+
).fetchone()[0]
|
|
196
|
+
for r in target_rows
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
print(f"\n {'=' * 55}")
|
|
200
|
+
print(f" {_('delete.mass_header', n=len(target_rows))}")
|
|
201
|
+
print(f" {'=' * 55}")
|
|
202
|
+
|
|
203
|
+
for r in target_rows[:20]:
|
|
204
|
+
created = format_ts(r["time_created"])
|
|
205
|
+
print(f" • {r['id'][:24]} {created} {r['title'] or '—'}")
|
|
206
|
+
|
|
207
|
+
if len(target_rows) > 20:
|
|
208
|
+
print(f" … и ещё {len(target_rows) - 20}")
|
|
209
|
+
|
|
210
|
+
print(f"\n {_('delete.cost_total', cost=f'${total_cost:.4f}')}")
|
|
211
|
+
print(f" {_('delete.tokens_total', n=str(total_tokens))}")
|
|
212
|
+
|
|
213
|
+
if dry_run:
|
|
214
|
+
print(f"\n{_('delete.dry_run')}")
|
|
215
|
+
return 0
|
|
216
|
+
|
|
217
|
+
if not force and not confirm(_("delete.mass_confirm", n=len(target_rows))):
|
|
218
|
+
print(_("canceled"))
|
|
219
|
+
return 0
|
|
220
|
+
|
|
221
|
+
ids = [r["id"] for r in target_rows]
|
|
222
|
+
placeholders = ",".join("?" for _ in ids)
|
|
223
|
+
|
|
224
|
+
db.execute(f"DELETE FROM part WHERE session_id IN ({placeholders})", ids)
|
|
225
|
+
db.execute(f"DELETE FROM message WHERE session_id IN ({placeholders})", ids)
|
|
226
|
+
db.execute(f"DELETE FROM todo WHERE session_id IN ({placeholders})", ids)
|
|
227
|
+
db.execute(f"DELETE FROM session WHERE id IN ({placeholders})", ids)
|
|
228
|
+
db.commit()
|
|
229
|
+
|
|
230
|
+
print(f"\n{_('delete.done_mass', n=len(ids))}")
|
|
231
|
+
return 0
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _delete_interactive(db, args) -> int:
|
|
235
|
+
"""Интерактивное удаление — выбор из списка последних сессий."""
|
|
236
|
+
sessions = get_recent_sessions(db, limit=20)
|
|
237
|
+
|
|
238
|
+
if not sessions:
|
|
239
|
+
print(_("delete.no_sessions"))
|
|
240
|
+
return 1
|
|
241
|
+
|
|
242
|
+
print(f"\n {_('delete.interactive_header')}")
|
|
243
|
+
print(f" {'─' * 55}")
|
|
244
|
+
for i, s in enumerate(sessions, 1):
|
|
245
|
+
title = get_session_title(s)
|
|
246
|
+
project = s.get("project_id", "") or ""
|
|
247
|
+
print(f" {i:2d}) {s['id'][:24]} {title[:40]:40s} {project[:20]}")
|
|
248
|
+
|
|
249
|
+
print()
|
|
250
|
+
choices = input(_("delete.interactive_prompt")).strip()
|
|
251
|
+
|
|
252
|
+
if not choices:
|
|
253
|
+
print(_("canceled"))
|
|
254
|
+
return 0
|
|
255
|
+
|
|
256
|
+
selected = _parse_choices(choices, len(sessions))
|
|
257
|
+
if not selected:
|
|
258
|
+
return 1
|
|
259
|
+
|
|
260
|
+
target_rows = [sessions[i - 1] for i in selected]
|
|
261
|
+
total_cost = sum(r["cost"] or 0 for r in target_rows)
|
|
262
|
+
|
|
263
|
+
print(f"\n {'=' * 55}")
|
|
264
|
+
print(f" {_('delete.mass_header', n=len(target_rows))}")
|
|
265
|
+
print(f" {'=' * 55}")
|
|
266
|
+
for r in target_rows:
|
|
267
|
+
created = format_ts(r["time_created"])
|
|
268
|
+
print(f" • {r['id'][:24]} {created} {r['title'] or '—'}")
|
|
269
|
+
|
|
270
|
+
dry_run = _a(args, "dry_run", False)
|
|
271
|
+
force = _a(args, "force", False)
|
|
272
|
+
print(f"\n {_('delete.cost_total', cost=f'${total_cost:.4f}')}")
|
|
273
|
+
|
|
274
|
+
if dry_run:
|
|
275
|
+
print(f"\n{_('delete.dry_run')}")
|
|
276
|
+
return 0
|
|
277
|
+
|
|
278
|
+
if not force and not confirm(_("delete.mass_confirm", n=len(target_rows))):
|
|
279
|
+
print(_("canceled"))
|
|
280
|
+
return 0
|
|
281
|
+
|
|
282
|
+
ids = [r["id"] for r in target_rows]
|
|
283
|
+
placeholders = ",".join("?" for _ in ids)
|
|
284
|
+
|
|
285
|
+
db.execute(f"DELETE FROM part WHERE session_id IN ({placeholders})", ids)
|
|
286
|
+
db.execute(f"DELETE FROM message WHERE session_id IN ({placeholders})", ids)
|
|
287
|
+
db.execute(f"DELETE FROM todo WHERE session_id IN ({placeholders})", ids)
|
|
288
|
+
db.execute(f"DELETE FROM session WHERE id IN ({placeholders})", ids)
|
|
289
|
+
db.commit()
|
|
290
|
+
|
|
291
|
+
print(f"\n{_('delete.done_mass', n=len(ids))}")
|
|
292
|
+
return 0
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _parse_choices(text: str, max_n: int) -> list[int]:
|
|
296
|
+
"""Парсит ввод пользователя вида '1,3-5,7' в список индексов."""
|
|
297
|
+
result = set()
|
|
298
|
+
for part in text.replace(" ", "").split(","):
|
|
299
|
+
if not part:
|
|
300
|
+
continue
|
|
301
|
+
if "-" in part:
|
|
302
|
+
try:
|
|
303
|
+
a, b = part.split("-", 1)
|
|
304
|
+
for i in range(int(a), int(b) + 1):
|
|
305
|
+
if 1 <= i <= max_n:
|
|
306
|
+
result.add(i)
|
|
307
|
+
except ValueError:
|
|
308
|
+
continue
|
|
309
|
+
else:
|
|
310
|
+
try:
|
|
311
|
+
i = int(part)
|
|
312
|
+
if 1 <= i <= max_n:
|
|
313
|
+
result.add(i)
|
|
314
|
+
except ValueError:
|
|
315
|
+
continue
|
|
316
|
+
return sorted(result)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _a(args, name: str, default=None):
|
|
320
|
+
"""getattr с дефолтом — для совместимости Namespace из разных парсеров."""
|
|
321
|
+
return getattr(args, name, default)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def run(args, db) -> int:
|
|
325
|
+
if _a(args, "interactive", False):
|
|
326
|
+
return _delete_interactive(db, args)
|
|
327
|
+
|
|
328
|
+
if _a(args, "session_id"):
|
|
329
|
+
session_id = _a(args, "session_id")
|
|
330
|
+
dry_run = _a(args, "dry_run", False)
|
|
331
|
+
force = _a(args, "force", False)
|
|
332
|
+
return _delete_single(db, session_id, dry_run, force)
|
|
333
|
+
|
|
334
|
+
older_than = _a(args, "older_than")
|
|
335
|
+
before = _a(args, "before")
|
|
336
|
+
after = _a(args, "after")
|
|
337
|
+
keep_last = _a(args, "keep_last")
|
|
338
|
+
project = _a(args, "project")
|
|
339
|
+
if older_than or before or after or keep_last or project:
|
|
340
|
+
return _delete_mass(db, args)
|
|
341
|
+
|
|
342
|
+
print(_("delete.usage"))
|
|
343
|
+
return 1
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
- export (без аргументов) — интерактивный выбор из списка
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
|
+
import argparse
|
|
9
10
|
import os
|
|
10
11
|
|
|
11
12
|
from db import (
|
|
@@ -19,11 +20,25 @@ from db import (
|
|
|
19
20
|
)
|
|
20
21
|
from formatters import collect_snapshot, format_part_to_md, update_log
|
|
21
22
|
from i18n import _
|
|
22
|
-
from utils import format_cost, format_ts, format_ts_short
|
|
23
|
+
from utils import build_help_epilog, format_cost, format_ts, format_ts_short
|
|
24
|
+
|
|
25
|
+
_EXPORT_EXAMPLES = [
|
|
26
|
+
("<session_id>", "help.export.e0"),
|
|
27
|
+
("--latest", "help.export.e1"),
|
|
28
|
+
("", "help.export.e2"),
|
|
29
|
+
("--full", "help.export.e3"),
|
|
30
|
+
("--force", "help.export.e4"),
|
|
31
|
+
('--note "text"', "help.export.e5"),
|
|
32
|
+
]
|
|
23
33
|
|
|
24
34
|
|
|
25
35
|
def register(subparsers) -> None:
|
|
26
|
-
p = subparsers.add_parser(
|
|
36
|
+
p = subparsers.add_parser(
|
|
37
|
+
"export",
|
|
38
|
+
help=_("help.cmd.export"),
|
|
39
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
40
|
+
epilog=build_help_epilog("export", _EXPORT_EXAMPLES),
|
|
41
|
+
)
|
|
27
42
|
p.add_argument(
|
|
28
43
|
"session_id",
|
|
29
44
|
nargs="?",
|
|
@@ -1,17 +1,37 @@
|
|
|
1
1
|
"""Команда help: подробная справка по opencode-db."""
|
|
2
2
|
|
|
3
|
+
import argparse
|
|
3
4
|
from typing import Literal
|
|
4
5
|
|
|
5
6
|
from i18n import _
|
|
7
|
+
from utils import build_help_epilog
|
|
8
|
+
|
|
9
|
+
_HELP_EXAMPLES = [
|
|
10
|
+
("", "help.help.e0"),
|
|
11
|
+
("<command>", "help.help.e1"),
|
|
12
|
+
]
|
|
6
13
|
|
|
7
14
|
|
|
8
15
|
def register(subparsers) -> None:
|
|
9
|
-
p = subparsers.add_parser(
|
|
16
|
+
p = subparsers.add_parser(
|
|
17
|
+
"help",
|
|
18
|
+
help=_("help.cmd.help"),
|
|
19
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
20
|
+
epilog=build_help_epilog("help", _HELP_EXAMPLES),
|
|
21
|
+
)
|
|
10
22
|
p.add_argument("topic", nargs="?", help="Command name")
|
|
11
23
|
p.add_argument("--json", action="store_true", help="JSON output")
|
|
12
24
|
|
|
13
25
|
|
|
14
26
|
_COMMANDS = [
|
|
27
|
+
(
|
|
28
|
+
"help",
|
|
29
|
+
"help.cmd.help",
|
|
30
|
+
[
|
|
31
|
+
("", "help.help.e0"),
|
|
32
|
+
("<command>", "help.help.e1"),
|
|
33
|
+
],
|
|
34
|
+
),
|
|
15
35
|
(
|
|
16
36
|
"list",
|
|
17
37
|
"help.cmd.list",
|
|
@@ -173,8 +193,14 @@ def _print_command_help(cmd_name) -> Literal[1] | Literal[0]:
|
|
|
173
193
|
|
|
174
194
|
desc_key, examples = entry
|
|
175
195
|
print()
|
|
176
|
-
print(
|
|
196
|
+
print(f" {'─' * 55}")
|
|
197
|
+
print(f" {_('help.cmd_header', cmd=cmd_name, desc=_(desc_key))}")
|
|
198
|
+
print(f" {'─' * 55}")
|
|
199
|
+
print()
|
|
200
|
+
print(f" {_('help.flags_header', cmd=cmd_name)}")
|
|
201
|
+
print(f" opencode-db {cmd_name} --help")
|
|
177
202
|
print()
|
|
203
|
+
print(f" {_('help.usage_header', cmd=cmd_name)}")
|
|
178
204
|
for flag, example_key in examples:
|
|
179
205
|
example_desc = _(example_key)
|
|
180
206
|
if flag:
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
"""Команда info: детальная информация о сессии."""
|
|
2
2
|
|
|
3
|
+
import argparse
|
|
4
|
+
|
|
3
5
|
from db import (
|
|
4
6
|
SessionError,
|
|
5
7
|
get_children_sessions,
|
|
@@ -13,11 +15,21 @@ from db import (
|
|
|
13
15
|
parse_model,
|
|
14
16
|
)
|
|
15
17
|
from i18n import _
|
|
16
|
-
from utils import format_cost, format_tokens, format_ts
|
|
18
|
+
from utils import build_help_epilog, format_cost, format_tokens, format_ts
|
|
19
|
+
|
|
20
|
+
_INFO_EXAMPLES = [
|
|
21
|
+
("<session_id>", "help.info.e0"),
|
|
22
|
+
("<session_id> --json", "help.info.e1"),
|
|
23
|
+
]
|
|
17
24
|
|
|
18
25
|
|
|
19
26
|
def register(subparsers) -> None:
|
|
20
|
-
p = subparsers.add_parser(
|
|
27
|
+
p = subparsers.add_parser(
|
|
28
|
+
"info",
|
|
29
|
+
help=_("help.cmd.info"),
|
|
30
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
31
|
+
epilog=build_help_epilog("info", _INFO_EXAMPLES),
|
|
32
|
+
)
|
|
21
33
|
p.add_argument("session_id", help="Session ID")
|
|
22
34
|
p.add_argument("--json", action="store_true", help="JSON output")
|
|
23
35
|
|
|
@@ -1,16 +1,30 @@
|
|
|
1
1
|
"""Команда list: список сессий с фильтрацией и сортировкой."""
|
|
2
2
|
|
|
3
|
+
import argparse
|
|
3
4
|
from typing import Literal
|
|
4
5
|
|
|
5
6
|
import db as db_module
|
|
6
7
|
from db import get_session_title, parse_model
|
|
7
8
|
from formatters import print_json, print_table_simple
|
|
8
9
|
from i18n import _
|
|
9
|
-
from utils import format_ts
|
|
10
|
+
from utils import build_help_epilog, format_ts
|
|
11
|
+
|
|
12
|
+
_LIST_EXAMPLES = [
|
|
13
|
+
("", "help.list.e0"),
|
|
14
|
+
("--limit 10", "help.list.e1"),
|
|
15
|
+
("--sort cost", "help.list.e2"),
|
|
16
|
+
("--project proj_xxx", "help.list.e3"),
|
|
17
|
+
("--json", "help.list.e4"),
|
|
18
|
+
]
|
|
10
19
|
|
|
11
20
|
|
|
12
21
|
def register(subparsers) -> None:
|
|
13
|
-
p = subparsers.add_parser(
|
|
22
|
+
p = subparsers.add_parser(
|
|
23
|
+
"list",
|
|
24
|
+
help=_("help.cmd.list"),
|
|
25
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
26
|
+
epilog=build_help_epilog("list", _LIST_EXAMPLES),
|
|
27
|
+
)
|
|
14
28
|
p.add_argument("--limit", type=int, default=25, help="Max results (default 25)")
|
|
15
29
|
p.add_argument("--offset", type=int, default=0, help="Offset")
|
|
16
30
|
p.add_argument("--project", type=str, help="Filter by project ID")
|
|
@@ -1,13 +1,24 @@
|
|
|
1
1
|
"""Команда projects: список проектов со статистикой."""
|
|
2
2
|
|
|
3
|
+
import argparse
|
|
3
4
|
from typing import Literal
|
|
4
5
|
|
|
5
6
|
from i18n import _
|
|
6
|
-
from utils import format_cost, format_tokens, format_ts
|
|
7
|
+
from utils import build_help_epilog, format_cost, format_tokens, format_ts
|
|
8
|
+
|
|
9
|
+
_PROJECTS_EXAMPLES = [
|
|
10
|
+
("", "help.projects.e0"),
|
|
11
|
+
("--json", "help.projects.e1"),
|
|
12
|
+
]
|
|
7
13
|
|
|
8
14
|
|
|
9
15
|
def register(subparsers) -> None:
|
|
10
|
-
p = subparsers.add_parser(
|
|
16
|
+
p = subparsers.add_parser(
|
|
17
|
+
"projects",
|
|
18
|
+
help=_("help.cmd.projects"),
|
|
19
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
20
|
+
epilog=build_help_epilog("projects", _PROJECTS_EXAMPLES),
|
|
21
|
+
)
|
|
11
22
|
p.add_argument("--json", action="store_true", help="JSON output")
|
|
12
23
|
|
|
13
24
|
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Команда prune: массовая очистка (alias для delete)."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
|
|
5
|
+
from cmd_delete import run as delete_run
|
|
6
|
+
from i18n import _
|
|
7
|
+
from utils import build_help_epilog
|
|
8
|
+
|
|
9
|
+
_PRUNE_EXAMPLES = [
|
|
10
|
+
("--older-than 90d", "help.prune.e0"),
|
|
11
|
+
("--keep-last 20", "help.prune.e1"),
|
|
12
|
+
("--dry-run", "help.prune.e2"),
|
|
13
|
+
("--force", "help.prune.e3"),
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def register(subparsers) -> None:
|
|
18
|
+
p = subparsers.add_parser(
|
|
19
|
+
"prune",
|
|
20
|
+
help=_("help.cmd.prune"),
|
|
21
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
22
|
+
epilog=build_help_epilog("prune", _PRUNE_EXAMPLES),
|
|
23
|
+
)
|
|
24
|
+
p.add_argument("--older-than", type=str, help="Delete sessions older than (30d, 6m, 1y)")
|
|
25
|
+
p.add_argument("--keep-last", type=int, help="Keep N most recent sessions")
|
|
26
|
+
p.add_argument("--project", type=str, help="Limit to project")
|
|
27
|
+
p.add_argument("--dry-run", action="store_true", help="Preview without deleting")
|
|
28
|
+
p.add_argument("--force", "-f", action="store_true", help="Skip confirmation")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def run(args, db) -> int:
|
|
32
|
+
return delete_run(args, db)
|
|
@@ -1,14 +1,26 @@
|
|
|
1
1
|
"""Команда search: поиск по тексту сообщений и частей."""
|
|
2
2
|
|
|
3
|
+
import argparse
|
|
3
4
|
import json
|
|
4
5
|
|
|
5
6
|
from db import SessionError, resolve_session_id
|
|
6
7
|
from i18n import _
|
|
7
|
-
from utils import format_ts
|
|
8
|
+
from utils import build_help_epilog, format_ts
|
|
9
|
+
|
|
10
|
+
_SEARCH_EXAMPLES = [
|
|
11
|
+
('"text"', "help.search.e0"),
|
|
12
|
+
('"text" --session ses_xxx', "help.search.e1"),
|
|
13
|
+
('"text" --json', "help.search.e2"),
|
|
14
|
+
]
|
|
8
15
|
|
|
9
16
|
|
|
10
17
|
def register(subparsers) -> None:
|
|
11
|
-
p = subparsers.add_parser(
|
|
18
|
+
p = subparsers.add_parser(
|
|
19
|
+
"search",
|
|
20
|
+
help=_("help.cmd.search"),
|
|
21
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
22
|
+
epilog=build_help_epilog("search", _SEARCH_EXAMPLES),
|
|
23
|
+
)
|
|
12
24
|
p.add_argument("query", help="Search text")
|
|
13
25
|
p.add_argument("--session", type=str, help="Limit to session")
|
|
14
26
|
p.add_argument("--limit", type=int, default=30, help="Max results")
|
|
@@ -1,14 +1,25 @@
|
|
|
1
1
|
"""Команда stats: общая статистика по базе данных."""
|
|
2
2
|
|
|
3
|
+
import argparse
|
|
3
4
|
from typing import Literal
|
|
4
5
|
|
|
5
6
|
from db import parse_model
|
|
6
7
|
from i18n import _
|
|
7
|
-
from utils import format_cost, format_tokens, format_ts
|
|
8
|
+
from utils import build_help_epilog, format_cost, format_tokens, format_ts
|
|
9
|
+
|
|
10
|
+
_STATS_EXAMPLES = [
|
|
11
|
+
("", "help.stats.e0"),
|
|
12
|
+
("--json", "help.stats.e1"),
|
|
13
|
+
]
|
|
8
14
|
|
|
9
15
|
|
|
10
16
|
def register(subparsers) -> None:
|
|
11
|
-
p = subparsers.add_parser(
|
|
17
|
+
p = subparsers.add_parser(
|
|
18
|
+
"stats",
|
|
19
|
+
help=_("help.cmd.stats"),
|
|
20
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
21
|
+
epilog=build_help_epilog("stats", _STATS_EXAMPLES),
|
|
22
|
+
)
|
|
12
23
|
p.add_argument("--json", action="store_true", help="JSON output")
|
|
13
24
|
|
|
14
25
|
|
|
@@ -1,12 +1,26 @@
|
|
|
1
1
|
"""Команда todos: просмотр задач (todo) из сессий."""
|
|
2
2
|
|
|
3
|
+
import argparse
|
|
4
|
+
|
|
3
5
|
from db import SessionError, resolve_session_id
|
|
4
6
|
from i18n import _
|
|
5
|
-
from utils import format_ts
|
|
7
|
+
from utils import build_help_epilog, format_ts
|
|
8
|
+
|
|
9
|
+
_TODOS_EXAMPLES = [
|
|
10
|
+
("", "help.todos.e0"),
|
|
11
|
+
("<session_id>", "help.todos.e1"),
|
|
12
|
+
("--status pending", "help.todos.e2"),
|
|
13
|
+
("--json", "help.todos.e3"),
|
|
14
|
+
]
|
|
6
15
|
|
|
7
16
|
|
|
8
17
|
def register(subparsers) -> None:
|
|
9
|
-
p = subparsers.add_parser(
|
|
18
|
+
p = subparsers.add_parser(
|
|
19
|
+
"todos",
|
|
20
|
+
help=_("help.cmd.todos"),
|
|
21
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
22
|
+
epilog=build_help_epilog("todos", _TODOS_EXAMPLES),
|
|
23
|
+
)
|
|
10
24
|
p.add_argument("session_id", nargs="?", help="Session ID")
|
|
11
25
|
p.add_argument(
|
|
12
26
|
"--status",
|
|
@@ -1,12 +1,25 @@
|
|
|
1
1
|
"""Команда tree: дерево ветвления сессий."""
|
|
2
2
|
|
|
3
|
+
import argparse
|
|
4
|
+
|
|
3
5
|
from db import SessionError, get_session_title, resolve_session_id
|
|
4
6
|
from i18n import _
|
|
5
|
-
from utils import format_ts
|
|
7
|
+
from utils import build_help_epilog, format_ts
|
|
8
|
+
|
|
9
|
+
_TREE_EXAMPLES = [
|
|
10
|
+
("", "help.tree.e0"),
|
|
11
|
+
("<session_id>", "help.tree.e1"),
|
|
12
|
+
("--depth 3", "help.tree.e2"),
|
|
13
|
+
]
|
|
6
14
|
|
|
7
15
|
|
|
8
16
|
def register(subparsers) -> None:
|
|
9
|
-
p = subparsers.add_parser(
|
|
17
|
+
p = subparsers.add_parser(
|
|
18
|
+
"tree",
|
|
19
|
+
help=_("help.cmd.tree"),
|
|
20
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
21
|
+
epilog=build_help_epilog("tree", _TREE_EXAMPLES),
|
|
22
|
+
)
|
|
10
23
|
p.add_argument("session_id", nargs="?", help="Start from this session")
|
|
11
24
|
p.add_argument("--project", type=str, help="Filter by project")
|
|
12
25
|
p.add_argument("--depth", type=int, default=5, help="Max depth")
|
|
@@ -1,15 +1,26 @@
|
|
|
1
1
|
"""Команда vacuum: оптимизация базы данных."""
|
|
2
2
|
|
|
3
|
+
import argparse
|
|
3
4
|
import os
|
|
4
5
|
from typing import Literal
|
|
5
6
|
|
|
6
7
|
from config import OPENCODE_DB
|
|
7
8
|
from i18n import _
|
|
8
|
-
from utils import confirm
|
|
9
|
+
from utils import build_help_epilog, confirm
|
|
10
|
+
|
|
11
|
+
_VACUUM_EXAMPLES = [
|
|
12
|
+
("", "help.vacuum.e0"),
|
|
13
|
+
("--force", "help.vacuum.e1"),
|
|
14
|
+
]
|
|
9
15
|
|
|
10
16
|
|
|
11
17
|
def register(subparsers) -> None:
|
|
12
|
-
p = subparsers.add_parser(
|
|
18
|
+
p = subparsers.add_parser(
|
|
19
|
+
"vacuum",
|
|
20
|
+
help=_("help.cmd.vacuum"),
|
|
21
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
22
|
+
epilog=build_help_epilog("vacuum", _VACUUM_EXAMPLES),
|
|
23
|
+
)
|
|
13
24
|
p.add_argument("--force", "-f", action="store_true", help="Skip confirmation")
|
|
14
25
|
|
|
15
26
|
|
|
@@ -185,6 +185,40 @@ L: dict[str, dict[str, str]] = {
|
|
|
185
185
|
"ru": " Сообщений: {msg}, частей: {parts}",
|
|
186
186
|
"en": " Messages: {msg}, parts: {parts}",
|
|
187
187
|
},
|
|
188
|
+
"delete.none": {"ru": " ✅ Нет сессий для удаления.", "en": " ✅ No sessions to delete."},
|
|
189
|
+
"delete.filter_required": {
|
|
190
|
+
"ru": "❌ Укажи ID сессии, фильтр, или --interactive",
|
|
191
|
+
"en": "❌ Specify session ID, a filter, or --interactive",
|
|
192
|
+
},
|
|
193
|
+
"delete.usage": {
|
|
194
|
+
"ru": " Использование: opencode-db delete <session_id>",
|
|
195
|
+
"en": " Usage: opencode-db delete <session_id>",
|
|
196
|
+
},
|
|
197
|
+
"delete.bad_spec": {
|
|
198
|
+
"ru": "❌ Не могу разобрать: --older-than {spec}",
|
|
199
|
+
"en": "❌ Cannot parse: --older-than {spec}",
|
|
200
|
+
},
|
|
201
|
+
"delete.bad_date": {
|
|
202
|
+
"ru": "❌ Неверный формат даты: {date}. Используй ГГГГ-ММ-ДД",
|
|
203
|
+
"en": "❌ Invalid date format: {date}. Use YYYY-MM-DD",
|
|
204
|
+
},
|
|
205
|
+
"delete.mass_header": {
|
|
206
|
+
"ru": "Будет удалено сессий: {n}",
|
|
207
|
+
"en": "Sessions to delete: {n}",
|
|
208
|
+
},
|
|
209
|
+
"delete.cost_total": {
|
|
210
|
+
"ru": "Стоимость удаляемых: {cost}",
|
|
211
|
+
"en": "Cost of deleted sessions: {cost}",
|
|
212
|
+
},
|
|
213
|
+
"delete.tokens_total": {"ru": "Освободится токенов: ~{n}", "en": "Tokens freed: ~{n}"},
|
|
214
|
+
"delete.mass_confirm": {"ru": "Удалить {n} сессий?", "en": "Delete {n} sessions?"},
|
|
215
|
+
"delete.done_mass": {"ru": " ✅ Удалено {n} сессий.", "en": " ✅ Deleted {n} sessions."},
|
|
216
|
+
"delete.interactive_header": {"ru": "Последние сессии:", "en": "Recent sessions:"},
|
|
217
|
+
"delete.interactive_prompt": {
|
|
218
|
+
"ru": " Введи номера (1,3-5) или Enter для отмены: ",
|
|
219
|
+
"en": " Enter numbers (1,3-5) or Enter to cancel: ",
|
|
220
|
+
},
|
|
221
|
+
"delete.no_sessions": {"ru": "❌ Нет сессий для удаления.", "en": "❌ No sessions to delete."},
|
|
188
222
|
# ==================================================================
|
|
189
223
|
# COSTS
|
|
190
224
|
# ==================================================================
|
|
@@ -322,8 +356,8 @@ L: dict[str, dict[str, str]] = {
|
|
|
322
356
|
"en": "╔══════════════════════════════════════════════════════════╗",
|
|
323
357
|
},
|
|
324
358
|
"help.header_title": {
|
|
325
|
-
"ru": "║ opencode-db — справка
|
|
326
|
-
"en": "║ opencode-db — help
|
|
359
|
+
"ru": "║ opencode-db — справка ║",
|
|
360
|
+
"en": "║ opencode-db — help ║",
|
|
327
361
|
},
|
|
328
362
|
"help.header_box_bottom": {
|
|
329
363
|
"ru": "╚══════════════════════════════════════════════════════════╝",
|
|
@@ -355,13 +389,21 @@ L: dict[str, dict[str, str]] = {
|
|
|
355
389
|
},
|
|
356
390
|
"help.unknown": {"ru": "❌ Неизвестная команда: {cmd}", "en": "❌ Unknown command: {cmd}"},
|
|
357
391
|
"help.available": {"ru": " Доступные: {cmds}", "en": " Available: {cmds}"},
|
|
392
|
+
"help.usage_header": {
|
|
393
|
+
"ru": "Примеры использования команды {cmd}:",
|
|
394
|
+
"en": "Usage examples for {cmd}:",
|
|
395
|
+
},
|
|
396
|
+
"help.flags_header": {
|
|
397
|
+
"ru": "Все доступные флаги для {cmd}:",
|
|
398
|
+
"en": "Available flags for {cmd}:",
|
|
399
|
+
},
|
|
358
400
|
# ==================================================================
|
|
359
401
|
# HELP — команды (краткие описания в справке)
|
|
360
402
|
# ==================================================================
|
|
361
403
|
"help.cmd.list": {"ru": "Список сессий", "en": "List sessions"},
|
|
362
404
|
"help.cmd.info": {"ru": "Детальная информация о сессии", "en": "Session details"},
|
|
363
405
|
"help.cmd.export": {"ru": "Экспорт диалога в Markdown", "en": "Export dialog to Markdown"},
|
|
364
|
-
"help.cmd.delete": {"ru": "Удаление
|
|
406
|
+
"help.cmd.delete": {"ru": "Удаление сессий", "en": "Delete sessions"},
|
|
365
407
|
"help.cmd.costs": {"ru": "Анализ расходов на токены", "en": "Token cost analysis"},
|
|
366
408
|
"help.cmd.prune": {"ru": "Массовая очистка сессий", "en": "Bulk session cleanup"},
|
|
367
409
|
"help.cmd.search": {"ru": "Поиск по сообщениям", "en": "Search messages"},
|
|
@@ -372,8 +414,12 @@ L: dict[str, dict[str, str]] = {
|
|
|
372
414
|
"help.cmd.vacuum": {"ru": "Оптимизация БД", "en": "Database optimization"},
|
|
373
415
|
"help.cmd.help": {"ru": "Подробная справка", "en": "Detailed help"},
|
|
374
416
|
# ==================================================================
|
|
375
|
-
# HELP — примеры
|
|
417
|
+
# HELP — примеры (epilog для argparse --help)
|
|
376
418
|
# ==================================================================
|
|
419
|
+
"help.cmd.list.examples": {
|
|
420
|
+
"ru": "\nПримеры:\n opencode-db list # Сессии за последнее время\n opencode-db list --limit 10 # Показать 10 последних\n opencode-db list --sort cost # Сортировка по стоимости\n opencode-db list --project proj_xxx # Фильтр по проекту\n opencode-db list --json # Вывод в JSON\n",
|
|
421
|
+
"en": "\nExamples:\n opencode-db list # Recent sessions\n opencode-db list --limit 10 # Show last 10\n opencode-db list --sort cost # Sort by cost\n opencode-db list --project proj_xxx # Filter by project\n opencode-db list --json # JSON output\n",
|
|
422
|
+
},
|
|
377
423
|
"help.list.e0": {"ru": "Сессии за последнее время", "en": "Recent sessions"},
|
|
378
424
|
"help.list.e1": {"ru": "Показать 10 последних", "en": "Show last 10"},
|
|
379
425
|
"help.list.e2": {"ru": "Сортировка по стоимости", "en": "Sort by cost"},
|
|
@@ -396,6 +442,22 @@ L: dict[str, dict[str, str]] = {
|
|
|
396
442
|
"help.delete.e0": {"ru": "Удалить с подтверждением", "en": "Delete with confirmation"},
|
|
397
443
|
"help.delete.e1": {"ru": "Показать что будет удалено", "en": "Preview what will be deleted"},
|
|
398
444
|
"help.delete.e2": {"ru": "Без подтверждения", "en": "Skip confirmation"},
|
|
445
|
+
"help.delete.e3": {
|
|
446
|
+
"ru": "Удалить сессии старше 90 дней",
|
|
447
|
+
"en": "Delete sessions older than 90 days",
|
|
448
|
+
},
|
|
449
|
+
"help.delete.e4": {
|
|
450
|
+
"ru": "Оставить 30 последних, остальное удалить",
|
|
451
|
+
"en": "Keep 30 most recent, delete the rest",
|
|
452
|
+
},
|
|
453
|
+
"help.delete.e5": {
|
|
454
|
+
"ru": "Удалить сессии до указанной даты",
|
|
455
|
+
"en": "Delete sessions before a date",
|
|
456
|
+
},
|
|
457
|
+
"help.delete.e6": {
|
|
458
|
+
"ru": "Интерактивный выбор из списка",
|
|
459
|
+
"en": "Choose sessions from a list",
|
|
460
|
+
},
|
|
399
461
|
"help.costs.e0": {"ru": "Топ сессий по расходам", "en": "Top sessions by cost"},
|
|
400
462
|
"help.costs.e1": {"ru": "Детально по сессии", "en": "Single session breakdown"},
|
|
401
463
|
"help.costs.e2": {"ru": "Сумма по всей БД", "en": "Total across all sessions"},
|
|
@@ -427,6 +489,14 @@ L: dict[str, dict[str, str]] = {
|
|
|
427
489
|
"en": "VACUUM + REINDEX + ANALYZE with confirmation",
|
|
428
490
|
},
|
|
429
491
|
"help.vacuum.e1": {"ru": "Без подтверждения", "en": "Skip confirmation"},
|
|
492
|
+
"help.help.e0": {
|
|
493
|
+
"ru": "Общая справка по всем командам",
|
|
494
|
+
"en": "General help for all commands",
|
|
495
|
+
},
|
|
496
|
+
"help.help.e1": {
|
|
497
|
+
"ru": "Справка по конкретной команде",
|
|
498
|
+
"en": "Help for a specific command",
|
|
499
|
+
},
|
|
430
500
|
# ==================================================================
|
|
431
501
|
# MARKDOWN EXPORT (formatters.py, config.py)
|
|
432
502
|
# ==================================================================
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: opencode-db
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
4
|
Summary: OpenCode database CLI manager — browse, export, analyze, clean up sessions
|
|
5
5
|
Project-URL: Homepage, https://github.com/VasilevNStas/opencode-db
|
|
6
6
|
Project-URL: Source, https://github.com/VasilevNStas/opencode-db
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "opencode-db"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.6.0"
|
|
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"
|
|
@@ -477,7 +477,7 @@ class TestPruneCommand:
|
|
|
477
477
|
from cmd_prune import run
|
|
478
478
|
|
|
479
479
|
args = _ns(older_than="30d", keep_last=None, project=None, dry_run=False, force=False)
|
|
480
|
-
with patch("
|
|
480
|
+
with patch("cmd_delete.confirm", return_value=False):
|
|
481
481
|
assert run(args, db) == 0
|
|
482
482
|
assert _count_sessions(db) == 4
|
|
483
483
|
|
|
@@ -37,6 +37,19 @@ def summarize_val(v, max_len=120) -> str:
|
|
|
37
37
|
return s
|
|
38
38
|
|
|
39
39
|
|
|
40
|
+
def parse_date(date_str: str) -> datetime | None:
|
|
41
|
+
"""Парсит дату в формате ГГГГ-ММ-ДД."""
|
|
42
|
+
if not date_str or not isinstance(date_str, str):
|
|
43
|
+
return None
|
|
44
|
+
try:
|
|
45
|
+
parts = date_str.strip().split("-")
|
|
46
|
+
if len(parts) != 3:
|
|
47
|
+
return None
|
|
48
|
+
return datetime(int(parts[0]), int(parts[1]), int(parts[2]), tzinfo=UTC)
|
|
49
|
+
except (ValueError, IndexError):
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
40
53
|
def parse_time_spec(spec) -> None | datetime:
|
|
41
54
|
"""Парсит спецификацию времени ('30d', '6m', '1y') в timedelta."""
|
|
42
55
|
if not spec or not isinstance(spec, str):
|
|
@@ -91,6 +104,27 @@ def confirm(prompt=None, default=False) -> bool:
|
|
|
91
104
|
return False
|
|
92
105
|
|
|
93
106
|
|
|
107
|
+
def build_help_epilog(cmd_name: str, examples: list[tuple[str, str]]) -> str:
|
|
108
|
+
"""Формирует epilog для argparse из списка примеров.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
cmd_name: имя команды (для заголовка)
|
|
112
|
+
examples: список (флаги, ключ в i18n с описанием)
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
str — готовый текст для epilog
|
|
116
|
+
"""
|
|
117
|
+
header = _("help.usage_header", cmd=cmd_name)
|
|
118
|
+
lines = [f"\n{header}"]
|
|
119
|
+
for flags, desc_key in examples:
|
|
120
|
+
desc = _(desc_key)
|
|
121
|
+
if flags:
|
|
122
|
+
lines.append(f" opencode-db {cmd_name} {flags:33s} # {desc}")
|
|
123
|
+
else:
|
|
124
|
+
lines.append(f" opencode-db {cmd_name:36s} # {desc}")
|
|
125
|
+
return "\n".join(lines)
|
|
126
|
+
|
|
127
|
+
|
|
94
128
|
def format_cost(cost_value) -> str:
|
|
95
129
|
"""Форматирует стоимость в долларах."""
|
|
96
130
|
if cost_value is None:
|
opencode_db-0.5.0/cmd_delete.py
DELETED
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
"""Команда delete: удаление сессии."""
|
|
2
|
-
|
|
3
|
-
from db import (
|
|
4
|
-
SessionError,
|
|
5
|
-
get_message_count,
|
|
6
|
-
get_part_count,
|
|
7
|
-
get_session_info,
|
|
8
|
-
get_session_title,
|
|
9
|
-
parse_model,
|
|
10
|
-
)
|
|
11
|
-
from i18n import _
|
|
12
|
-
from utils import confirm, format_cost, format_ts
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
def register(subparsers) -> None:
|
|
16
|
-
p = subparsers.add_parser("delete", help=_("help.cmd.delete"))
|
|
17
|
-
p.add_argument("session_id", help="Session ID to delete")
|
|
18
|
-
p.add_argument("--dry-run", action="store_true", help="Preview without deleting")
|
|
19
|
-
p.add_argument("--force", "-f", action="store_true", help="Skip confirmation")
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
28
|
-
session_id = info["id"]
|
|
29
|
-
title = get_session_title(info)
|
|
30
|
-
model = parse_model(info)
|
|
31
|
-
|
|
32
|
-
msg_count = get_message_count(db, session_id)
|
|
33
|
-
part_count = get_part_count(db, session_id)
|
|
34
|
-
created = format_ts(info["time_created"])
|
|
35
|
-
|
|
36
|
-
print(f"\n{'=' * 50}")
|
|
37
|
-
print(f" {_('delete.header')}")
|
|
38
|
-
print(f"{'=' * 50}")
|
|
39
|
-
print(f" {_('delete.id')}: {session_id}")
|
|
40
|
-
print(f" {_('delete.title')}: {title}")
|
|
41
|
-
print(f" {_('delete.model')}: {model}")
|
|
42
|
-
print(f" {_('delete.created')}: {created}")
|
|
43
|
-
print(f" {_('delete.messages')}: {msg_count}")
|
|
44
|
-
print(f" {_('delete.parts')}: {part_count}")
|
|
45
|
-
print(f" {_('delete.cost')}: {format_cost(info['cost'])}")
|
|
46
|
-
|
|
47
|
-
if args.dry_run:
|
|
48
|
-
print(f"\n{_('delete.dry_run')}")
|
|
49
|
-
print(f" {_('delete.dry_run_hint')}")
|
|
50
|
-
return 0
|
|
51
|
-
|
|
52
|
-
if not args.force and not confirm(_("delete.confirm", id=session_id[:24])):
|
|
53
|
-
print(_("canceled"))
|
|
54
|
-
return 0
|
|
55
|
-
|
|
56
|
-
db.execute("DELETE FROM session WHERE id = ?", (session_id,))
|
|
57
|
-
db.commit()
|
|
58
|
-
|
|
59
|
-
print(f"\n{_('delete.done', id=session_id[:24])}")
|
|
60
|
-
print(_("delete.done_detail", msg=msg_count, parts=part_count))
|
|
61
|
-
return 0
|
opencode_db-0.5.0/cmd_prune.py
DELETED
|
@@ -1,127 +0,0 @@
|
|
|
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
|
|
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
|