opencode-db 0.6.2__py3-none-any.whl → 0.7.2__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 +9 -3
- cmd_view.py +363 -0
- commands/__init__.py +2 -0
- formatters.py +44 -11
- i18n.py +80 -0
- markdown_to_ansi.py +221 -0
- opencode_db-0.7.2.dist-info/METADATA +62 -0
- {opencode_db-0.6.2.dist-info → opencode_db-0.7.2.dist-info}/RECORD +14 -11
- {opencode_db-0.6.2.dist-info → opencode_db-0.7.2.dist-info}/WHEEL +1 -1
- {opencode_db-0.6.2.dist-info → opencode_db-0.7.2.dist-info}/top_level.txt +3 -0
- runtime.py +55 -0
- utils.py +1 -1
- opencode_db-0.6.2.dist-info/METADATA +0 -38
- {opencode_db-0.6.2.dist-info → opencode_db-0.7.2.dist-info}/entry_points.txt +0 -0
- {opencode_db-0.6.2.dist-info → opencode_db-0.7.2.dist-info}/licenses/LICENSE +0 -0
cli.py
CHANGED
|
@@ -7,14 +7,20 @@
|
|
|
7
7
|
- Единообразный интерфейс
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
10
12
|
import argparse
|
|
11
13
|
import os
|
|
12
14
|
import sys
|
|
13
15
|
from typing import Any, Literal
|
|
14
16
|
|
|
15
|
-
import
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
from runtime import require_python
|
|
18
|
+
|
|
19
|
+
require_python()
|
|
20
|
+
|
|
21
|
+
import commands # noqa: E402
|
|
22
|
+
from db import get_db # noqa: E402
|
|
23
|
+
from i18n import _, set_lang # noqa: E402
|
|
18
24
|
|
|
19
25
|
|
|
20
26
|
def build_parser() -> argparse.ArgumentParser:
|
cmd_view.py
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
"""Command view: просмотр сессии в терминале с прокруткой.
|
|
2
|
+
|
|
3
|
+
Выводит отформатированный диалог с ANSI-цветами,
|
|
4
|
+
автоматически использует $PAGER (less -R) для прокрутки.
|
|
5
|
+
|
|
6
|
+
Режимы выбора сессии:
|
|
7
|
+
- view <session_id> — по ID (полный или префикс)
|
|
8
|
+
- view --latest — самая свежая сессия
|
|
9
|
+
- view (без аргументов) — интерактивный выбор из списка
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import os
|
|
14
|
+
import shutil
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
|
|
18
|
+
from db import (
|
|
19
|
+
SessionError,
|
|
20
|
+
get_latest_session,
|
|
21
|
+
get_messages,
|
|
22
|
+
get_project_name,
|
|
23
|
+
get_recent_sessions,
|
|
24
|
+
get_session_info,
|
|
25
|
+
get_session_title,
|
|
26
|
+
parse_model,
|
|
27
|
+
)
|
|
28
|
+
from i18n import _
|
|
29
|
+
from markdown_to_ansi import render as md_render
|
|
30
|
+
from utils import build_help_epilog, format_cost, format_tokens, format_ts
|
|
31
|
+
|
|
32
|
+
_VIEW_EXAMPLES = [
|
|
33
|
+
("<session_id>", "help.view.e0"),
|
|
34
|
+
("--latest", "help.view.e1"),
|
|
35
|
+
("", "help.view.e2"),
|
|
36
|
+
("--no-pager", "help.view.e3"),
|
|
37
|
+
("--raw", "help.view.e4"),
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def register(subparsers) -> None:
|
|
42
|
+
p = subparsers.add_parser(
|
|
43
|
+
"view",
|
|
44
|
+
help=_("help.cmd.view"),
|
|
45
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
46
|
+
epilog=build_help_epilog("view", _VIEW_EXAMPLES),
|
|
47
|
+
)
|
|
48
|
+
p.add_argument(
|
|
49
|
+
"session_id",
|
|
50
|
+
nargs="?",
|
|
51
|
+
help="Session ID (optional — interactive mode if omitted)",
|
|
52
|
+
)
|
|
53
|
+
p.add_argument("--latest", "-l", action="store_true", help=_("view.flag.latest"))
|
|
54
|
+
p.add_argument("--no-pager", "-P", action="store_true", help=_("view.flag.no_pager"))
|
|
55
|
+
p.add_argument("--raw", "-R", action="store_true", help=_("view.flag.raw"))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def run(args, db) -> int:
|
|
59
|
+
if args.session_id:
|
|
60
|
+
return _view_by_id(db, args.session_id, args.no_pager, args.raw)
|
|
61
|
+
if args.latest:
|
|
62
|
+
return _view_latest(db, args.no_pager, args.raw)
|
|
63
|
+
return _view_interactive(db, args.no_pager, args.raw)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ======================================================================
|
|
67
|
+
# Selection modes
|
|
68
|
+
# ======================================================================
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _view_by_id(db, session_id, no_pager, raw) -> int:
|
|
72
|
+
try:
|
|
73
|
+
info = get_session_info(db, session_id)
|
|
74
|
+
except SessionError as e:
|
|
75
|
+
print(e.message)
|
|
76
|
+
return 1
|
|
77
|
+
return _do_view(db, info, no_pager, raw)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _view_latest(db, no_pager, raw) -> int:
|
|
81
|
+
try:
|
|
82
|
+
session_id = get_latest_session(db)
|
|
83
|
+
info = get_session_info(db, session_id)
|
|
84
|
+
except SessionError as e:
|
|
85
|
+
print(e.message)
|
|
86
|
+
return 1
|
|
87
|
+
title = get_session_title(info)
|
|
88
|
+
print(_("session.latest", title=title))
|
|
89
|
+
return _do_view(db, info, no_pager, raw)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _view_interactive(db, no_pager, raw) -> int:
|
|
93
|
+
sessions = get_recent_sessions(db)
|
|
94
|
+
if not sessions:
|
|
95
|
+
print(_("view.no_sessions"))
|
|
96
|
+
return 1
|
|
97
|
+
|
|
98
|
+
print()
|
|
99
|
+
print(f" {'─' * 60}")
|
|
100
|
+
print(f" {_('view.interactive_header')}")
|
|
101
|
+
print(f" {'─' * 60}")
|
|
102
|
+
|
|
103
|
+
for i, s in enumerate(sessions, 1):
|
|
104
|
+
title = get_session_title(s)
|
|
105
|
+
created = format_ts(s["time_created"], "%Y-%m-%d %H:%M")
|
|
106
|
+
cost = format_cost(s["cost"])
|
|
107
|
+
print(f" {i:2d}. {s['id'][:24]} {created} {title[:40]:40s} {cost}")
|
|
108
|
+
|
|
109
|
+
print(f" {'─' * 60}")
|
|
110
|
+
|
|
111
|
+
while True:
|
|
112
|
+
try:
|
|
113
|
+
choice = input(" " + _("view.interactive_prompt")).strip()
|
|
114
|
+
except (EOFError, KeyboardInterrupt):
|
|
115
|
+
print()
|
|
116
|
+
return 1
|
|
117
|
+
|
|
118
|
+
if not choice:
|
|
119
|
+
print(_("view.interactive_abort"))
|
|
120
|
+
return 1
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
idx = int(choice) - 1
|
|
124
|
+
if 0 <= idx < len(sessions):
|
|
125
|
+
break
|
|
126
|
+
except ValueError:
|
|
127
|
+
pass
|
|
128
|
+
|
|
129
|
+
print(f" {_('view.interactive_error', n=len(sessions))}")
|
|
130
|
+
|
|
131
|
+
session_id = sessions[idx]["id"]
|
|
132
|
+
title = get_session_title(sessions[idx])
|
|
133
|
+
print(_("view.interactive_viewing", title=title))
|
|
134
|
+
try:
|
|
135
|
+
info = get_session_info(db, session_id)
|
|
136
|
+
except SessionError as e:
|
|
137
|
+
print(e.message)
|
|
138
|
+
return 1
|
|
139
|
+
return _do_view(db, info, no_pager, raw)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ======================================================================
|
|
143
|
+
# Display engine
|
|
144
|
+
# ======================================================================
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _init_styles(raw=False):
|
|
148
|
+
"""Return style dict. Empty dict = no ANSI codes."""
|
|
149
|
+
if raw or not sys.stdout.isatty():
|
|
150
|
+
return {}
|
|
151
|
+
return {
|
|
152
|
+
"R": "\033[0m",
|
|
153
|
+
"B": "\033[1m",
|
|
154
|
+
"D": "\033[2m",
|
|
155
|
+
"G": "\033[32m",
|
|
156
|
+
"C": "\033[36m",
|
|
157
|
+
"Y": "\033[33m",
|
|
158
|
+
"M": "\033[90m",
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _do_view(db, info, no_pager, raw) -> int:
|
|
163
|
+
sty = _init_styles(raw)
|
|
164
|
+
messages = get_messages(db, info["id"])
|
|
165
|
+
text = _format_session(db, info, messages, sty)
|
|
166
|
+
|
|
167
|
+
use_pager = not no_pager and sys.stdout.isatty() and len(text) > 1000
|
|
168
|
+
if use_pager:
|
|
169
|
+
_page_text(text)
|
|
170
|
+
else:
|
|
171
|
+
sys.stdout.write(text)
|
|
172
|
+
if not text.endswith("\n"):
|
|
173
|
+
sys.stdout.write("\n")
|
|
174
|
+
sys.stdout.flush()
|
|
175
|
+
|
|
176
|
+
return 0
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _format_session(db, info, messages, sty) -> str:
|
|
180
|
+
"""Build the full formatted session view string."""
|
|
181
|
+
title = get_session_title(info)
|
|
182
|
+
model = parse_model(info)
|
|
183
|
+
created = format_ts(info["time_created"])
|
|
184
|
+
msg_count = len(messages)
|
|
185
|
+
|
|
186
|
+
duration = "—"
|
|
187
|
+
if info["time_updated"] and info["time_created"]:
|
|
188
|
+
duration_sec = (info["time_updated"] - info["time_created"]) / 1000
|
|
189
|
+
if duration_sec < 60:
|
|
190
|
+
duration = _("info.dur_sec", n=int(duration_sec))
|
|
191
|
+
elif duration_sec < 3600:
|
|
192
|
+
duration = _("info.dur_min", n=int(duration_sec / 60))
|
|
193
|
+
else:
|
|
194
|
+
hours = int(duration_sec / 3600)
|
|
195
|
+
mins = int((duration_sec % 3600) / 60)
|
|
196
|
+
duration = _("info.dur_hours", h=hours, m=mins)
|
|
197
|
+
|
|
198
|
+
ti = info["tokens_input"]
|
|
199
|
+
to = info["tokens_output"]
|
|
200
|
+
tr = info["tokens_reasoning"]
|
|
201
|
+
tokens_str = f"{format_tokens(ti)} in · {format_tokens(to)} out"
|
|
202
|
+
if tr:
|
|
203
|
+
tokens_str += f" · {format_tokens(tr)} reason"
|
|
204
|
+
|
|
205
|
+
cost = format_cost(info["cost"])
|
|
206
|
+
|
|
207
|
+
project_name = None
|
|
208
|
+
if info["project_id"]:
|
|
209
|
+
project_name = get_project_name(db, info["project_id"])
|
|
210
|
+
|
|
211
|
+
sep = f"{sty.get('M', '')}{'─' * 60}{sty.get('R', '')}"
|
|
212
|
+
lr = sty.get("R", "")
|
|
213
|
+
|
|
214
|
+
lines = []
|
|
215
|
+
lines.append("")
|
|
216
|
+
lines.append(f"{sty.get('B', '')}{sty.get('C', '')}🔍 {_('view.header')}{lr}")
|
|
217
|
+
lines.append(sep)
|
|
218
|
+
lines.append(f" {_('info.id')}: {info['id']}")
|
|
219
|
+
lines.append(f" {_('list.header.title')}: {title}")
|
|
220
|
+
lines.append(f" {_('info.model')}: {model}")
|
|
221
|
+
lines.append(f" {_('info.agent')}: {info['agent'] or '—'}")
|
|
222
|
+
lines.append(f" {_('info.created')}: {created}")
|
|
223
|
+
lines.append(f" {_('info.duration')}: {duration}")
|
|
224
|
+
lines.append(f" {_('info.tokens_total')}: {tokens_str}")
|
|
225
|
+
lines.append(f" {_('info.cost')}: {cost}")
|
|
226
|
+
if project_name:
|
|
227
|
+
lines.append(f" {_('info.project')}: {project_name}")
|
|
228
|
+
lines.append(f" {_('info.messages')}: {msg_count}")
|
|
229
|
+
lines.append(sep)
|
|
230
|
+
lines.append("")
|
|
231
|
+
|
|
232
|
+
if not messages:
|
|
233
|
+
lines.append(f" {sty.get('M', '')}{_('view.no_messages')}{lr}")
|
|
234
|
+
lines.append("")
|
|
235
|
+
lines.append(sep)
|
|
236
|
+
lines.append("")
|
|
237
|
+
else:
|
|
238
|
+
for msg in messages:
|
|
239
|
+
role = msg.get("role", "unknown")
|
|
240
|
+
ts = format_ts(msg.get("time"), "%Y-%m-%d %H:%M:%S") if msg.get("time") else ""
|
|
241
|
+
agent = msg.get("agent", "")
|
|
242
|
+
|
|
243
|
+
if role == "user":
|
|
244
|
+
role_str = f"{sty.get('G', '')}👤 {_('view.role_user')} [{ts}]{lr}"
|
|
245
|
+
elif role == "assistant":
|
|
246
|
+
agent_tag = f" ({agent})" if agent else ""
|
|
247
|
+
role_str = f"{sty.get('C', '')}🤖 {_('view.role_assistant')}{agent_tag} [{ts}]{lr}"
|
|
248
|
+
else:
|
|
249
|
+
role_str = f"{sty.get('Y', '')}⚙️ {role} [{ts}]{lr}"
|
|
250
|
+
|
|
251
|
+
lines.append(sep)
|
|
252
|
+
lines.append(role_str)
|
|
253
|
+
lines.append(sep)
|
|
254
|
+
|
|
255
|
+
for i, part in enumerate(msg.get("parts", [])):
|
|
256
|
+
formatted = _format_part_ansi(part, sty)
|
|
257
|
+
if formatted:
|
|
258
|
+
if i > 0:
|
|
259
|
+
lines.append("")
|
|
260
|
+
lines.append(formatted)
|
|
261
|
+
|
|
262
|
+
lines.append(f"{sty.get('M', '')}─── {_('view.footer')} ───{lr}")
|
|
263
|
+
lines.append("")
|
|
264
|
+
|
|
265
|
+
return "\n".join(lines)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
# ======================================================================
|
|
269
|
+
# Part formatting (ANSI version of format_part_to_md)
|
|
270
|
+
# ======================================================================
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _format_part_ansi(part, sty) -> str:
|
|
274
|
+
"""Форматирует часть сообщения с ANSI-цветами."""
|
|
275
|
+
t = part.get("type", "")
|
|
276
|
+
|
|
277
|
+
if t == "text":
|
|
278
|
+
text = part.get("text", "").strip()
|
|
279
|
+
if not text:
|
|
280
|
+
return ""
|
|
281
|
+
return md_render(text, sty, plain=not sty)
|
|
282
|
+
|
|
283
|
+
if t == "reasoning":
|
|
284
|
+
text = part.get("text", "").strip()
|
|
285
|
+
if not text:
|
|
286
|
+
return ""
|
|
287
|
+
title = f"{sty.get('Y', '')}{sty.get('D', '')}💭 {_('md.reasoning')}:{sty.get('R', '')}"
|
|
288
|
+
body = f"{sty.get('D', '')}{text}{sty.get('R', '')}"
|
|
289
|
+
return f"{title}\n{body}"
|
|
290
|
+
|
|
291
|
+
if t == "sound":
|
|
292
|
+
return f"{sty.get('Y', '')}*🔊 {_('md.sound')}*{sty.get('R', '')}"
|
|
293
|
+
|
|
294
|
+
if t == "tool":
|
|
295
|
+
tool_name = part.get("tool", "?")
|
|
296
|
+
t_input = part.get("input", {})
|
|
297
|
+
t_output = part.get("output", "")
|
|
298
|
+
t_status = part.get("status", "")
|
|
299
|
+
|
|
300
|
+
icon = {"completed": "✅", "error": "❌", "running": "⏳"}.get(t_status, "🔧")
|
|
301
|
+
y = sty.get("Y", "")
|
|
302
|
+
r = sty.get("R", "")
|
|
303
|
+
|
|
304
|
+
if tool_name == "read":
|
|
305
|
+
fp = t_input.get("filePath", "")
|
|
306
|
+
return f"{y}{icon} read:{r} `{fp}`"
|
|
307
|
+
if tool_name == "write":
|
|
308
|
+
fp = t_input.get("filePath", "")
|
|
309
|
+
return f"{y}{icon} write:{r} `{fp}`"
|
|
310
|
+
if tool_name == "edit":
|
|
311
|
+
fp = t_input.get("filePath", "")
|
|
312
|
+
return f"{y}{icon} edit:{r} `{fp}`"
|
|
313
|
+
if tool_name == "glob":
|
|
314
|
+
pat = t_input.get("pattern", "")
|
|
315
|
+
pth = t_input.get("path", "")
|
|
316
|
+
if pth:
|
|
317
|
+
return f"{y}{icon} glob:{r} `{pat}` in `{pth}`"
|
|
318
|
+
return f"{y}{icon} glob:{r} `{pat}`"
|
|
319
|
+
if tool_name == "grep":
|
|
320
|
+
pat = t_input.get("pattern", "")
|
|
321
|
+
return f"{y}{icon} grep:{r} `{pat}`"
|
|
322
|
+
|
|
323
|
+
lines = [f"{y}{icon} {tool_name}{r}"]
|
|
324
|
+
if t_input:
|
|
325
|
+
for k, v in t_input.items():
|
|
326
|
+
s = str(v)
|
|
327
|
+
if len(s) > 200:
|
|
328
|
+
s = s[:200] + "..."
|
|
329
|
+
lines.append(f" {k}: {s}")
|
|
330
|
+
if t_output:
|
|
331
|
+
out = str(t_output).strip()
|
|
332
|
+
if out:
|
|
333
|
+
if len(out) > 300:
|
|
334
|
+
out = out[:300] + "..."
|
|
335
|
+
lines.append(f" → {out}")
|
|
336
|
+
return "\n".join(lines)
|
|
337
|
+
|
|
338
|
+
return ""
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
# ======================================================================
|
|
342
|
+
# Pager
|
|
343
|
+
# ======================================================================
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _page_text(text: str) -> None:
|
|
347
|
+
"""Pipe text through $PAGER or less -R."""
|
|
348
|
+
pager_cmd = os.environ.get("PAGER", "")
|
|
349
|
+
try:
|
|
350
|
+
if pager_cmd:
|
|
351
|
+
subprocess.run(pager_cmd, input=text, text=True, shell=True)
|
|
352
|
+
elif shutil.which("less"):
|
|
353
|
+
subprocess.run(["less", "-R"], input=text, text=True)
|
|
354
|
+
else:
|
|
355
|
+
sys.stdout.write(text)
|
|
356
|
+
if not text.endswith("\n"):
|
|
357
|
+
sys.stdout.write("\n")
|
|
358
|
+
sys.stdout.flush()
|
|
359
|
+
except OSError:
|
|
360
|
+
sys.stdout.write(text)
|
|
361
|
+
if not text.endswith("\n"):
|
|
362
|
+
sys.stdout.write("\n")
|
|
363
|
+
sys.stdout.flush()
|
commands/__init__.py
CHANGED
|
@@ -23,6 +23,7 @@ import cmd_stats
|
|
|
23
23
|
import cmd_todos
|
|
24
24
|
import cmd_tree
|
|
25
25
|
import cmd_vacuum
|
|
26
|
+
import cmd_view
|
|
26
27
|
|
|
27
28
|
# Словарь {имя_команды: модуль}
|
|
28
29
|
# Модуль должен иметь register(subparsers) и run(args, db)
|
|
@@ -39,6 +40,7 @@ COMMANDS = {
|
|
|
39
40
|
"stats": cmd_stats,
|
|
40
41
|
"todos": cmd_todos,
|
|
41
42
|
"vacuum": cmd_vacuum,
|
|
43
|
+
"view": cmd_view,
|
|
42
44
|
"help": cmd_help,
|
|
43
45
|
}
|
|
44
46
|
|
formatters.py
CHANGED
|
@@ -15,28 +15,61 @@ def print_json(data: Any) -> None:
|
|
|
15
15
|
print(json.dumps(data, ensure_ascii=False, indent=2, default=str))
|
|
16
16
|
|
|
17
17
|
|
|
18
|
-
def print_table_simple(
|
|
19
|
-
|
|
18
|
+
def print_table_simple(
|
|
19
|
+
headers: list[str],
|
|
20
|
+
rows: list[list],
|
|
21
|
+
sep: str = " ",
|
|
22
|
+
) -> None:
|
|
23
|
+
"""Выводит таблицу с рамкой и колонками.
|
|
24
|
+
|
|
25
|
+
Формат:
|
|
26
|
+
┌──────┬───────────┐
|
|
27
|
+
│ ID │ Title │
|
|
28
|
+
├──────┼───────────┤
|
|
29
|
+
│ abc │ Session 1 │
|
|
30
|
+
└──────┴───────────┘
|
|
31
|
+
"""
|
|
20
32
|
if not rows:
|
|
21
33
|
print(_("empty"))
|
|
22
34
|
return
|
|
23
35
|
|
|
36
|
+
ncols = len(headers)
|
|
37
|
+
pad = 1 # padding inside each cell
|
|
38
|
+
|
|
39
|
+
# compute column widths
|
|
24
40
|
widths = []
|
|
25
|
-
for ci in range(
|
|
41
|
+
for ci in range(ncols):
|
|
26
42
|
max_w = len(headers[ci])
|
|
27
43
|
for row in rows:
|
|
28
44
|
max_w = max(max_w, len(str(row[ci])))
|
|
29
|
-
widths.append(min(max_w +
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
45
|
+
widths.append(min(max_w + pad * 2, 80))
|
|
46
|
+
|
|
47
|
+
tl = "┌" # top-left
|
|
48
|
+
tm = "┬" # top-middle
|
|
49
|
+
tr = "┐" # top-right
|
|
50
|
+
ml = "├" # middle-left
|
|
51
|
+
mm = "┼" # middle-middle
|
|
52
|
+
mr = "┤" # middle-right
|
|
53
|
+
bl = "└" # bottom-left
|
|
54
|
+
bm = "┴" # bottom-middle
|
|
55
|
+
br = "┘" # bottom-right
|
|
56
|
+
ve = "│" # vertical edge
|
|
57
|
+
|
|
58
|
+
top = tl + tm.join("─" * w for w in widths) + tr
|
|
59
|
+
sep = ml + mm.join("─" * w for w in widths) + mr
|
|
60
|
+
bot = bl + bm.join("─" * w for w in widths) + br
|
|
61
|
+
|
|
62
|
+
header_cells = [h.center(widths[ci])[: widths[ci]] for ci, h in enumerate(headers)]
|
|
63
|
+
header_line = ve + ve.join(header_cells) + ve
|
|
33
64
|
|
|
34
65
|
print()
|
|
35
|
-
print(
|
|
36
|
-
print(
|
|
66
|
+
print(top)
|
|
67
|
+
print(header_line)
|
|
68
|
+
print(sep)
|
|
37
69
|
for row in rows:
|
|
38
|
-
|
|
39
|
-
print(
|
|
70
|
+
cells = [str(row[ci]).ljust(widths[ci])[: widths[ci]] for ci in range(ncols)]
|
|
71
|
+
print(ve + ve.join(cells) + ve)
|
|
72
|
+
print(bot)
|
|
40
73
|
print(_("fmt.rows_count", n=len(rows)))
|
|
41
74
|
|
|
42
75
|
|
i18n.py
CHANGED
|
@@ -349,6 +349,65 @@ L: dict[str, dict[str, str]] = {
|
|
|
349
349
|
"vacuum.done": {"ru": "✅", "en": "✅"},
|
|
350
350
|
"vacuum.canceled": {"ru": " Отменено.", "en": " Canceled."},
|
|
351
351
|
# ==================================================================
|
|
352
|
+
# VIEW
|
|
353
|
+
# ==================================================================
|
|
354
|
+
"view.header": {
|
|
355
|
+
"ru": "Просмотр сессии",
|
|
356
|
+
"en": "Session View",
|
|
357
|
+
},
|
|
358
|
+
"view.role_user": {
|
|
359
|
+
"ru": "Вы",
|
|
360
|
+
"en": "You",
|
|
361
|
+
},
|
|
362
|
+
"view.role_assistant": {
|
|
363
|
+
"ru": "Ассистент",
|
|
364
|
+
"en": "Assistant",
|
|
365
|
+
},
|
|
366
|
+
"view.no_messages": {
|
|
367
|
+
"ru": "⏳ Нет сообщений в этой сессии.",
|
|
368
|
+
"en": "⏳ No messages in this session.",
|
|
369
|
+
},
|
|
370
|
+
"view.no_sessions": {
|
|
371
|
+
"ru": "❌ Нет сессий для просмотра.",
|
|
372
|
+
"en": "❌ No sessions to view.",
|
|
373
|
+
},
|
|
374
|
+
"view.interactive_header": {
|
|
375
|
+
"ru": "Последние сессии:",
|
|
376
|
+
"en": "Recent sessions:",
|
|
377
|
+
},
|
|
378
|
+
"view.interactive_prompt": {
|
|
379
|
+
"ru": "Выбери номер (или Enter для отмены): ",
|
|
380
|
+
"en": "Pick a number (or Enter to cancel): ",
|
|
381
|
+
},
|
|
382
|
+
"view.interactive_error": {
|
|
383
|
+
"ru": "Введи число от 1 до {n}",
|
|
384
|
+
"en": "Enter a number from 1 to {n}",
|
|
385
|
+
},
|
|
386
|
+
"view.interactive_viewing": {
|
|
387
|
+
"ru": " Просмотр: {title}",
|
|
388
|
+
"en": " Viewing: {title}",
|
|
389
|
+
},
|
|
390
|
+
"view.interactive_abort": {
|
|
391
|
+
"ru": " Отменено.",
|
|
392
|
+
"en": " Canceled.",
|
|
393
|
+
},
|
|
394
|
+
"view.footer": {
|
|
395
|
+
"ru": "конец сессии",
|
|
396
|
+
"en": "end of session",
|
|
397
|
+
},
|
|
398
|
+
"view.flag.latest": {
|
|
399
|
+
"ru": "Просмотр самой свежей сессии",
|
|
400
|
+
"en": "View the most recent session",
|
|
401
|
+
},
|
|
402
|
+
"view.flag.no_pager": {
|
|
403
|
+
"ru": "Вывод без пейджера (просто печатать)",
|
|
404
|
+
"en": "Print without pager",
|
|
405
|
+
},
|
|
406
|
+
"view.flag.raw": {
|
|
407
|
+
"ru": "Без ANSI-цветов (обычный текст)",
|
|
408
|
+
"en": "Plain text, no ANSI colors",
|
|
409
|
+
},
|
|
410
|
+
# ==================================================================
|
|
352
411
|
# HELP
|
|
353
412
|
# ==================================================================
|
|
354
413
|
"help.header_box_top": {
|
|
@@ -412,6 +471,7 @@ L: dict[str, dict[str, str]] = {
|
|
|
412
471
|
"help.cmd.stats": {"ru": "Статистика БД", "en": "Database statistics"},
|
|
413
472
|
"help.cmd.todos": {"ru": "Задачи из сессий", "en": "Tasks from sessions"},
|
|
414
473
|
"help.cmd.vacuum": {"ru": "Оптимизация БД", "en": "Database optimization"},
|
|
474
|
+
"help.cmd.view": {"ru": "Просмотр сессии с прокруткой", "en": "View session with scrolling"},
|
|
415
475
|
"help.cmd.help": {"ru": "Подробная справка", "en": "Detailed help"},
|
|
416
476
|
# ==================================================================
|
|
417
477
|
# HELP — примеры (epilog для argparse --help)
|
|
@@ -489,6 +549,26 @@ L: dict[str, dict[str, str]] = {
|
|
|
489
549
|
"en": "VACUUM + REINDEX + ANALYZE with confirmation",
|
|
490
550
|
},
|
|
491
551
|
"help.vacuum.e1": {"ru": "Без подтверждения", "en": "Skip confirmation"},
|
|
552
|
+
"help.view.e0": {
|
|
553
|
+
"ru": "Просмотр по ID",
|
|
554
|
+
"en": "View by ID",
|
|
555
|
+
},
|
|
556
|
+
"help.view.e1": {
|
|
557
|
+
"ru": "Самая свежая сессия",
|
|
558
|
+
"en": "Most recent session",
|
|
559
|
+
},
|
|
560
|
+
"help.view.e2": {
|
|
561
|
+
"ru": "Интерактивный выбор из списка",
|
|
562
|
+
"en": "Interactive picker",
|
|
563
|
+
},
|
|
564
|
+
"help.view.e3": {
|
|
565
|
+
"ru": "Без пейджера (простой вывод)",
|
|
566
|
+
"en": "Without pager (plain output)",
|
|
567
|
+
},
|
|
568
|
+
"help.view.e4": {
|
|
569
|
+
"ru": "Без ANSI-цветов",
|
|
570
|
+
"en": "Without ANSI colors",
|
|
571
|
+
},
|
|
492
572
|
"help.help.e0": {
|
|
493
573
|
"ru": "Общая справка по всем командам",
|
|
494
574
|
"en": "General help for all commands",
|
markdown_to_ansi.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Конвертация Markdown в ANSI-форматированный текст для терминала.
|
|
2
|
+
|
|
3
|
+
Режимы:
|
|
4
|
+
- rich (опционально): через `pip install opencode-db[rich]`
|
|
5
|
+
- fallback: stdlib (re) + ANSI escape codes, zero external deps
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
HAS_RICH: bool = False
|
|
13
|
+
_RICH_CONSOLE = None
|
|
14
|
+
try:
|
|
15
|
+
from rich.console import Console
|
|
16
|
+
|
|
17
|
+
_RICH_CONSOLE = Console(force_terminal=True, color_system="truecolor")
|
|
18
|
+
HAS_RICH = True
|
|
19
|
+
except ImportError:
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def render(text: str, styles: dict[str, str] | None = None, *, plain: bool = False) -> str:
|
|
24
|
+
"""Конвертирует Markdown-текст в ANSI-форматированный вывод.
|
|
25
|
+
|
|
26
|
+
При наличии rich использует его Markdown-рендерер (полное качество),
|
|
27
|
+
иначе — встроенный конвертер (базовые конструкции).
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
text: исходный Markdown-текст
|
|
31
|
+
styles: dict с ANSI-кодами (только для fallback)
|
|
32
|
+
plain: True — вернуть как есть (без форматирования)
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
str с ANSI-escape последовательностями
|
|
36
|
+
"""
|
|
37
|
+
if not text:
|
|
38
|
+
return ""
|
|
39
|
+
|
|
40
|
+
if plain:
|
|
41
|
+
return text
|
|
42
|
+
|
|
43
|
+
if HAS_RICH and _RICH_CONSOLE is not None:
|
|
44
|
+
return _render_rich(text)
|
|
45
|
+
|
|
46
|
+
return _render_fallback(text, styles or {})
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ======================================================================
|
|
50
|
+
# RENDERER: rich
|
|
51
|
+
# ======================================================================
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _render_rich(text: str) -> str:
|
|
55
|
+
from rich.markdown import Markdown
|
|
56
|
+
|
|
57
|
+
with _RICH_CONSOLE.capture() as capture:
|
|
58
|
+
_RICH_CONSOLE.print(Markdown(text))
|
|
59
|
+
result = capture.get()
|
|
60
|
+
return result.rstrip("\n")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ======================================================================
|
|
64
|
+
# RENDERER: fallback (stdlib)
|
|
65
|
+
# ======================================================================
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _render_fallback(text: str, sty: dict[str, str]) -> str:
|
|
69
|
+
r = sty.get("R", "")
|
|
70
|
+
bold = sty.get("B", "")
|
|
71
|
+
dim = sty.get("D", "")
|
|
72
|
+
cyan = sty.get("C", "")
|
|
73
|
+
yellow = sty.get("Y", "")
|
|
74
|
+
gray = sty.get("M", "")
|
|
75
|
+
green = sty.get("G", "")
|
|
76
|
+
|
|
77
|
+
result = text
|
|
78
|
+
|
|
79
|
+
# fenced code blocks
|
|
80
|
+
result = re.sub(
|
|
81
|
+
r"```(\w*)\n(.*?)```",
|
|
82
|
+
lambda m: _fb_code_block(m.group(1), m.group(2), sty),
|
|
83
|
+
result,
|
|
84
|
+
flags=re.DOTALL,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
# inline code
|
|
88
|
+
result = re.sub(r"`([^`]+)`", rf"{yellow}\1{r}", result)
|
|
89
|
+
|
|
90
|
+
# bold **...**
|
|
91
|
+
result = re.sub(r"\*\*(.+?)\*\*", rf"{bold}\1{r}", result)
|
|
92
|
+
|
|
93
|
+
# italic *...*
|
|
94
|
+
result = re.sub(r"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)", rf"{dim}\1{r}", result)
|
|
95
|
+
|
|
96
|
+
# headings
|
|
97
|
+
result = re.sub(
|
|
98
|
+
r"^#{1,3} (.+)$",
|
|
99
|
+
rf"{bold}{cyan}\1{r}",
|
|
100
|
+
result,
|
|
101
|
+
flags=re.MULTILINE,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
# blockquotes
|
|
105
|
+
result = re.sub(
|
|
106
|
+
r"^> (.+)$",
|
|
107
|
+
lambda m: _fb_blockquote(m.group(1), sty),
|
|
108
|
+
result,
|
|
109
|
+
flags=re.MULTILINE,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# horizontal rules
|
|
113
|
+
result = re.sub(
|
|
114
|
+
r"^---+$",
|
|
115
|
+
f"{gray}{'─' * 60}{r}",
|
|
116
|
+
result,
|
|
117
|
+
flags=re.MULTILINE,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
# unordered lists
|
|
121
|
+
result = re.sub(r"^(\s*)[-*] ", r"\1• ", result, flags=re.MULTILINE)
|
|
122
|
+
|
|
123
|
+
# ordered lists
|
|
124
|
+
result = re.sub(
|
|
125
|
+
r"^(\s*)(\d+)\. ",
|
|
126
|
+
lambda m: f"{m.group(1)}{green}{m.group(2)}.{r} ",
|
|
127
|
+
result,
|
|
128
|
+
flags=re.MULTILINE,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
# links [text](url)
|
|
132
|
+
result = re.sub(
|
|
133
|
+
r"\[([^\]]+)\]\(([^)]+)\)",
|
|
134
|
+
lambda m: f"{m.group(1)} ({gray}{m.group(2)}{r})",
|
|
135
|
+
result,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
# tables: convert to box-drawing
|
|
139
|
+
result = re.sub(
|
|
140
|
+
r"^(\|.+)$(?:\n\|.+)+\n\|.+(?:\n\|.+)*",
|
|
141
|
+
lambda m: _fb_table(m.group(0), sty),
|
|
142
|
+
result,
|
|
143
|
+
flags=re.MULTILINE,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
return result
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _fb_code_block(lang: str, body: str, sty: dict[str, str]) -> str:
|
|
150
|
+
r = sty.get("R", "")
|
|
151
|
+
gray = sty.get("M", "")
|
|
152
|
+
yellow = sty.get("Y", "")
|
|
153
|
+
lang_tag = f" {lang}" if lang else ""
|
|
154
|
+
header = f"{gray}┌─{lang_tag}{r}"
|
|
155
|
+
footer = f"{gray}└──{r}"
|
|
156
|
+
lines = body.strip().split("\n")
|
|
157
|
+
indented = "\n".join(f"{yellow}{line}{r}" for line in lines)
|
|
158
|
+
return f"{header}\n{indented}\n{footer}"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _fb_blockquote(line: str, sty: dict[str, str]) -> str:
|
|
162
|
+
gray = sty.get("M", "")
|
|
163
|
+
r = sty.get("R", "")
|
|
164
|
+
return f"{gray}│ {line}{r}"
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _fb_table(text: str, sty: dict[str, str]) -> str:
|
|
168
|
+
"""Converts a Markdown table to box-drawing format."""
|
|
169
|
+
lines = [ln.strip() for ln in text.strip().split("\n") if ln.strip()]
|
|
170
|
+
if len(lines) < 3:
|
|
171
|
+
return text
|
|
172
|
+
|
|
173
|
+
# skip separator row (|--|--|)
|
|
174
|
+
data_rows = []
|
|
175
|
+
for line in lines:
|
|
176
|
+
if not line.strip().startswith("|"):
|
|
177
|
+
continue
|
|
178
|
+
if re.match(r"^\|[-| ]+\|$", line):
|
|
179
|
+
continue
|
|
180
|
+
cells = [c.strip() for c in line.strip().strip("|").split("|")]
|
|
181
|
+
data_rows.append(cells)
|
|
182
|
+
|
|
183
|
+
if len(data_rows) < 2:
|
|
184
|
+
return text
|
|
185
|
+
|
|
186
|
+
ncols = max(len(r) for r in data_rows)
|
|
187
|
+
pad = 1
|
|
188
|
+
|
|
189
|
+
widths: list[int] = []
|
|
190
|
+
for ci in range(ncols):
|
|
191
|
+
max_w = max(len(r[ci]) if ci < len(r) else 0 for r in data_rows)
|
|
192
|
+
widths.append(max_w + pad * 2)
|
|
193
|
+
|
|
194
|
+
tl, tm, tr = "┌", "┬", "┐"
|
|
195
|
+
ml, mm, mr = "├", "┼", "┤"
|
|
196
|
+
bl, bm, br = "└", "┴", "┘"
|
|
197
|
+
ve = "│"
|
|
198
|
+
r = sty.get("R", "")
|
|
199
|
+
gray = sty.get("M", "")
|
|
200
|
+
|
|
201
|
+
top = tl + tm.join("─" * w for w in widths) + tr
|
|
202
|
+
sep = ml + mm.join("─" * w for w in widths) + mr
|
|
203
|
+
bot = bl + bm.join("─" * w for w in widths) + br
|
|
204
|
+
|
|
205
|
+
header_cells = [
|
|
206
|
+
(data_rows[0][ci] if ci < len(data_rows[0]) else "").center(widths[ci])[: widths[ci]]
|
|
207
|
+
for ci in range(ncols)
|
|
208
|
+
]
|
|
209
|
+
header_line = f"{gray}{ve}{ve.join(header_cells)}{ve}{r}"
|
|
210
|
+
|
|
211
|
+
result = [f"{gray}{top}{r}", header_line, f"{gray}{sep}{r}"]
|
|
212
|
+
|
|
213
|
+
for row in data_rows[1:]:
|
|
214
|
+
cells = [
|
|
215
|
+
(row[ci] if ci < len(row) else "").ljust(widths[ci])[: widths[ci]]
|
|
216
|
+
for ci in range(ncols)
|
|
217
|
+
]
|
|
218
|
+
result.append(f"{ve}{ve.join(cells)}{ve}")
|
|
219
|
+
|
|
220
|
+
result.append(f"{gray}{bot}{r}")
|
|
221
|
+
return "\n".join(result)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: opencode-db
|
|
3
|
+
Version: 0.7.2
|
|
4
|
+
Summary: OpenCode database CLI manager — browse, export, analyze, clean up sessions
|
|
5
|
+
Project-URL: Homepage, https://github.com/VasilevNStas/opencode-db
|
|
6
|
+
Project-URL: Source, https://github.com/VasilevNStas/opencode-db
|
|
7
|
+
Project-URL: Tracker, https://github.com/VasilevNStas/opencode-db/issues
|
|
8
|
+
Requires-Python: >=3.12
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Provides-Extra: rich
|
|
12
|
+
Requires-Dist: rich>=13.0; extra == "rich"
|
|
13
|
+
Provides-Extra: all
|
|
14
|
+
Requires-Dist: opencode-db[rich]; extra == "all"
|
|
15
|
+
Dynamic: license-file
|
|
16
|
+
|
|
17
|
+
# opencode-db
|
|
18
|
+
|
|
19
|
+
[](https://www.python.org/downloads/)
|
|
20
|
+
[](LICENSE)
|
|
21
|
+
|
|
22
|
+
**CLI for OpenCode's local database.** OpenCode saves every agent session, conversation, token usage, and cost in a local SQLite file. This tool lets you inspect, export, analyze, and clean up that data.
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install opencode-db
|
|
26
|
+
|
|
27
|
+
# recommended if your system python3 is older than 3.12:
|
|
28
|
+
uv tool install opencode-db
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Quick start
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
opencode-db list # recent sessions
|
|
35
|
+
opencode-db view # interactive session viewer (colored, scrollable)
|
|
36
|
+
opencode-db stats # database summary
|
|
37
|
+
opencode-db costs --total # total token costs
|
|
38
|
+
opencode-db export # export dialog to .md (interactive)
|
|
39
|
+
opencode-db help # full reference
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Features
|
|
43
|
+
|
|
44
|
+
- **`view`** — просмотр сессии в терминале с ANSI-цветами, Markdown-рендерингом и прокруткой через `less`
|
|
45
|
+
- **`list`** / **`info`** / **`search`** / **`tree`** — навигация и поиск по сессиям
|
|
46
|
+
- **`export`** — экспорт диалога в Markdown (с поддержкой Obsidian)
|
|
47
|
+
- **`delete`** / **`prune`** — удаление и массовая очистка с фильтрами
|
|
48
|
+
- **`costs`** / **`stats`** — аналитика токенов и расходов
|
|
49
|
+
- **`--db-path`** / **`OPENCODE_DB`** — кастомный путь к БД
|
|
50
|
+
|
|
51
|
+
## Optional extras
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pip install opencode-db[rich] # enhanced Markdown rendering (recommended)
|
|
55
|
+
pip install opencode-db # zero external dependencies
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Requirements
|
|
59
|
+
|
|
60
|
+
- Python 3.12+ — the CLI checks the interpreter at startup and exits with a clear message instead of an `ImportError` on older versions
|
|
61
|
+
- [OpenCode](https://opencode.ai) (must have been run at least once)
|
|
62
|
+
- macOS / Linux
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
cli.py,sha256=
|
|
1
|
+
cli.py,sha256=H-7TFu17dqAQv_bUvQsw0Tuly57Je53WzKs6yQOjajk,2428
|
|
2
2
|
cmd_costs.py,sha256=LstYavRJp-ZwwianBjRGtB8XiEKByGwMf6XwOHDHZvU,6395
|
|
3
3
|
cmd_delete.py,sha256=VJyaWcxr3UVIGqkZrlXs4IOkHzySbc9dt0W6sAL5IIo,11282
|
|
4
4
|
cmd_export.py,sha256=OfnNE1J9z7qbGbPqp1TXYIUBZZCEcUefkJcL1qlMdRw,6627
|
|
@@ -12,15 +12,18 @@ cmd_stats.py,sha256=IE_cLoxUTPLG_UaDvjZfgCNDmRW1cNqH9mw6f1X9GXQ,4818
|
|
|
12
12
|
cmd_todos.py,sha256=P47_HDIHm6ikHW9XsFEvPsl4jqm79X690oqXnD616d4,3182
|
|
13
13
|
cmd_tree.py,sha256=OaP1xQ3cxxWkHTbST2GHVmg-re9FXINWR_FgL_X1HiU,3274
|
|
14
14
|
cmd_vacuum.py,sha256=7Y6kdoKDMqIW_-Tvablge7v169tFGv3W65LB5GKgifI,2180
|
|
15
|
+
cmd_view.py,sha256=fy7YJB-GcfEMl8LVXE8ogN17kt95T5KSLiMmxqRkf2o,11781
|
|
15
16
|
config.py,sha256=Og_iwTlmqzpuHj5rmzkhFz33Ag915RhOE8SGsCbSr0w,694
|
|
16
17
|
db.py,sha256=5NlNM9m_g2frj0oNVgJ9IIEf5oK2n6rbJqDycoVC_Mo,11914
|
|
17
|
-
formatters.py,sha256=
|
|
18
|
-
i18n.py,sha256=
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
opencode_db-0.
|
|
24
|
-
opencode_db-0.
|
|
25
|
-
opencode_db-0.
|
|
26
|
-
opencode_db-0.
|
|
18
|
+
formatters.py,sha256=ovgtaB-gZFTCtrYl5dPWLKDUL8zG-Zl5nVHxFj2nJY4,7768
|
|
19
|
+
i18n.py,sha256=7QHCc7-KcGfzA7odkesFpqfbi9XKuZ5fXZ2OTFazF68,36667
|
|
20
|
+
markdown_to_ansi.py,sha256=HSr_yOZ1t1tYz5Z0Bx3yRqKzKSWxJIcuS02EKMO1WtE,6181
|
|
21
|
+
runtime.py,sha256=BbKIOdGkrcgckMvOqJgfiEpp6vDAfwO0aI-uaC4-F_M,2155
|
|
22
|
+
utils.py,sha256=c7RqEuxbx340RzJ1kzG14ucuf88aUzuc2gbKYb4919E,4849
|
|
23
|
+
commands/__init__.py,sha256=0VZs89RtmsPYGkmgedKxPKaNtIgkMdx6HYHWHsuKCtE,1290
|
|
24
|
+
opencode_db-0.7.2.dist-info/licenses/LICENSE,sha256=NhUr8ufTYb5u9F08J5bmDQ0xTVmCOexoq1ki_RdCqys,1210
|
|
25
|
+
opencode_db-0.7.2.dist-info/METADATA,sha256=xB704HtWzrLTwEOtBrgPt_9BvP5Pzie1pXYpf1L5azk,2511
|
|
26
|
+
opencode_db-0.7.2.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
27
|
+
opencode_db-0.7.2.dist-info/entry_points.txt,sha256=dMHc-RWkGEu0mTNvHHThSEKQdGbCdwdKUCO-cLF7TCw,41
|
|
28
|
+
opencode_db-0.7.2.dist-info/top_level.txt,sha256=CnAP9fA7TDBA6mjuh2AWR2bo4ceTfTpxwLn-pe0Kcj4,212
|
|
29
|
+
opencode_db-0.7.2.dist-info/RECORD,,
|
runtime.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Проверка минимальной версии Python.
|
|
2
|
+
|
|
3
|
+
Модуль намеренно написан без синтаксиса новее Python 3.8, чтобы его можно
|
|
4
|
+
было импортировать до проверки версии и показать понятную ошибку вместо
|
|
5
|
+
ImportError/SyntaxError.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
MIN_PYTHON = (3, 12)
|
|
11
|
+
REQUIRED_VERSION = "3.12"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def format_version_error(version_info=None, executable=None) -> str:
|
|
15
|
+
"""Возвращает текст ошибки для неподдерживаемой версии Python."""
|
|
16
|
+
info = version_info or sys.version_info
|
|
17
|
+
exe = executable or sys.executable
|
|
18
|
+
current = f"{info[0]}.{info[1]}.{info[2]}"
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
f"opencode-db requires Python {REQUIRED_VERSION}+.\n"
|
|
22
|
+
f"opencode-db требует Python {REQUIRED_VERSION}+.\n"
|
|
23
|
+
"\n"
|
|
24
|
+
f"Current interpreter: Python {current} ({exe})\n"
|
|
25
|
+
f"Текущий интерпретатор: Python {current} ({exe})\n"
|
|
26
|
+
"\n"
|
|
27
|
+
"How to fix / Как исправить:\n"
|
|
28
|
+
" uv python install 3.13\n"
|
|
29
|
+
" uv venv --python 3.13\n"
|
|
30
|
+
" source .venv/bin/activate\n"
|
|
31
|
+
" pip install -e .\n"
|
|
32
|
+
"\n"
|
|
33
|
+
"Or run with uv / Или запуск через uv:\n"
|
|
34
|
+
" uv run --python 3.13 opencode-db --help\n"
|
|
35
|
+
" uv tool install --editable . && opencode-db --help\n"
|
|
36
|
+
"\n"
|
|
37
|
+
"After changing the interpreter, rerun install.sh to regenerate\n"
|
|
38
|
+
"the ~/.local/bin/opencode-db launcher."
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def check_python_version(version_info=None):
|
|
43
|
+
"""Возвращает None, если версия подходит, иначе текст ошибки."""
|
|
44
|
+
info = version_info or sys.version_info
|
|
45
|
+
if tuple(info[:2]) >= MIN_PYTHON:
|
|
46
|
+
return None
|
|
47
|
+
return format_version_error(info)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def require_python() -> None:
|
|
51
|
+
"""Завершает процесс с понятной ошибкой, если Python слишком старый."""
|
|
52
|
+
message = check_python_version()
|
|
53
|
+
if message:
|
|
54
|
+
print(message, file=sys.stderr)
|
|
55
|
+
raise SystemExit(1)
|
utils.py
CHANGED
|
@@ -50,7 +50,7 @@ def parse_date(date_str: str) -> datetime | None:
|
|
|
50
50
|
return None
|
|
51
51
|
|
|
52
52
|
|
|
53
|
-
def parse_time_spec(spec) ->
|
|
53
|
+
def parse_time_spec(spec) -> datetime | None:
|
|
54
54
|
"""Парсит спецификацию времени ('30d', '6m', '1y') в timedelta."""
|
|
55
55
|
if not spec or not isinstance(spec, str):
|
|
56
56
|
return None
|
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: opencode-db
|
|
3
|
-
Version: 0.6.2
|
|
4
|
-
Summary: OpenCode database CLI manager — browse, export, analyze, clean up sessions
|
|
5
|
-
Project-URL: Homepage, https://github.com/VasilevNStas/opencode-db
|
|
6
|
-
Project-URL: Source, https://github.com/VasilevNStas/opencode-db
|
|
7
|
-
Project-URL: Tracker, https://github.com/VasilevNStas/opencode-db/issues
|
|
8
|
-
Requires-Python: >=3.12
|
|
9
|
-
Description-Content-Type: text/markdown
|
|
10
|
-
License-File: LICENSE
|
|
11
|
-
Dynamic: license-file
|
|
12
|
-
|
|
13
|
-
# opencode-db
|
|
14
|
-
|
|
15
|
-
[](https://www.python.org/downloads/)
|
|
16
|
-
[](LICENSE)
|
|
17
|
-
|
|
18
|
-
**CLI for OpenCode's local database.** OpenCode saves every agent session, conversation, token usage, and cost in a local SQLite file. This tool lets you inspect, export, analyze, and clean up that data.
|
|
19
|
-
|
|
20
|
-
```bash
|
|
21
|
-
pip install opencode-db
|
|
22
|
-
```
|
|
23
|
-
|
|
24
|
-
## Quick start
|
|
25
|
-
|
|
26
|
-
```bash
|
|
27
|
-
opencode-db list # recent sessions
|
|
28
|
-
opencode-db stats # database summary
|
|
29
|
-
opencode-db costs --total # total token costs
|
|
30
|
-
opencode-db export # export dialog to .md (interactive)
|
|
31
|
-
opencode-db help # full reference
|
|
32
|
-
```
|
|
33
|
-
|
|
34
|
-
## Requirements
|
|
35
|
-
|
|
36
|
-
- Python 3.12+
|
|
37
|
-
- [OpenCode](https://opencode.ai) (must have been run at least once)
|
|
38
|
-
- macOS / Linux
|
|
File without changes
|
|
File without changes
|