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 ADDED
@@ -0,0 +1,70 @@
1
+ """Точка входа: парсинг аргументов и диспетчеризация команд.
2
+
3
+ Каждая команда регистрирует свой subparser через register(subparsers).
4
+ Это даёт:
5
+ - Автоматический --help для каждой команды
6
+ - Валидацию аргументов
7
+ - Единообразный интерфейс
8
+ """
9
+
10
+ import argparse
11
+ import sys
12
+ from typing import Any, Literal
13
+
14
+ import commands
15
+ from db import get_db
16
+ from i18n import _, set_lang
17
+
18
+
19
+ def build_parser() -> argparse.ArgumentParser:
20
+ """Создаёт парсер аргументов со всеми подкомандами."""
21
+ parser = argparse.ArgumentParser(
22
+ prog="opencode-db",
23
+ description="OpenCode database manager: browse, export, analyze, clean up sessions.",
24
+ )
25
+ parser.add_argument(
26
+ "--lang",
27
+ choices=["en", "ru"],
28
+ default=None,
29
+ help="Output language / язык вывода (en/ru)",
30
+ )
31
+
32
+ subparsers = parser.add_subparsers(dest="command", required=True)
33
+ for _name, mod in commands.get_all().items():
34
+ mod.register(subparsers)
35
+ return parser
36
+
37
+
38
+ def main(argv=None) -> Any | Literal[0] | Literal[1]:
39
+ """Главная функция. Парсит аргументы, открывает БД, запускает команду.
40
+
41
+ Args:
42
+ argv: список аргументов (по умолчанию sys.argv[1:])
43
+
44
+ Returns:
45
+ int: код возврата (0 = успех, 1 = ошибка)
46
+ """
47
+ parser = build_parser()
48
+
49
+ if argv is None:
50
+ argv = sys.argv[1:]
51
+
52
+ if not argv:
53
+ parser.print_help()
54
+ return 0
55
+
56
+ args = parser.parse_args(argv)
57
+
58
+ if args.lang:
59
+ set_lang(args.lang)
60
+
61
+ db = get_db()
62
+
63
+ try:
64
+ handler = commands.get_handler(args.command)
65
+ if handler is None:
66
+ print(_("cmd.unknown", cmd=args.command))
67
+ return 1
68
+ return handler.run(args, db)
69
+ finally:
70
+ db.close()
cmd_costs.py ADDED
@@ -0,0 +1,197 @@
1
+ """Команда costs: анализ расходов на токены.
2
+
3
+ Стоимость берётся напрямую из поля session.cost БД OpenCode.
4
+ """
5
+
6
+ from typing import Literal
7
+
8
+ from db import get_session_info, get_session_title, parse_model
9
+ from i18n import _
10
+ from utils import format_cost, format_tokens
11
+
12
+
13
+ def register(subparsers) -> None:
14
+ p = subparsers.add_parser("costs", help=_("help.cmd.costs"))
15
+ p.add_argument("session_id", nargs="?", help="Session ID")
16
+ p.add_argument("--project", type=str, help="Filter by project")
17
+ p.add_argument("--total", action="store_true", help="Total across all sessions")
18
+ p.add_argument("--json", action="store_true", help="JSON output")
19
+ p.add_argument("--limit", type=int, default=30, help="Max rows")
20
+
21
+
22
+ def run(args, db) -> Literal[0]:
23
+ if args.session_id:
24
+ return _show_session(db, args.session_id, args.json)
25
+
26
+ if args.total:
27
+ return _show_total(db, args.project, args.json)
28
+
29
+ return _show_list(db, args.project, args.limit, args.json)
30
+
31
+
32
+ def _show_session(db, session_id, as_json) -> Literal[0]:
33
+ info = get_session_info(db, session_id)
34
+ title = get_session_title(info)
35
+ model = parse_model(info)
36
+
37
+ ti = info["tokens_input"] or 0
38
+ to = info["tokens_output"] or 0
39
+ tr = info["tokens_reasoning"] or 0
40
+ cost = info["cost"] or 0
41
+
42
+ if as_json:
43
+ from formatters import print_json
44
+
45
+ print_json(
46
+ {
47
+ "id": info["id"],
48
+ "title": title,
49
+ "model": model,
50
+ "tokens_input": ti,
51
+ "tokens_output": to,
52
+ "tokens_reasoning": tr,
53
+ "tokens_total": ti + to + tr,
54
+ "cost": cost,
55
+ }
56
+ )
57
+ return 0
58
+
59
+ print(f"\n {_('costs.session')}: {title}")
60
+ print(f" {_('costs.model')}: {model}")
61
+ print(f" {'─' * 50}")
62
+ print(f" {_('costs.input')}: {format_tokens(ti)}")
63
+ print(f" {_('costs.output')}: {format_tokens(to)}")
64
+ print(f" {_('costs.reasoning')}: {format_tokens(tr)}")
65
+ if info["tokens_cache_read"]:
66
+ print(f" {_('costs.cache_read')}: {format_tokens(info['tokens_cache_read'])}")
67
+ if info["tokens_cache_write"]:
68
+ print(f" {_('costs.cache_write')}: {format_tokens(info['tokens_cache_write'])}")
69
+ print(f" {'─' * 50}")
70
+ print(f" {_('costs.total')}: {format_tokens(ti + to + tr)}")
71
+ print(f" {_('costs.cost')}: {format_cost(cost)}")
72
+ print()
73
+ return 0
74
+
75
+
76
+ def _show_total(db, project_id, as_json) -> Literal[0]:
77
+ where = "1=1"
78
+ params = []
79
+
80
+ if project_id:
81
+ where = "s.project_id = ?"
82
+ params.append(project_id)
83
+
84
+ row = db.execute(
85
+ f"""
86
+ SELECT COUNT(*) as cnt,
87
+ SUM(tokens_input) as ti, SUM(tokens_output) as tok_out,
88
+ SUM(tokens_reasoning) as tr, SUM(cost) as c
89
+ FROM session WHERE {where}
90
+ """,
91
+ params,
92
+ ).fetchone()
93
+
94
+ cnt = row["cnt"] or 0
95
+ total_cost = row["c"] or 0
96
+ total_ti = row["ti"] or 0
97
+ total_to = row["tok_out"] or 0
98
+ total_tr = row["tr"] or 0
99
+
100
+ if as_json:
101
+ from formatters import print_json
102
+
103
+ print_json(
104
+ {
105
+ "sessions": cnt,
106
+ "tokens_input": total_ti,
107
+ "tokens_output": total_to,
108
+ "tokens_reasoning": total_tr,
109
+ "tokens_total": total_ti + total_to + total_tr,
110
+ "cost": total_cost,
111
+ }
112
+ )
113
+ return 0
114
+
115
+ scope = _("costs.scope_project") if project_id else _("costs.scope_all")
116
+ print(f"\n {'=' * 45}")
117
+ print(f" {_('costs.total_header', scope=scope)}")
118
+ print(f" {_('costs.sessions')}: {cnt}")
119
+ print(f" {'=' * 45}")
120
+ print(f" {_('costs.tokens_input')}: {format_tokens(total_ti)}")
121
+ print(f" {_('costs.tokens_output')}: {format_tokens(total_to)}")
122
+ print(f" {_('costs.tokens_reasoning')}: {format_tokens(total_tr)}")
123
+ print(f" {_('costs.tokens_total')}: {format_tokens(total_ti + total_to + total_tr)}")
124
+ print(f" {_('costs.cost')}: {format_cost(total_cost)}")
125
+ print()
126
+ return 0
127
+
128
+
129
+ def _show_list(db, project_id, limit, as_json) -> Literal[0]:
130
+ where = "1=1"
131
+ params = []
132
+
133
+ if project_id:
134
+ where = "s.project_id = ?"
135
+ params.append(project_id)
136
+
137
+ rows = db.execute(
138
+ f"""
139
+ SELECT s.id, s.title, s.model,
140
+ s.tokens_input, s.tokens_output, s.tokens_reasoning,
141
+ s.cost
142
+ FROM session s
143
+ WHERE {where}
144
+ ORDER BY (s.tokens_input + s.tokens_output) DESC
145
+ LIMIT ?
146
+ """,
147
+ (*params, limit),
148
+ ).fetchall()
149
+
150
+ if as_json:
151
+ from formatters import print_json
152
+
153
+ data = []
154
+ for r in rows:
155
+ data.append(
156
+ {
157
+ "id": r["id"],
158
+ "title": get_session_title(r),
159
+ "model": parse_model(r),
160
+ "tokens_total": (r["tokens_input"] or 0)
161
+ + (r["tokens_output"] or 0)
162
+ + (r["tokens_reasoning"] or 0),
163
+ "cost": r["cost"] or 0,
164
+ }
165
+ )
166
+ print_json(data)
167
+ return 0
168
+
169
+ from formatters import print_table_simple
170
+
171
+ table = []
172
+ for r in rows:
173
+ total_tokens = (
174
+ (r["tokens_input"] or 0) + (r["tokens_output"] or 0) + (r["tokens_reasoning"] or 0)
175
+ )
176
+ table.append(
177
+ [
178
+ r["id"][:24],
179
+ get_session_title(r)[:28],
180
+ parse_model(r)[:20],
181
+ format_tokens(total_tokens),
182
+ format_cost(r["cost"] or 0),
183
+ ]
184
+ )
185
+
186
+ print_table_simple(
187
+ [
188
+ _("list.header.id"),
189
+ _("list.header.title"),
190
+ _("list.header.model"),
191
+ _("costs.total"),
192
+ _("costs.cost"),
193
+ ],
194
+ table,
195
+ )
196
+
197
+ return 0
cmd_delete.py ADDED
@@ -0,0 +1,58 @@
1
+ """Команда delete: удаление сессии."""
2
+
3
+ from typing import Literal
4
+
5
+ from db import (
6
+ get_message_count,
7
+ get_part_count,
8
+ get_session_info,
9
+ get_session_title,
10
+ parse_model,
11
+ )
12
+ from i18n import _
13
+ from utils import confirm, format_cost, format_ts
14
+
15
+
16
+ def register(subparsers) -> None:
17
+ p = subparsers.add_parser("delete", help=_("help.cmd.delete"))
18
+ p.add_argument("session_id", help="Session ID to delete")
19
+ p.add_argument("--dry-run", action="store_true", help="Preview without deleting")
20
+ p.add_argument("--force", "-f", action="store_true", help="Skip confirmation")
21
+
22
+
23
+ def run(args, db) -> Literal[0]:
24
+ info = get_session_info(db, args.session_id)
25
+ session_id = info["id"]
26
+ title = get_session_title(info)
27
+ model = parse_model(info)
28
+
29
+ msg_count = get_message_count(db, session_id)
30
+ part_count = get_part_count(db, session_id)
31
+ created = format_ts(info["time_created"])
32
+
33
+ print(f"\n{'=' * 50}")
34
+ print(f" {_('delete.header')}")
35
+ print(f"{'=' * 50}")
36
+ print(f" {_('delete.id')}: {session_id}")
37
+ print(f" {_('delete.title')}: {title}")
38
+ print(f" {_('delete.model')}: {model}")
39
+ print(f" {_('delete.created')}: {created}")
40
+ print(f" {_('delete.messages')}: {msg_count}")
41
+ print(f" {_('delete.parts')}: {part_count}")
42
+ print(f" {_('delete.cost')}: {format_cost(info['cost'])}")
43
+
44
+ if args.dry_run:
45
+ print(f"\n{_('delete.dry_run')}")
46
+ print(f" {_('delete.dry_run_hint')}")
47
+ return 0
48
+
49
+ if not args.force and not confirm(_("delete.confirm", id=session_id[:24])):
50
+ print(_("canceled"))
51
+ return 0
52
+
53
+ db.execute("DELETE FROM session WHERE id = ?", (session_id,))
54
+ db.commit()
55
+
56
+ print(f"\n{_('delete.done', id=session_id[:24])}")
57
+ print(_("delete.done_detail", msg=msg_count, parts=part_count))
58
+ return 0
cmd_export.py ADDED
@@ -0,0 +1,189 @@
1
+ """Команда export: экспорт диалога в Markdown.
2
+
3
+ Режимы выбора сессии:
4
+ - export <session_id> — по ID (полный или префикс)
5
+ - export --latest — самая свежая сессия
6
+ - export (без аргументов) — интерактивный выбор из списка
7
+ """
8
+
9
+ import os
10
+ from typing import Literal
11
+
12
+ from db import (
13
+ get_latest_session,
14
+ get_messages,
15
+ get_recent_sessions,
16
+ get_session_info,
17
+ get_session_title,
18
+ parse_model,
19
+ )
20
+ from formatters import collect_snapshot, format_part_to_md, update_log
21
+ from i18n import _
22
+ from utils import format_cost, format_ts, format_ts_short
23
+
24
+
25
+ def register(subparsers) -> None:
26
+ p = subparsers.add_parser("export", help=_("help.cmd.export"))
27
+ p.add_argument(
28
+ "session_id",
29
+ nargs="?",
30
+ help="Session ID (optional — interactive mode if omitted)",
31
+ )
32
+ p.add_argument("--latest", "-l", action="store_true", help="Export the most recent session")
33
+ p.add_argument(
34
+ "--full",
35
+ "-F",
36
+ action="store_true",
37
+ help="Full output (do not truncate tool outputs)",
38
+ )
39
+ p.add_argument("--force", "-f", action="store_true", help="Overwrite existing file")
40
+ p.add_argument("--note", "-n", type=str, help="Description for log.md")
41
+ p.add_argument(
42
+ "--output",
43
+ "-o",
44
+ type=str,
45
+ default=None,
46
+ help="Output directory (default: current)",
47
+ )
48
+
49
+
50
+ def _export_by_id(db, session_id, output_dir, force, note, full) -> Literal[1] | Literal[0]:
51
+ session_info = get_session_info(db, session_id)
52
+ return _do_export(db, session_info, output_dir, force, note, full)
53
+
54
+
55
+ def _export_latest(db, output_dir, force, note, full) -> Literal[1] | Literal[0]:
56
+ session_id = get_latest_session(db)
57
+ session_info = get_session_info(db, session_id)
58
+ title = get_session_title(session_info)
59
+ print(_("session.latest", title=title))
60
+ return _do_export(db, session_info, output_dir, force, note, full)
61
+
62
+
63
+ def _export_interactive(db, output_dir, force, note, full) -> Literal[1] | Literal[0]:
64
+ sessions = get_recent_sessions(db)
65
+
66
+ if not sessions:
67
+ print(_("export.no_sessions"))
68
+ return 1
69
+
70
+ print(f"\n {'─' * 60}")
71
+ print(f" {_('export.interactive_header')}")
72
+ print(f" {'─' * 60}")
73
+
74
+ for i, s in enumerate(sessions, 1):
75
+ title = get_session_title(s)
76
+ created = format_ts(s["time_created"], "%Y-%m-%d %H:%M")
77
+ cost = format_cost(s["cost"])
78
+ print(f" {i:2d}. {s['id'][:24]} {created} {title[:40]:40s} {cost}")
79
+
80
+ print(f" {'─' * 60}")
81
+
82
+ while True:
83
+ try:
84
+ choice = input(" " + _("export.interactive_prompt")).strip()
85
+ except (EOFError, KeyboardInterrupt):
86
+ print()
87
+ return 1
88
+
89
+ if not choice:
90
+ print(_("export.interactive_abort"))
91
+ return 1
92
+
93
+ try:
94
+ idx = int(choice) - 1
95
+ if 0 <= idx < len(sessions):
96
+ break
97
+ except ValueError:
98
+ pass
99
+
100
+ print(f" {_('export.interactive_error', n=len(sessions))}")
101
+
102
+ session_id = sessions[idx]["id"]
103
+ title = get_session_title(sessions[idx])
104
+ print(_("export.interactive_exporting", title=title))
105
+ session_info = get_session_info(db, session_id)
106
+ return _do_export(db, session_info, output_dir, force, note, full)
107
+
108
+
109
+ def _do_export(db, session_info, output_dir, force, note, full=False) -> Literal[1] | Literal[0]:
110
+ """Внутренняя функция: экспорт диалога."""
111
+ title = get_session_title(session_info)
112
+ model = parse_model(session_info)
113
+
114
+ messages = get_messages(db, session_info["id"])
115
+
116
+ date_str = format_ts_short(session_info["time_created"])
117
+ filename = os.path.join(output_dir, f"opencode-{date_str}.md")
118
+
119
+ if os.path.isfile(filename) and not force:
120
+ print(_("export.exists", path=filename))
121
+ print(_("export.exists_hint"))
122
+ return 1
123
+
124
+ date_display = format_ts(session_info["time_created"])
125
+
126
+ md_header = f"""# {_("md.header.title")}: {title}
127
+
128
+ - **{_("md.header.date")}**: {date_display}
129
+ - **{_("md.header.model")}**: {model}
130
+ - **{_("md.header.session_id")}**: `{session_info["id"][:24]}`
131
+ - **{_("md.header.messages")}**: {len(messages)}
132
+
133
+ ---
134
+ """
135
+
136
+ md_body = []
137
+ for msg in messages:
138
+ if not msg.get("parts"):
139
+ continue
140
+
141
+ ts = format_ts(msg["time"], "%H:%M:%S")
142
+
143
+ if msg["role"] == "user":
144
+ md_body.append(f"## {_('export.role_user', ts=ts)}")
145
+ else:
146
+ agent_tag = _("export.agent_tag", agent=msg["agent"]) if msg.get("agent") else ""
147
+ md_body.append(f"## {_('export.role_assistant', ts=ts, agent=agent_tag)}")
148
+
149
+ md_body.append("")
150
+
151
+ for part in msg["parts"]:
152
+ formatted = format_part_to_md(part, full=full)
153
+ if formatted:
154
+ md_body.append(formatted)
155
+ md_body.append("")
156
+
157
+ md_body.append("---")
158
+ md_body.append("")
159
+
160
+ content = md_header + "\n".join(md_body)
161
+
162
+ with open(filename, "w", encoding="utf-8") as f:
163
+ f.write(content)
164
+
165
+ print(_("export.saved", path=filename))
166
+ print(_("export.messages", n=len(messages)))
167
+
168
+ obsidian_dir = os.getcwd()
169
+ if os.path.isdir(os.path.join(obsidian_dir, ".obsidian")):
170
+ snapshot = collect_snapshot(obsidian_dir)
171
+ now_str = format_ts(session_info["time_created"], "%Y-%m-%d %H:%M")
172
+ update_log(obsidian_dir, title, note, filename, snapshot, now_str)
173
+ else:
174
+ print(_("not_obsidian"))
175
+
176
+ return 0
177
+
178
+
179
+ def run(args, db) -> Literal[1] | Literal[0]:
180
+ output_dir = args.output or os.getcwd()
181
+ full = args.full
182
+
183
+ if args.session_id:
184
+ return _export_by_id(db, args.session_id, output_dir, args.force, args.note, full)
185
+
186
+ if args.latest:
187
+ return _export_latest(db, output_dir, args.force, args.note, full)
188
+
189
+ return _export_interactive(db, output_dir, args.force, args.note, full)