opencode-chat-exporter 1.0.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.
chat_exporter/cli.py ADDED
@@ -0,0 +1,194 @@
1
+ import argparse
2
+ import re
3
+ import sys
4
+ import time
5
+ from pathlib import Path
6
+ from typing import Sequence
7
+
8
+ from . import __version__
9
+ from .db import SOURCES, make_db
10
+ from .render import Exporter
11
+
12
+
13
+ def _safe_title(title: str) -> str:
14
+ safe = re.sub(r'[\\/:*?"<>|]', "_", title)
15
+ safe = safe.strip().strip(".")
16
+ return safe[:120] or "untitled"
17
+
18
+
19
+ def _make_exporter(args) -> Exporter:
20
+ return Exporter(
21
+ make_db(args.source, args.db),
22
+ include_reasoning=args.reasoning,
23
+ include_metadata=args.metadata,
24
+ output_dir=Path(getattr(args, "output_dir", None) or "."),
25
+ max_output=args.max_output,
26
+ )
27
+
28
+
29
+ def _pick(db, args) -> list[dict]:
30
+ sessions = db.list_sessions(include_archived=args.archived)
31
+ if args.all:
32
+ return sessions
33
+ if not sessions:
34
+ print("No sessions found.")
35
+ sys.exit(1)
36
+ if args.session:
37
+ by_id = [s for s in sessions if s["id"] == args.session]
38
+ if by_id:
39
+ return by_id
40
+ try:
41
+ idx = int(args.session) - 1
42
+ except ValueError:
43
+ idx = -1
44
+ if 0 <= idx < len(sessions):
45
+ return [sessions[idx]]
46
+ by_title = [s for s in sessions if args.session.lower() in (s["title"] or "").lower()]
47
+ if by_title:
48
+ return by_title
49
+ print(f"Session not found: {args.session}")
50
+ print("Use `list` to see available sessions, or pass a numeric index.")
51
+ sys.exit(1)
52
+ if args.latest:
53
+ return [sessions[0]]
54
+ print("Specify `--session <id|index|title>`, `--latest`, or `--all`.")
55
+ sys.exit(1)
56
+
57
+
58
+ def cmd_list(args) -> int:
59
+ db = make_db(args.source, args.db)
60
+ sessions = db.list_sessions(include_archived=args.archived)
61
+ if not sessions:
62
+ print("No sessions found.")
63
+ return 0
64
+ print(f"{'#':<4} {'created':<17} {'msgs':<6} {'parts':<7} title")
65
+ for i, s in enumerate(sessions, 1):
66
+ created = db.ts(s["time_created"])
67
+ created_s = created.strftime("%Y-%m-%d %H:%M") if created else "-"
68
+ print(f"{i:<4} {created_s:<17} {s['message_count']:<6} {s['part_count']:<7} {s['title'] or '(untitled)'}")
69
+ print()
70
+ print("IDs:")
71
+ for s in sessions:
72
+ print(f" {s['id']}")
73
+ return 0
74
+
75
+
76
+ def cmd_export(args) -> int:
77
+ db = make_db(args.source, args.db)
78
+ exporter = _make_exporter(args)
79
+ if args.all and args.one:
80
+ md = exporter.render_all(include_archived=args.archived)
81
+ out = Path(args.output or "all-conversations.md")
82
+ out.write_text(md, encoding="utf-8")
83
+ print(f"exported -> {out} ({len(md):,} chars)")
84
+ return 0
85
+ for s in _pick(db, args):
86
+ md = exporter.render_session(s["id"])
87
+ out = Path(args.output or f"{_safe_title(s['title'] or s['id'])}.md")
88
+ out.write_text(md, encoding="utf-8")
89
+ print(f"exported -> {out} ({len(md):,} chars, {s['message_count']} messages)")
90
+ return 0
91
+
92
+
93
+ def cmd_export_all(args) -> int:
94
+ db = make_db(args.source, args.db)
95
+ exporter = _make_exporter(args)
96
+ sessions = db.list_sessions(include_archived=args.archived)
97
+ if not sessions:
98
+ print("No sessions found.")
99
+ return 0
100
+ out_dir = Path(args.output_dir or "chats")
101
+ out_dir.mkdir(parents=True, exist_ok=True)
102
+ for s in sessions:
103
+ md = exporter.render_session(s["id"])
104
+ out = out_dir / f"{_safe_title(s['title'] or s['id'])}.md"
105
+ out.write_text(md, encoding="utf-8")
106
+ print(f"exported -> {out}")
107
+ return 0
108
+
109
+
110
+ def _watch_refresh(db, exporter: Exporter, out_dir: Path, show: bool) -> None:
111
+ out_dir.mkdir(parents=True, exist_ok=True)
112
+ for s in db.list_sessions():
113
+ md = exporter.render_session(s["id"])
114
+ out = out_dir / f"{_safe_title(s['title'] or s['id'])}.md"
115
+ out.write_text(md, encoding="utf-8")
116
+ if show:
117
+ print(f"updated -> {out}")
118
+
119
+
120
+ def cmd_watch(args) -> int:
121
+ db = make_db(args.source, args.db)
122
+ exporter = _make_exporter(args)
123
+ out_dir = Path(args.output_dir or "chats")
124
+ print(f"Watching {db.db_path} every {args.poll}s -> {out_dir}")
125
+ last = None
126
+ while True:
127
+ try:
128
+ mtime = db.db_path.stat().st_mtime
129
+ except OSError:
130
+ mtime = None
131
+ if mtime != last:
132
+ last = mtime
133
+ _watch_refresh(db, exporter, out_dir, show=True)
134
+ time.sleep(args.poll)
135
+
136
+
137
+ def build_parser() -> argparse.ArgumentParser:
138
+ p = argparse.ArgumentParser(
139
+ prog="chat_exporter",
140
+ description="Export full OpenCode conversations from the local database into one continuous Markdown transcript.",
141
+ formatter_class=argparse.RawDescriptionHelpFormatter,
142
+ )
143
+ p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
144
+ p.add_argument("--source", choices=SOURCES, default="opencode",
145
+ help="conversation source (default: opencode)")
146
+ p.add_argument("--db", default=None,
147
+ help="path to opencode.db, or (with --source antigravity) the AntiGravity "
148
+ "folder, its conversations\\ subfolder, or a single .db file")
149
+ sub = p.add_subparsers(dest="command", required=True)
150
+
151
+ lp = sub.add_parser("list", help="list available sessions")
152
+ lp.add_argument("--archived", action="store_true")
153
+ lp.set_defaults(func=cmd_list)
154
+
155
+ ep = sub.add_parser("export", help="export one session to a single Markdown file")
156
+ ep.add_argument("--session", help="session ID, numeric index, or title fragment")
157
+ ep.add_argument("--latest", action="store_true")
158
+ ep.add_argument("--all", action="store_true")
159
+ ep.add_argument("--one", action="store_true", help="with --all: write every session into one combined file")
160
+ ep.add_argument("--archived", action="store_true")
161
+ ep.add_argument("--reasoning", action="store_true", help="include reasoning parts")
162
+ ep.add_argument("--metadata", action="store_true", help="include raw metadata parts")
163
+ ep.add_argument("--max-output", type=int, default=None, help="truncate tool outputs to N chars")
164
+ ep.add_argument("--output", "-o", default=None, help="output .md file path")
165
+ ep.set_defaults(func=cmd_export)
166
+
167
+ ap = sub.add_parser("export-all", help="export every session to its own Markdown file")
168
+ ap.add_argument("--output-dir", default="chats", help="output directory (default: chats)")
169
+ ap.add_argument("--archived", action="store_true")
170
+ ap.add_argument("--reasoning", action="store_true")
171
+ ap.add_argument("--metadata", action="store_true")
172
+ ap.add_argument("--max-output", type=int, default=None)
173
+ ap.set_defaults(func=cmd_export_all)
174
+
175
+ wp = sub.add_parser("watch", help="continuously re-export all sessions on database changes")
176
+ wp.add_argument("--output-dir", default="chats")
177
+ wp.add_argument("--poll", type=int, default=5, help="poll interval in seconds (default: 5)")
178
+ wp.add_argument("--archived", action="store_true")
179
+ wp.add_argument("--reasoning", action="store_true")
180
+ wp.add_argument("--metadata", action="store_true")
181
+ wp.add_argument("--max-output", type=int, default=None)
182
+ wp.set_defaults(func=cmd_watch)
183
+
184
+ return p
185
+
186
+
187
+ def main(argv: Sequence[str] | None = None) -> int:
188
+ parser = build_parser()
189
+ args = parser.parse_args(argv)
190
+ return args.func(args)
191
+
192
+
193
+ if __name__ == "__main__":
194
+ sys.exit(main())
chat_exporter/db.py ADDED
@@ -0,0 +1,149 @@
1
+ import json
2
+ import os
3
+ import sqlite3
4
+ from datetime import datetime, timezone
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ DEFAULT_DB = Path(os.path.expandvars(r"%USERPROFILE%\.local\share\opencode\opencode.db"))
9
+
10
+ SOURCES = ("opencode", "antigravity")
11
+
12
+
13
+ def make_db(source: str, path: str | Path | None = None):
14
+ """Build the right reader for a conversation source.
15
+
16
+ ``opencode`` uses the OpenCode SQLite database; ``antigravity`` scans the
17
+ AntiGravity folder (conversations + readable JSON transcripts).
18
+ """
19
+ if source == "antigravity":
20
+ from .antigravity import DEFAULT_AG_DIR, AntiGravityDB
21
+ return AntiGravityDB(Path(path) if path else DEFAULT_AG_DIR)
22
+ return OpenCodeDB(Path(path) if path else DEFAULT_DB)
23
+
24
+
25
+ class OpenCodeDB:
26
+ """Read-only access to the OpenCode local SQLite database."""
27
+
28
+ def __init__(self, db_path: Path | None = None) -> None:
29
+ self.db_path = Path(db_path) if db_path else DEFAULT_DB
30
+ if not self.db_path.exists():
31
+ raise FileNotFoundError(f"OpenCode database not found: {self.db_path}")
32
+
33
+ def connect(self) -> sqlite3.Connection:
34
+ con = sqlite3.connect(f"file:{self.db_path}?mode=ro", uri=True, timeout=30)
35
+ con.row_factory = sqlite3.Row
36
+ return con
37
+
38
+ # ------------------------------------------------------------------ sessions
39
+ def list_sessions(self, include_archived: bool = False) -> list[dict]:
40
+ sql = """
41
+ SELECT s.id, s.title, s.directory, s.agent, s.model, s.time_created,
42
+ s.time_updated, s.time_archived, s.cost, s.tokens_input,
43
+ s.tokens_output, s.tokens_reasoning,
44
+ (SELECT COUNT(*) FROM message m WHERE m.session_id = s.id) AS message_count,
45
+ (SELECT COUNT(*) FROM part p WHERE p.session_id = s.id) AS part_count
46
+ FROM session s
47
+ """
48
+ if not include_archived:
49
+ sql += " WHERE s.time_archived IS NULL"
50
+ sql += " ORDER BY s.time_updated DESC"
51
+
52
+ rows = []
53
+ with self.connect() as con:
54
+ for r in con.execute(sql):
55
+ rows.append(self._row_to_dict(r))
56
+ return rows
57
+
58
+ def get_session(self, session_id: str) -> dict | None:
59
+ sql = """
60
+ SELECT s.*, p.name AS project_name, p.worktree AS project_worktree
61
+ FROM session s
62
+ LEFT JOIN project p ON p.id = s.project_id
63
+ WHERE s.id = ?
64
+ """
65
+ with self.connect() as con:
66
+ r = con.execute(sql, (session_id,)).fetchone()
67
+ return self._row_to_dict(r) if r else None
68
+
69
+ # ------------------------------------------------------------------ messages
70
+ def get_messages(self, session_id: str) -> list[dict]:
71
+ sql = """
72
+ SELECT id, time_created, time_updated, data
73
+ FROM message
74
+ WHERE session_id = ?
75
+ ORDER BY time_created ASC, rowid ASC
76
+ """
77
+ out = []
78
+ with self.connect() as con:
79
+ for r in con.execute(sql, (session_id,)):
80
+ try:
81
+ data = json.loads(r["data"])
82
+ except Exception:
83
+ data = {}
84
+ out.append(
85
+ {
86
+ "id": r["id"],
87
+ "time_created": r["time_created"],
88
+ "time_updated": r["time_updated"],
89
+ "data": data,
90
+ "parts": [],
91
+ }
92
+ )
93
+ return out
94
+
95
+ # --------------------------------------------------------------------- parts
96
+ def get_parts_for_messages(self, message_ids: list[str]) -> dict[str, list[dict]]:
97
+ """Return all parts grouped by message_id, ordered by time_created."""
98
+ if not message_ids:
99
+ return {}
100
+ holder = ",".join("?" for _ in message_ids)
101
+ sql = f"""
102
+ SELECT id, message_id, time_created, time_updated, data
103
+ FROM part
104
+ WHERE message_id IN ({holder})
105
+ ORDER BY time_created ASC, rowid ASC
106
+ """
107
+ grouped: dict[str, list[dict]] = {mid: [] for mid in message_ids}
108
+ with self.connect() as con:
109
+ for r in con.execute(sql, message_ids):
110
+ try:
111
+ data = json.loads(r["data"])
112
+ except Exception:
113
+ data = {}
114
+ grouped.setdefault(r["message_id"], []).append(
115
+ {
116
+ "id": r["id"],
117
+ "message_id": r["message_id"],
118
+ "time_created": r["time_created"],
119
+ "time_updated": r["time_updated"],
120
+ "data": data,
121
+ }
122
+ )
123
+ return grouped
124
+
125
+ def get_session_messages(self, session_id: str) -> list[dict]:
126
+ messages = self.get_messages(session_id)
127
+ parts = self.get_parts_for_messages([m["id"] for m in messages])
128
+ for m in messages:
129
+ m["parts"] = parts.get(m["id"], [])
130
+ return messages
131
+
132
+ # ------------------------------------------------------------------- helpers
133
+ @staticmethod
134
+ def _row_to_dict(r: sqlite3.Row) -> dict:
135
+ d = {k: r[k] for k in r.keys()}
136
+ for key in ("model", "metadata", "summary_diffs", "revert", "permission"):
137
+ if isinstance(d.get(key), str):
138
+ try:
139
+ d[key] = json.loads(d[key])
140
+ except Exception:
141
+ pass
142
+ return d
143
+
144
+ @staticmethod
145
+ def ts(ms: int | None) -> datetime | None:
146
+ if not isinstance(ms, int) or ms <= 0:
147
+ return None
148
+ t = datetime.fromtimestamp(ms / 1000, tz=timezone.utc).astimezone()
149
+ return t