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.
@@ -0,0 +1,272 @@
1
+ import base64
2
+ import json
3
+ import re
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from .db import OpenCodeDB
8
+
9
+ ROLE_LABELS = {
10
+ "user": "USER",
11
+ "assistant": "ASSISTANT",
12
+ "system": "SYSTEM",
13
+ "tool": "TOOL",
14
+ }
15
+
16
+
17
+ def _ts(db: OpenCodeDB, ms: int | None) -> str:
18
+ t = db.ts(ms)
19
+ return t.strftime("%Y-%m-%d %H:%M:%S") if t else ""
20
+
21
+
22
+ def _fence(text: str) -> str:
23
+ for f in ("```", "~~~"):
24
+ if f not in text:
25
+ return f
26
+ return "````"
27
+
28
+
29
+ def _is_base64url(value: Any) -> bool:
30
+ return isinstance(value, str) and value.startswith("data:")
31
+
32
+
33
+ def _pretty_json(obj: Any) -> str:
34
+ try:
35
+ return json.dumps(obj, ensure_ascii=False, indent=2, default=str)
36
+ except Exception:
37
+ return str(obj)
38
+
39
+
40
+ def _safe_filename(name: str) -> str:
41
+ return re.sub(r'[\\/:*?"<>|]', "_", name)
42
+
43
+
44
+ def _tool_title(state: dict) -> str:
45
+ """One-line human summary of a tool call from its input."""
46
+ inp = state.get("input") or {}
47
+ for key in ("command", "pattern", "filePath", "path", "url", "query"):
48
+ if inp.get(key) is not None:
49
+ return str(inp[key])
50
+ return ""
51
+
52
+
53
+ class Exporter:
54
+ def __init__(
55
+ self,
56
+ db: OpenCodeDB,
57
+ include_reasoning: bool = False,
58
+ include_metadata: bool = False,
59
+ output_dir: Path | None = None,
60
+ max_output: int | None = None,
61
+ ) -> None:
62
+ self.db = db
63
+ self.include_reasoning = include_reasoning
64
+ self.include_metadata = include_metadata
65
+ self.output_dir = output_dir
66
+ self.max_output = max_output
67
+ self.attachments_dir = (output_dir / "attachments") if output_dir else None
68
+
69
+ # ---------------------------------------------------------------- sessions
70
+ def render_session(self, session_id: str) -> str:
71
+ session = self.db.get_session(session_id)
72
+ if session is None:
73
+ raise KeyError(f"session not found: {session_id}")
74
+ messages = self.db.get_session_messages(session_id)
75
+
76
+ out: list[str] = []
77
+ self._header(out, session)
78
+ out.append("")
79
+ out.append("---")
80
+ out.append("")
81
+
82
+ n_user = n_asst = 0
83
+ for msg in messages:
84
+ role = msg["data"].get("role")
85
+ if role == "user":
86
+ n_user += 1
87
+ elif role == "assistant":
88
+ n_asst += 1
89
+ self._message(out, msg)
90
+
91
+ if n_user == 0 and n_asst == 0:
92
+ out.append("_(no messages in this session)_")
93
+
94
+ return "\n".join(out).rstrip() + "\n"
95
+
96
+ def render_all(self, include_archived: bool = False) -> str:
97
+ chunks = []
98
+ for s in self.db.list_sessions(include_archived=include_archived):
99
+ chunks.append(self.render_session(s["id"]))
100
+ chunks.append("\n\n---\n\n# ┄┄┄ NEXT SESSION ┄┄┄\n\n")
101
+ return "\n".join(chunks).rstrip() + "\n"
102
+
103
+ # ------------------------------------------------------------------ header
104
+ def _header(self, out: list[str], s: dict) -> None:
105
+ title = s.get("title") or "Untitled session"
106
+ out.append(f"# {title}")
107
+ out.append("")
108
+ out.append(f"> Session `{s.get('id')}`")
109
+ agent = s.get("agent")
110
+ model = s.get("model")
111
+ if isinstance(model, dict):
112
+ bits = [x for x in (model.get("providerID"), model.get("modelID")) if x]
113
+ model = " · ".join(bits)
114
+ if agent:
115
+ out.append(f"**Agent:** `{agent}`")
116
+ if model:
117
+ out.append(f"**Model:** `{model}`")
118
+ directory = s.get("directory")
119
+ if directory:
120
+ out.append(f"**Directory:** `{directory}`")
121
+ created = _ts(self.db, s.get("time_created"))
122
+ updated = _ts(self.db, s.get("time_updated"))
123
+ if created:
124
+ out.append(f"**Created:** {created}")
125
+ if updated:
126
+ out.append(f"**Updated:** {updated}")
127
+ share_url = s.get("share_url")
128
+ if share_url:
129
+ out.append(f"**Share:** {share_url}")
130
+ total = (int(s.get("tokens_input") or 0) + int(s.get("tokens_output") or 0)
131
+ + int(s.get("tokens_reasoning") or 0))
132
+ if total:
133
+ out.append(f"**Tokens:** {total:,}")
134
+ cost = s.get("cost")
135
+ if isinstance(cost, (int, float)) and cost:
136
+ out.append(f"**Cost:** ${float(cost):.4f}")
137
+ out.append("")
138
+
139
+ # ---------------------------------------------------------------- messages
140
+ def _message(self, out: list[str], msg: dict) -> None:
141
+ role = msg["data"].get("role", "unknown")
142
+ label = ROLE_LABELS.get(role, role.upper())
143
+ header = f"### {label}"
144
+ t = _ts(self.db, msg["time_created"])
145
+ if t:
146
+ header += f" `{t}`"
147
+ model = msg["data"].get("model")
148
+ if isinstance(model, dict) and model.get("modelID"):
149
+ header += f" · {model['modelID']}"
150
+ out.append(header)
151
+ out.append("")
152
+
153
+ for part in msg["parts"]:
154
+ data = part["data"]
155
+ ptype = data.get("type", "")
156
+ if ptype == "text":
157
+ text = str(data.get("text", "")).strip()
158
+ if text:
159
+ out.append(text)
160
+ out.append("")
161
+ elif ptype == "reasoning":
162
+ if self.include_reasoning:
163
+ rtext = str(data.get("text", "")).strip()
164
+ if rtext:
165
+ out.append("<details><summary>🤔 reasoning</summary>")
166
+ out.append("")
167
+ out.append(rtext)
168
+ out.append("")
169
+ out.append("</details>")
170
+ out.append("")
171
+ elif ptype == "tool":
172
+ self._tool(out, data)
173
+ elif ptype == "file":
174
+ self._file(out, data)
175
+ elif ptype == "compaction":
176
+ out.append("> 🧹 *context compacted*")
177
+ out.append("")
178
+ elif ptype in ("step-start", "step-finish"):
179
+ pass
180
+ else:
181
+ if self.include_metadata:
182
+ out.append(f"<!-- part type: {ptype} -->")
183
+ out.append(_pretty_json(data))
184
+ out.append("")
185
+
186
+ out.append("")
187
+ out.append("---")
188
+ out.append("")
189
+
190
+ # ------------------------------------------------------------------- tools
191
+ def _tool(self, out: list[str], data: dict) -> None:
192
+ tool = data.get("tool") or data.get("name") or "tool"
193
+ state = data.get("state") or {}
194
+ status = state.get("status", "")
195
+ inp = state.get("input") or {}
196
+ out_value = state.get("output")
197
+ metadata = state.get("metadata") if isinstance(state.get("metadata"), dict) else {}
198
+
199
+ icon = {"completed": "✅", "error": "⛔", "cancelled": "🚫"}.get(status, "⏳")
200
+ out.append(f"#### {icon} Tool: `{tool}` · `{status}`")
201
+ out.append("")
202
+
203
+ title = _tool_title(state)
204
+ if title:
205
+ out.append(f"**Input:**")
206
+ out.append("")
207
+ out.append(self._fence_block(title))
208
+ out.append("")
209
+ elif inp:
210
+ out.append(f"**Input:**")
211
+ out.append("")
212
+ out.append(self._fence_block(_pretty_json(inp)))
213
+ out.append("")
214
+
215
+ text = self._format_output(out_value, tool)
216
+ if text:
217
+ out.append("**Output:**")
218
+ out.append("")
219
+ out.append(self._fence_block(text))
220
+ out.append("")
221
+
222
+ if metadata.get("exit") is not None:
223
+ out.append(f"*exit code: {metadata['exit']}*")
224
+ out.append("")
225
+
226
+ def _format_output(self, out_value: Any, tool: str) -> str | None:
227
+ if out_value is None or out_value == "":
228
+ return None
229
+ if _is_base64url(out_value):
230
+ try:
231
+ b64 = out_value.split(",", 1)[1]
232
+ return base64.b64decode(b64).decode("utf-8", errors="replace")
233
+ except Exception:
234
+ return None
235
+ if isinstance(out_value, (dict, list)):
236
+ text = _pretty_json(out_value)
237
+ else:
238
+ text = str(out_value)
239
+ if self.max_output and len(text) > self.max_output:
240
+ text = text[: self.max_output] + "\n…[output truncated]"
241
+ return text
242
+
243
+ def _fence_block(self, text: str) -> str:
244
+ f = _fence(text)
245
+ return f"{f}\n{text}\n{f}"
246
+
247
+ # ------------------------------------------------------------------- files
248
+ def _file(self, out: list[str], data: dict) -> None:
249
+ filename = data.get("filename") or "(attachment)"
250
+ mime = data.get("mime") or ""
251
+ url = data.get("url") or ""
252
+ line = f"#### 📎 {filename}"
253
+ if mime:
254
+ line += f" `{mime}`"
255
+ out.append(line)
256
+ out.append("")
257
+
258
+ if self.attachments_dir and _is_base64url(url):
259
+ try:
260
+ b64 = url.split(",", 1)[1]
261
+ raw = base64.b64decode(b64)
262
+ dest = self.attachments_dir / _safe_filename(filename)
263
+ dest.parent.mkdir(parents=True, exist_ok=True)
264
+ dest.write_bytes(raw)
265
+ out.append(f"- saved to `{dest}`")
266
+ except Exception as e:
267
+ out.append(f"- *could not save attachment: {e}*")
268
+ elif url.startswith("http"):
269
+ out.append(f"- `{url}`")
270
+ elif url:
271
+ out.append(f"- `{url[:200]}`")
272
+ out.append("")
@@ -0,0 +1,109 @@
1
+ Metadata-Version: 2.4
2
+ Name: opencode-chat-exporter
3
+ Version: 1.0.0
4
+ Summary: Export OpenCode / AntiGravity local chats to a single connected Markdown file (GUI + CLI)
5
+ Author: nasro2123
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/nasro2123/chat-exporter
8
+ Project-URL: Repository, https://github.com/nasro2123/chat-exporter
9
+ Project-URL: Issues, https://github.com/nasro2123/chat-exporter/issues
10
+ Keywords: opencode,antigravity,chat,export,markdown,cli
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: Microsoft :: Windows
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Topic :: Utilities
22
+ Requires-Python: >=3.10
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Requires-Dist: customtkinter==5.2.2
26
+ Dynamic: license-file
27
+
28
+ # Chat Exporter
29
+
30
+ A Python tool that exports your local chats — from **OpenCode (Desktop)** or **AntiGravity (Google)** — into one **single connected Markdown file**, preserving every detail verbatim: user and assistant messages, CMD / PowerShell commands, tool outputs, file & search operations, and attachments.
31
+
32
+ ## Features
33
+
34
+ - **Two chat sources**: OpenCode or AntiGravity (switch from the GUI or CLI)
35
+ - **Simple, clean GUI** (CustomTkinter) to browse and preview conversations
36
+ - **Live preview** with color-coded messages (you / assistant / tools)
37
+ - **Full export** of each conversation into one connected Markdown file — not separate sections
38
+ - Export all conversations to a folder, or everything into a single file
39
+ - **Live watch mode**: automatically re-export when the source updates
40
+ - Fully equivalent command-line interface
41
+
42
+ ## Requirements
43
+
44
+ - Python 3.10+
45
+
46
+ ## Building the EXE
47
+
48
+ **Easiest way:** double-click `build.bat`
49
+
50
+ Or from PowerShell:
51
+
52
+ ```powershell
53
+ .\build.ps1 # onedir build (faster startup)
54
+ .\build.ps1 -OneFile # single-file exe (easier to share)
55
+ ```
56
+
57
+ The script handles everything automatically: creating a `.venv`, installing dependencies, then building with PyInstaller. Output goes to `dist\`.
58
+
59
+ ## Usage
60
+
61
+ ```
62
+ pip install -r requirements.txt
63
+
64
+ # GUI
65
+ python run.py
66
+
67
+ # CLI — OpenCode (default)
68
+ python -m chat_exporter --cli list
69
+ python -m chat_exporter --cli export --latest
70
+
71
+ # CLI — AntiGravity
72
+ python -m chat_exporter --cli --source antigravity list
73
+ python -m chat_exporter --cli --source antigravity export --latest
74
+ ```
75
+
76
+ ### Default locations
77
+
78
+ | Source | Path |
79
+ |---|---|
80
+ | OpenCode | `%USERPROFILE%\.local\share\opencode\opencode.db` |
81
+ | AntiGravity | `%USERPROFILE%\.gemini\antigravity` |
82
+
83
+ The path can be changed from the "…" button in the GUI or via `--db` (for AntiGravity, pass the `antigravity` folder containing `conversations\` and `brain\`).
84
+
85
+ ### How does AntiGravity reading work?
86
+
87
+ Conversations are stored in a SQLite database with a `step_payload` column encoded as **protobuf**. The reader first tries the plain-text `transcript.jsonl` generated by the app under `brain\<uuid>\.system_generated\logs\` (easy and readable); if not found, it decodes the protobuf directly from the database. Both paths produce identical output.
88
+
89
+ ## Project structure
90
+
91
+ ```
92
+ Chat Exporter/
93
+ ├── chat_exporter/
94
+ │ ├── db.py # OpenCode database reader (sessions / messages / parts) + factory
95
+ │ ├── antigravity.py # AntiGravity reader (transcript.jsonl + protobuf fallback)
96
+ │ ├── render.py # Conversation -> full Markdown renderer
97
+ │ ├── gui.py # Graphical interface
98
+ │ ├── cli.py # Command-line interface
99
+ │ └── __main__.py
100
+ ├── run.py # Launch the GUI
101
+ ├── run_gui.py
102
+ ├── build.bat # One-click EXE build
103
+ ├── build.ps1 # Build script (auto venv + PyInstaller)
104
+ └── requirements.txt
105
+ ```
106
+
107
+ ## Privacy
108
+
109
+ Everything runs locally — the tool reads data on your machine and writes Markdown files only. Nothing is sent to the internet. Review exported files before sharing them; they may contain sensitive paths and data from your sessions.
@@ -0,0 +1,13 @@
1
+ chat_exporter/__init__.py,sha256=ZE9dmbrNoJ7tBAOISIu5mK8xZEOzBgVYYxOAKtRphkg,109
2
+ chat_exporter/__main__.py,sha256=s--TO_TfzC1TZRCuZtu4HQBObfAnv_hIVhWAgHmHpLU,447
3
+ chat_exporter/antigravity.py,sha256=ozmz2HImVRCRBS1wr50geNFohNEUWRR-cWSgidM9rMw,21876
4
+ chat_exporter/cli.py,sha256=E1aJan1WUnQ2tmmxxWuN2pKdpXIv4SD7NYGzUa25nZ0,7341
5
+ chat_exporter/db.py,sha256=1SEW9dY4K9uTGZpAYEH6J9dvbaGXHuAIJkZyODVZQxo,5756
6
+ chat_exporter/gui.py,sha256=jMgcvVnuMihFYVKqhOxFR7qxdAjZ2JcZaxX1Y1ZxFmI,22034
7
+ chat_exporter/render.py,sha256=PDXoglDjMj8Qquv60ufWJcDfz_MpJkI7a0KaZ0ImwIM,9502
8
+ opencode_chat_exporter-1.0.0.dist-info/licenses/LICENSE,sha256=WKBSpMZp6NRMM0kGUKXFH2OpDKtSQJtnaOvte0bETOo,1066
9
+ opencode_chat_exporter-1.0.0.dist-info/METADATA,sha256=eYINvNg2MQa747qJvJJ9IvHvOgQ4ai6sOik9snMbhSs,4450
10
+ opencode_chat_exporter-1.0.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
11
+ opencode_chat_exporter-1.0.0.dist-info/entry_points.txt,sha256=T_EglGYkzIcXoeQqbELA6pi5O6Vk90gGNG8Dn_bGRVg,66
12
+ opencode_chat_exporter-1.0.0.dist-info/top_level.txt,sha256=X2tacK0ysdURlsoPuW4TzGvcDt2cXhbHPlto5Qc5WQE,14
13
+ opencode_chat_exporter-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ opencode-chat-exporter = chat_exporter.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 nasro2123
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ chat_exporter