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/__init__.py +2 -0
- chat_exporter/__main__.py +16 -0
- chat_exporter/antigravity.py +599 -0
- chat_exporter/cli.py +194 -0
- chat_exporter/db.py +149 -0
- chat_exporter/gui.py +546 -0
- chat_exporter/render.py +272 -0
- opencode_chat_exporter-1.0.0.dist-info/METADATA +109 -0
- opencode_chat_exporter-1.0.0.dist-info/RECORD +13 -0
- opencode_chat_exporter-1.0.0.dist-info/WHEEL +5 -0
- opencode_chat_exporter-1.0.0.dist-info/entry_points.txt +2 -0
- opencode_chat_exporter-1.0.0.dist-info/licenses/LICENSE +21 -0
- opencode_chat_exporter-1.0.0.dist-info/top_level.txt +1 -0
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
|