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.
cmd_search.py ADDED
@@ -0,0 +1,111 @@
1
+ """Команда search: поиск по тексту сообщений и частей."""
2
+
3
+ import json
4
+ from typing import Literal
5
+
6
+ from db import resolve_session_id
7
+ from i18n import _
8
+ from utils import format_ts
9
+
10
+
11
+ def register(subparsers) -> None:
12
+ p = subparsers.add_parser("search", help=_("help.cmd.search"))
13
+ p.add_argument("query", help="Search text")
14
+ p.add_argument("--session", type=str, help="Limit to session")
15
+ p.add_argument("--limit", type=int, default=30, help="Max results")
16
+ p.add_argument("--json", action="store_true", help="JSON output")
17
+
18
+
19
+ def _search_in_part_data(
20
+ data, query_lower
21
+ ) -> tuple[Literal[False], Literal[""]] | tuple[Literal[True], str]:
22
+ """Ищет query в part.data (JSON), проверяя текстовые поля."""
23
+ if not data:
24
+ return False, ""
25
+ try:
26
+ parsed = json.loads(data) if isinstance(data, str) else data
27
+ except (json.JSONDecodeError, TypeError):
28
+ return False, ""
29
+
30
+ text = parsed.get("text", "")
31
+ if isinstance(text, str) and query_lower in text.lower():
32
+ return True, text[:200]
33
+
34
+ state = parsed.get("state", {})
35
+ if isinstance(state, dict):
36
+ t_input = state.get("input", {})
37
+ t_output = state.get("output", "")
38
+ if isinstance(t_input, dict):
39
+ for v in t_input.values():
40
+ if isinstance(v, str) and query_lower in v.lower():
41
+ return True, f"[tool] {str(v)[:200]}"
42
+ if isinstance(t_output, str) and query_lower in t_output.lower():
43
+ return True, f"[output] {t_output[:200]}"
44
+
45
+ return False, ""
46
+
47
+
48
+ def run(args, db) -> Literal[0]:
49
+ query_lower = args.query.lower()
50
+
51
+ conditions = []
52
+ params = []
53
+
54
+ if args.session:
55
+ full_id = resolve_session_id(db, args.session)
56
+ conditions.append("p.session_id = ?")
57
+ params.append(full_id)
58
+
59
+ where = " AND ".join(conditions) if conditions else "1=1"
60
+
61
+ rows = db.execute(
62
+ f"""
63
+ SELECT p.id, p.session_id, p.message_id, p.data,
64
+ m.time_created,
65
+ json_extract(m.data, '$.role') as role
66
+ FROM part p
67
+ JOIN message m ON m.id = p.message_id
68
+ WHERE {where}
69
+ AND p.data IS NOT NULL
70
+ ORDER BY p.time_created DESC
71
+ LIMIT ?
72
+ """,
73
+ (*params, args.limit * 5),
74
+ ).fetchall()
75
+
76
+ results = []
77
+ for r in rows:
78
+ found, snippet = _search_in_part_data(r["data"], query_lower)
79
+ if found:
80
+ results.append(
81
+ {
82
+ "session_id": r["session_id"][:24],
83
+ "message_id": r["message_id"][:12],
84
+ "role": r["role"],
85
+ "time": format_ts(r["time_created"]) if r["time_created"] else "—",
86
+ "snippet": snippet,
87
+ }
88
+ )
89
+ if len(results) >= args.limit:
90
+ break
91
+
92
+ if args.json:
93
+ from formatters import print_json
94
+
95
+ print_json(results)
96
+ return 0
97
+
98
+ if not results:
99
+ print(_("search.none", query=args.query))
100
+ return 0
101
+
102
+ print(_("search.header", query=args.query))
103
+ print(f" {'─' * 60}")
104
+
105
+ for r in results:
106
+ print(f" [{r['time']}] {r['role']} {r['session_id']} msg:{r['message_id']}")
107
+ print(f" {r['snippet']}")
108
+ print()
109
+
110
+ print(_("search.found", n=len(results)))
111
+ return 0
cmd_stats.py ADDED
@@ -0,0 +1,115 @@
1
+ """Команда stats: общая статистика по базе данных."""
2
+
3
+ from typing import Literal
4
+
5
+ from db import parse_model
6
+ from i18n import _
7
+ from utils import format_cost, format_tokens, format_ts
8
+
9
+
10
+ def register(subparsers) -> None:
11
+ p = subparsers.add_parser("stats", help=_("help.cmd.stats"))
12
+ p.add_argument("--json", action="store_true", help="JSON output")
13
+
14
+
15
+ def run(args, db) -> Literal[0]:
16
+ session_count = db.execute("SELECT COUNT(*) FROM session").fetchone()[0]
17
+ message_count = db.execute("SELECT COUNT(*) FROM message").fetchone()[0]
18
+ part_count = db.execute("SELECT COUNT(*) FROM part").fetchone()[0]
19
+ project_count = db.execute("SELECT COUNT(*) FROM project").fetchone()[0]
20
+ todo_count = db.execute("SELECT COUNT(*) FROM todo").fetchone()[0]
21
+
22
+ totals = db.execute("""
23
+ SELECT
24
+ COALESCE(SUM(cost), 0) as total_cost,
25
+ COALESCE(SUM(tokens_input), 0) as total_ti,
26
+ COALESCE(SUM(tokens_output), 0) as total_to,
27
+ COALESCE(SUM(tokens_reasoning), 0) as total_tr,
28
+ COALESCE(SUM(tokens_cache_read), 0) as total_cr,
29
+ COALESCE(SUM(tokens_cache_write), 0) as total_cw
30
+ FROM session
31
+ """).fetchone()
32
+
33
+ first_last = db.execute("""
34
+ SELECT MIN(time_created) as first_ts, MAX(time_created) as last_ts
35
+ FROM session
36
+ """).fetchone()
37
+
38
+ top_models = db.execute("""
39
+ SELECT model, COUNT(*) as cnt, SUM(cost) as cost
40
+ FROM session
41
+ WHERE model IS NOT NULL
42
+ GROUP BY model
43
+ ORDER BY cnt DESC
44
+ LIMIT 5
45
+ """).fetchall()
46
+
47
+ agents = db.execute("""
48
+ SELECT agent, COUNT(*) as cnt
49
+ FROM session
50
+ WHERE agent IS NOT NULL
51
+ GROUP BY agent
52
+ ORDER BY cnt DESC
53
+ """).fetchall()
54
+
55
+ if args.json:
56
+ from formatters import print_json
57
+
58
+ data = {
59
+ "sessions": session_count,
60
+ "messages": message_count,
61
+ "parts": part_count,
62
+ "projects": project_count,
63
+ "todos": todo_count,
64
+ "cost": totals["total_cost"],
65
+ "tokens_input": totals["total_ti"],
66
+ "tokens_output": totals["total_to"],
67
+ "tokens_reasoning": totals["total_tr"],
68
+ "tokens_cache_read": totals["total_cr"],
69
+ "tokens_cache_write": totals["total_cw"],
70
+ "tokens_total": totals["total_ti"] + totals["total_to"] + totals["total_tr"],
71
+ "first_session": format_ts(first_last["first_ts"]) if first_last["first_ts"] else None,
72
+ "last_session": format_ts(first_last["last_ts"]) if first_last["last_ts"] else None,
73
+ "top_models": [
74
+ {"model": parse_model(m), "count": m["cnt"], "cost": m["cost"]} for m in top_models
75
+ ],
76
+ "agents": [{"agent": a["agent"], "count": a["cnt"]} for a in agents],
77
+ }
78
+ print_json(data)
79
+ return 0
80
+
81
+ total_tokens = totals["total_ti"] + totals["total_to"] + totals["total_tr"]
82
+ first_date = format_ts(first_last["first_ts"], "%Y-%m-%d") if first_last["first_ts"] else "—"
83
+ last_date = format_ts(first_last["last_ts"], "%Y-%m-%d") if first_last["last_ts"] else "—"
84
+
85
+ print(f"\n {'=' * 50}")
86
+ print(f" {_('stats.header')}")
87
+ print(f" {'=' * 50}")
88
+ print(f" {_('stats.projects')}: {project_count}")
89
+ print(f" {_('stats.sessions')}: {session_count}")
90
+ print(f" {_('stats.messages')}: {message_count}")
91
+ print(f" {_('stats.parts')}: {part_count}")
92
+ print(f" {_('stats.todos')}: {todo_count}")
93
+ print(f" {'─' * 50}")
94
+ print(f" {_('stats.first_session')}: {first_date}")
95
+ print(f" {_('stats.last_session')}: {last_date}")
96
+ print(f" {'─' * 50}")
97
+ print(f" {_('stats.tokens_input')}: {format_tokens(totals['total_ti'])}")
98
+ print(f" {_('stats.tokens_output')}: {format_tokens(totals['total_to'])}")
99
+ print(f" {_('stats.tokens_reasoning')}: {format_tokens(totals['total_tr'])}")
100
+ print(f" {_('stats.tokens_total')}: {format_tokens(total_tokens)}")
101
+ print(f" {_('stats.cost')}: {format_cost(totals['total_cost'])}")
102
+
103
+ if top_models:
104
+ print(f"\n {_('stats.top_models')}:")
105
+ for m in top_models:
106
+ model_str = parse_model(m)
107
+ print(f" {m['cnt']:4d}× {model_str:30s} ({format_cost(m['cost'] or 0)})")
108
+
109
+ if agents:
110
+ print(f"\n {_('stats.agents')}:")
111
+ for a in agents:
112
+ print(f" {a['cnt']:4d}× {a['agent']}")
113
+
114
+ print()
115
+ return 0
cmd_todos.py ADDED
@@ -0,0 +1,90 @@
1
+ """Команда todos: просмотр задач (todo) из сессий."""
2
+
3
+ from typing import Literal
4
+
5
+ from db import resolve_session_id
6
+ from i18n import _
7
+ from utils import format_ts
8
+
9
+
10
+ def register(subparsers) -> None:
11
+ p = subparsers.add_parser("todos", help=_("help.cmd.todos"))
12
+ p.add_argument("session_id", nargs="?", help="Session ID")
13
+ p.add_argument(
14
+ "--status",
15
+ choices=["pending", "completed", "cancelled"],
16
+ help="Filter by status",
17
+ )
18
+ p.add_argument("--limit", type=int, default=30, help="Max entries")
19
+ p.add_argument("--json", action="store_true", help="JSON output")
20
+
21
+
22
+ def run(args, db) -> Literal[0]:
23
+ conditions = []
24
+ params = []
25
+
26
+ if args.session_id:
27
+ full_id = resolve_session_id(db, args.session_id)
28
+ conditions.append("t.session_id = ?")
29
+ params.append(full_id)
30
+
31
+ if args.status:
32
+ conditions.append("t.status = ?")
33
+ params.append(args.status)
34
+
35
+ where = " AND ".join(conditions) if conditions else "1=1"
36
+
37
+ rows = db.execute(
38
+ f"""
39
+ SELECT t.session_id, t.content, t.status, t.priority,
40
+ t.position, t.time_created, t.time_updated,
41
+ s.title as session_title
42
+ FROM todo t
43
+ JOIN session s ON s.id = t.session_id
44
+ WHERE {where}
45
+ ORDER BY t.time_created DESC
46
+ LIMIT ?
47
+ """,
48
+ (*params, args.limit),
49
+ ).fetchall()
50
+
51
+ if args.json:
52
+ from formatters import print_json
53
+
54
+ data = []
55
+ for r in rows:
56
+ data.append(
57
+ {
58
+ "session_id": r["session_id"][:24],
59
+ "session_title": r["session_title"],
60
+ "content": r["content"],
61
+ "status": r["status"],
62
+ "priority": r["priority"],
63
+ "created": format_ts(r["time_created"]) if r["time_created"] else None,
64
+ }
65
+ )
66
+ print_json(data)
67
+ return 0
68
+
69
+ if not rows:
70
+ print(_("todos.none"))
71
+ return 0
72
+
73
+ status_icon = {"pending": "⏳", "completed": "✅", "cancelled": "❌"}
74
+ priority_mark = {"high": " 🔴", "medium": "", "low": " 🟢"}
75
+
76
+ print(_("todos.header", n=len(rows)))
77
+ print(f" {'─' * 55}")
78
+
79
+ for r in rows:
80
+ icon = status_icon.get(r["status"], "📝")
81
+ prio = priority_mark.get(r["priority"], "")
82
+ created = format_ts(r["time_created"], "%Y-%m-%d") if r["time_created"] else "—"
83
+ title = r["session_title"] or "—"
84
+
85
+ print(f" {icon} {r['content'][:60]}{prio}")
86
+ print(f" {_('todos.session')}: {title[:40]}")
87
+ print(f" {_('todos.status')}: {r['status']}, {_('todos.created')}: {created}")
88
+ print()
89
+
90
+ return 0
cmd_tree.py ADDED
@@ -0,0 +1,95 @@
1
+ """Команда tree: дерево ветвления сессий."""
2
+
3
+ from typing import Literal
4
+
5
+ from db import get_session_title, resolve_session_id
6
+ from i18n import _
7
+ from utils import format_ts
8
+
9
+
10
+ def register(subparsers) -> None:
11
+ p = subparsers.add_parser("tree", help=_("help.cmd.tree"))
12
+ p.add_argument("session_id", nargs="?", help="Start from this session")
13
+ p.add_argument("--project", type=str, help="Filter by project")
14
+ p.add_argument("--depth", type=int, default=5, help="Max depth")
15
+
16
+
17
+ def _build_tree(db, parent_id, depth, max_depth, indent=0, visited=None):
18
+ if visited is None:
19
+ visited = set()
20
+ if depth > max_depth:
21
+ return []
22
+
23
+ lines = []
24
+
25
+ if parent_id is None:
26
+ rows = db.execute("""
27
+ SELECT id, title, time_created
28
+ FROM session
29
+ WHERE parent_id IS NULL
30
+ ORDER BY time_created
31
+ """).fetchall()
32
+ else:
33
+ rows = db.execute(
34
+ """
35
+ SELECT id, title, time_created
36
+ FROM session
37
+ WHERE parent_id = ?
38
+ ORDER BY time_created
39
+ """,
40
+ (parent_id,),
41
+ ).fetchall()
42
+
43
+ for r in rows:
44
+ if r["id"] in visited:
45
+ continue
46
+ visited.add(r["id"])
47
+
48
+ prefix = " " * indent + ("└─ " if indent > 0 else "")
49
+ title = get_session_title(r)
50
+ created = format_ts(r["time_created"], "%Y-%m-%d")
51
+ lines.append(f"{prefix}{r['id'][:24]} {created} {title}")
52
+
53
+ lines.extend(_build_tree(db, r["id"], depth + 1, max_depth, indent + 1, visited))
54
+
55
+ return lines
56
+
57
+
58
+ def run(args, db) -> Literal[0]:
59
+ if args.session_id:
60
+ full_id = resolve_session_id(db, args.session_id)
61
+ info = db.execute(
62
+ "SELECT id, title, time_created FROM session WHERE id = ?", (full_id,)
63
+ ).fetchone()
64
+
65
+ title = get_session_title(info)
66
+ print(f"\n {_('tree.header', title=title)}")
67
+ print(f" {'─' * 55}")
68
+
69
+ lines = _build_tree(db, full_id, 1, args.depth, 0)
70
+ for line in lines:
71
+ print(f" {line}")
72
+
73
+ elif args.project:
74
+ print(f"\n {_('tree.header_project', project=args.project)}")
75
+ print(f" {'─' * 55}")
76
+
77
+ lines = _build_tree(db, None, 1, args.depth, 0)
78
+ project_lines = []
79
+ for line in lines:
80
+ sid = line.split(" ")[0].strip().lstrip("└─ ")
81
+ row = db.execute("SELECT project_id FROM session WHERE id = ?", (sid,)).fetchone()
82
+ if row and row["project_id"] == args.project:
83
+ project_lines.append(line)
84
+ for line in project_lines:
85
+ print(f" {line}")
86
+
87
+ else:
88
+ print(f"\n {_('tree.header_all')}")
89
+ print(f" {'─' * 55}")
90
+ lines = _build_tree(db, None, 1, args.depth, 0)
91
+ for line in lines:
92
+ print(f" {line}")
93
+
94
+ print()
95
+ return 0
cmd_vacuum.py ADDED
@@ -0,0 +1,67 @@
1
+ """Команда vacuum: оптимизация базы данных."""
2
+
3
+ import os
4
+ from typing import Literal
5
+
6
+ from config import OPENCODE_DB
7
+ from i18n import _
8
+ from utils import confirm
9
+
10
+
11
+ def register(subparsers) -> None:
12
+ p = subparsers.add_parser("vacuum", help=_("help.cmd.vacuum"))
13
+ p.add_argument("--force", "-f", action="store_true", help="Skip confirmation")
14
+
15
+
16
+ def run(args, db) -> Literal[0] | Literal[1]:
17
+ try:
18
+ size_before = os.path.getsize(OPENCODE_DB)
19
+ except OSError:
20
+ size_before = 0
21
+
22
+ size_before_mb = size_before / (1024 * 1024)
23
+
24
+ print(f"\n {_('vacuum.header')}")
25
+ print(f" {'─' * 40}")
26
+ print(f" {_('vacuum.size_before')}: {size_before_mb:.1f} MB")
27
+ print(f" {_('vacuum.path')}: {OPENCODE_DB}")
28
+
29
+ if not args.force and not confirm(_("vacuum.confirm")):
30
+ print(_("vacuum.canceled"))
31
+ return 0
32
+
33
+ print(f" {_('vacuum.vacuum')}", end=" ", flush=True)
34
+ try:
35
+ db.execute("VACUUM")
36
+ print(_("vacuum.done"))
37
+ except Exception as e:
38
+ print(_("vacuum.error", error=e))
39
+ return 1
40
+
41
+ print(f" {_('vacuum.reindex')}", end=" ", flush=True)
42
+ try:
43
+ db.execute("REINDEX")
44
+ print(_("vacuum.done"))
45
+ except Exception as e:
46
+ print(_("vacuum.error", error=e))
47
+
48
+ print(f" {_('vacuum.analyze')}", end=" ", flush=True)
49
+ try:
50
+ db.execute("ANALYZE")
51
+ print(_("vacuum.done"))
52
+ except Exception as e:
53
+ print(_("vacuum.error", error=e))
54
+
55
+ try:
56
+ size_after = os.path.getsize(OPENCODE_DB)
57
+ size_after_mb = size_after / (1024 * 1024)
58
+ saved = size_before - size_after
59
+ saved_mb = saved / (1024 * 1024)
60
+ print(f"\n {_('vacuum.size_after')}: {size_after_mb:.1f} MB")
61
+ if saved > 0:
62
+ print(f" {_('vacuum.freed')}: {saved_mb:.1f} MB")
63
+ except OSError:
64
+ pass
65
+
66
+ print()
67
+ return 0
commands/__init__.py ADDED
@@ -0,0 +1,51 @@
1
+ """Реестр команд. Импортирует модули команд и регистрирует их.
2
+
3
+ Каждый модуль (cmd_xxx.py) экспортирует:
4
+ - register(subparsers) — регистрирует subparser
5
+ - run(args, db) — выполняет команду
6
+
7
+ Добавление новой команды:
8
+ 1. Создать cmd_xxx.py
9
+ 2. Добавить import cmd_xxx
10
+ 3. Добавить "xxx": cmd_xxx в COMMANDS
11
+ """
12
+
13
+ import cmd_costs
14
+ import cmd_delete
15
+ import cmd_export
16
+ import cmd_help
17
+ import cmd_info
18
+ import cmd_list
19
+ import cmd_projects
20
+ import cmd_prune
21
+ import cmd_search
22
+ import cmd_stats
23
+ import cmd_todos
24
+ import cmd_tree
25
+ import cmd_vacuum
26
+
27
+ # Словарь {имя_команды: модуль}
28
+ # Модуль должен иметь register(subparsers) и run(args, db)
29
+ COMMANDS = {
30
+ "list": cmd_list,
31
+ "info": cmd_info,
32
+ "export": cmd_export,
33
+ "delete": cmd_delete,
34
+ "costs": cmd_costs,
35
+ "prune": cmd_prune,
36
+ "search": cmd_search,
37
+ "tree": cmd_tree,
38
+ "projects": cmd_projects,
39
+ "stats": cmd_stats,
40
+ "todos": cmd_todos,
41
+ "vacuum": cmd_vacuum,
42
+ "help": cmd_help,
43
+ }
44
+
45
+
46
+ def get_handler(name):
47
+ return COMMANDS.get(name)
48
+
49
+
50
+ def get_all():
51
+ return COMMANDS
config.py ADDED
@@ -0,0 +1,14 @@
1
+ """Конфигурация и константы opencode-db.
2
+
3
+ Здесь централизованы пути и общие настройки.
4
+ Стоимость сессий берётся напрямую из БД (session.cost), а не рассчитывается.
5
+ """
6
+
7
+ import os
8
+
9
+ # --- Пути ---
10
+ # База данных opencode. Стандартное расположение для Linux/macOS.
11
+ OPENCODE_DB = os.path.expanduser("~/.local/share/opencode/opencode.db")
12
+
13
+ # Директории, игнорируемые при сборе snapshot'а проекта
14
+ IGNORE_DIRS = {".git", "node_modules", ".venv", "__pycache__", ".opencode"}