opencode-chat-export 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,22 @@
1
+ """
2
+ opencode-chat-export — MCP server to export OpenCode chat history to Markdown.
3
+
4
+ Install:
5
+ pip install opencode-chat-export
6
+
7
+ Add to ~/.config/opencode/opencode.jsonc:
8
+ "chat-export": {
9
+ "type": "local",
10
+ "command": ["opencode-chat-export"],
11
+ "enabled": false
12
+ }
13
+
14
+ CLI usage:
15
+ opencode-chat-export list
16
+ opencode-chat-export export <session_id>
17
+ opencode-chat-export export-all
18
+ """
19
+
20
+ from .server import main
21
+
22
+ __all__ = ["main"]
@@ -0,0 +1,505 @@
1
+ """MCP server for exporting OpenCode chat history to Markdown."""
2
+
3
+ import sqlite3
4
+ import json
5
+ import os
6
+ import re
7
+ import sys
8
+ import argparse
9
+ from datetime import datetime, timezone
10
+ from pathlib import Path
11
+
12
+ # ---- config ----
13
+
14
+ def _find_db() -> str:
15
+ """Locate the opencode.db file across known install locations."""
16
+ candidates = [
17
+ os.path.expanduser(r"~\AppData\Roaming\ai.opencode.desktop\opencode.db"),
18
+ os.path.expanduser(r"~\.local\share\opencode\opencode.db"),
19
+ ]
20
+ for p in candidates:
21
+ if os.path.exists(p):
22
+ return p
23
+ # fallback to first candidate
24
+ return candidates[0]
25
+
26
+ DB_PATH = _find_db()
27
+ OUTPUT_DIR = os.path.expanduser("~/opencode-exports")
28
+
29
+
30
+ # ---- data access ----
31
+
32
+ def get_db() -> sqlite3.Connection:
33
+ conn = sqlite3.connect(DB_PATH)
34
+ conn.row_factory = sqlite3.Row
35
+ return conn
36
+
37
+
38
+ def get_session_tree(session_id: str) -> list[dict]:
39
+ """Build a chronologically ordered conversation with all parts."""
40
+ conn = get_db()
41
+ c = conn.cursor()
42
+
43
+ c.execute(
44
+ "SELECT id, data, time_created FROM message WHERE session_id = ? ORDER BY time_created",
45
+ (session_id,),
46
+ )
47
+ rows = c.fetchall()
48
+
49
+ msg_map: dict[str, dict] = {}
50
+ for r in rows:
51
+ data = json.loads(r["data"])
52
+ msg_map[r["id"]] = {
53
+ "id": r["id"],
54
+ "role": data.get("role", "unknown"),
55
+ "parent_id": data.get("parentID"),
56
+ "time_created": r["time_created"],
57
+ "agent": data.get("agent", ""),
58
+ "model_id": data.get("modelID", ""),
59
+ "finish": data.get("finish", ""),
60
+ "cost": data.get("cost", 0),
61
+ "tokens": data.get("tokens", {}),
62
+ "mode": data.get("mode", ""),
63
+ "parts": [],
64
+ }
65
+
66
+ msg_ids = list(msg_map.keys())
67
+ if msg_ids:
68
+ placeholders = ",".join("?" for _ in msg_ids)
69
+ c.execute(
70
+ f"SELECT message_id, data, time_created FROM part WHERE message_id IN ({placeholders}) ORDER BY time_created",
71
+ msg_ids,
72
+ )
73
+ for r in c.fetchall():
74
+ pdata = json.loads(r["data"])
75
+ ptype = pdata.get("type", "unknown")
76
+ entry = {"type": ptype, "data": pdata, "time": r["time_created"]}
77
+ if r["message_id"] in msg_map:
78
+ msg_map[r["message_id"]]["parts"].append(entry)
79
+
80
+ conn.close()
81
+
82
+ return sorted(msg_map.values(), key=lambda m: m["time_created"] or 0)
83
+
84
+
85
+ # ---- markdown rendering ----
86
+
87
+ def fmt_ts(ms: int | None) -> str:
88
+ if ms:
89
+ return datetime.fromtimestamp(ms / 1000, tz=timezone.utc).strftime("%H:%M:%S")
90
+ return ""
91
+
92
+
93
+ def render_session_md(session: dict, messages: list[dict]) -> str:
94
+ """Render a full session to Markdown."""
95
+ title = session.get("title", "Untitled")
96
+ slug = session.get("slug", "")
97
+ model_raw = session.get("model", "{}")
98
+ if isinstance(model_raw, str):
99
+ try:
100
+ model_name = json.loads(model_raw).get("id", model_raw)
101
+ except json.JSONDecodeError:
102
+ model_name = model_raw
103
+ else:
104
+ model_name = str(model_raw)
105
+
106
+ agent = session.get("agent", "")
107
+ cost = session.get("cost", 0)
108
+ tokens_in = session.get("tokens_input", 0)
109
+ tokens_out = session.get("tokens_output", 0)
110
+ ts = session.get("time_created", 0)
111
+ date_str = (
112
+ datetime.fromtimestamp(ts / 1000, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
113
+ if ts else "unknown"
114
+ )
115
+
116
+ lines = [
117
+ f"# {title}",
118
+ "",
119
+ f"- **Date:** {date_str}",
120
+ f"- **Agent:** {agent}",
121
+ f"- **Model:** {model_name}",
122
+ f"- **Cost:** ${cost:.6f}" if cost else "- **Cost:** N/A",
123
+ f"- **Tokens:** {tokens_in:,} in / {tokens_out:,} out",
124
+ ]
125
+ if slug:
126
+ lines.append(f"- **Slug:** {slug}")
127
+ lines.extend(["", "---", ""])
128
+
129
+ for msg in messages:
130
+ role = msg["role"]
131
+ ts_str = fmt_ts(msg["time_created"])
132
+ agent_label = msg.get("agent", "")
133
+ label = f"### {role.title()}"
134
+ if agent_label and role == "assistant":
135
+ label += f" ({agent_label})"
136
+ if ts_str:
137
+ label += f" @ {ts_str}"
138
+ lines.append(label)
139
+ lines.append("")
140
+
141
+ for part in msg["parts"]:
142
+ ptype = part["type"]
143
+ pdata = part["data"]
144
+
145
+ if ptype == "text":
146
+ lines.append(pdata.get("text", ""))
147
+ lines.append("")
148
+
149
+ elif ptype == "reasoning":
150
+ text = pdata.get("text", "") or pdata.get("reasoning", "")
151
+ if text:
152
+ lines.append("> **Reasoning:**")
153
+ lines.append(f"> {text}")
154
+ lines.append("")
155
+
156
+ elif ptype == "tool":
157
+ tool_name = pdata.get("tool", "unknown")
158
+ state = pdata.get("state", {})
159
+ tool_input = state.get("input", {})
160
+ tool_result = state.get("output", "")
161
+ tool_status = state.get("status", "")
162
+ is_error = "error" in tool_status.lower() if tool_status else False
163
+
164
+ if isinstance(tool_input, str):
165
+ try:
166
+ tool_input = json.loads(tool_input)
167
+ except json.JSONDecodeError:
168
+ pass
169
+
170
+ lines.append("<details>")
171
+ lines.append(
172
+ f"<summary>🔧 {tool_name}{' ⚠️ ERROR' if is_error else ''}</summary>"
173
+ )
174
+ lines.append("")
175
+ if tool_input and tool_input != {}:
176
+ lines.append("**Input:**")
177
+ lines.append("```json")
178
+ lines.append(json.dumps(tool_input, indent=2, ensure_ascii=False))
179
+ lines.append("```")
180
+ lines.append("")
181
+ if tool_result:
182
+ result_str = (
183
+ str(tool_result)
184
+ if isinstance(tool_result, str)
185
+ else json.dumps(tool_result, indent=2, ensure_ascii=False)
186
+ )
187
+ if len(result_str) > 5000:
188
+ result_str = result_str[:5000] + "\n\n... (truncated)"
189
+ lines.append("**Result:**")
190
+ lines.append("```")
191
+ lines.append(result_str)
192
+ lines.append("```")
193
+ lines.append("")
194
+ lines.append("</details>")
195
+ lines.append("")
196
+
197
+ elif ptype == "file":
198
+ path = pdata.get("path", "")
199
+ content = pdata.get("content", "")
200
+ lines.append(f"**File:** `{path}`")
201
+ lines.append("")
202
+ if content:
203
+ ext = os.path.splitext(path)[1] if path else ""
204
+ lang = ext.lstrip(".") if ext else ""
205
+ lines.append(f"```{lang}")
206
+ lines.append(content)
207
+ lines.append("```")
208
+ lines.append("")
209
+
210
+ elif ptype == "patch":
211
+ patch_text = pdata.get("text", "")
212
+ path = pdata.get("path", "")
213
+ if path:
214
+ lines.append(f"**Patch:** `{path}`")
215
+ if patch_text:
216
+ lines.append("```diff")
217
+ lines.append(patch_text)
218
+ lines.append("```")
219
+ lines.append("")
220
+
221
+ lines.append("")
222
+
223
+ return "\n".join(lines)
224
+
225
+
226
+ # ---- helpers ----
227
+
228
+ def _resolve_session(session_id: str):
229
+ """Look up a session by full ID, prefix, or slug."""
230
+ conn = get_db()
231
+ c = conn.cursor()
232
+ c.execute(
233
+ "SELECT * FROM session WHERE id = ? OR id LIKE ?",
234
+ (session_id, session_id + "%"),
235
+ )
236
+ session = c.fetchone()
237
+ if not session:
238
+ c.execute("SELECT * FROM session WHERE slug LIKE ?", (f"%{session_id}%",))
239
+ session = c.fetchone()
240
+ conn.close()
241
+ return dict(session) if session else None
242
+
243
+
244
+ def _safe_filename(text: str, max_len: int = 60) -> str:
245
+ return re.sub(r'[\\/:*?"<>|]', "_", text)[:max_len]
246
+
247
+
248
+ def _export_one(session: dict, out_dir: str) -> str:
249
+ """Export one session to .md, return file path."""
250
+ title = session.get("title", "Untitled")
251
+ slug = session.get("slug", "untitled")
252
+ ts = session.get("time_created", 0)
253
+
254
+ messages = get_session_tree(session["id"])
255
+ md = render_session_md(session, messages)
256
+
257
+ prefix = ""
258
+ if ts:
259
+ prefix = datetime.fromtimestamp(ts / 1000).strftime("%Y%m%d_%H%M") + "_"
260
+ fname = f"{prefix}{_safe_filename(slug, 30)}_{_safe_filename(title, 60)}.md"
261
+ os.makedirs(out_dir, exist_ok=True)
262
+ fpath = os.path.join(out_dir, fname)
263
+ with open(fpath, "w", encoding="utf-8") as f:
264
+ f.write(md)
265
+ return fpath
266
+
267
+
268
+ # ---- CLI ----
269
+
270
+ def cli_main():
271
+ parser = argparse.ArgumentParser(
272
+ description="Export OpenCode chat history to Markdown"
273
+ )
274
+ parser.add_argument(
275
+ "action", nargs="?", choices=["list", "export", "export-all"],
276
+ help="list: show sessions | export: export one session | export-all: all sessions",
277
+ )
278
+ parser.add_argument("session_id", nargs="?", help="Session ID or slug prefix")
279
+ parser.add_argument("--limit", type=int, default=20, help="Max entries to show")
280
+ parser.add_argument("--output", "-o", default=OUTPUT_DIR, help="Output directory")
281
+ args = parser.parse_args()
282
+
283
+ conn = get_db()
284
+ c = conn.cursor()
285
+
286
+ if args.action == "list" or not args.action:
287
+ c.execute(
288
+ "SELECT id, title, slug, agent, time_updated FROM session ORDER BY time_updated DESC LIMIT ?",
289
+ (args.limit,),
290
+ )
291
+ rows = c.fetchall()
292
+ print(f"{'#':>3} {'ID':<14} {'Title':<40} {'Agent':<12} {'Updated'}")
293
+ print("-" * 85)
294
+ for i, r in enumerate(rows, 1):
295
+ ts = r["time_updated"]
296
+ ts_str = datetime.fromtimestamp(ts / 1000).strftime("%Y-%m-%d %H:%M") if ts else "?"
297
+ print(
298
+ f"{i:>3} {r['id'][:12]:<14} "
299
+ f"{(r['title'] or 'Untitled')[:40]:<40} "
300
+ f"{(r['agent'] or '?')[:12]:<12} {ts_str}"
301
+ )
302
+
303
+ elif args.action == "export":
304
+ if not args.session_id:
305
+ print("Error: need session_id")
306
+ return
307
+ session = _resolve_session(args.session_id)
308
+ if not session:
309
+ print(f"Session not found: {args.session_id}")
310
+ return
311
+ fpath = _export_one(session, args.output)
312
+ print(f"Exported → {fpath}")
313
+
314
+ elif args.action == "export-all":
315
+ c.execute("SELECT id FROM session ORDER BY time_updated DESC")
316
+ sids = [r[0] for r in c.fetchall()]
317
+ for i, sid in enumerate(sids):
318
+ c2 = conn.cursor()
319
+ c2.execute("SELECT * FROM session WHERE id = ?", (sid,))
320
+ session = c2.fetchone()
321
+ if not session:
322
+ continue
323
+ fpath = _export_one(dict(session), args.output)
324
+ print(f"[{i+1}/{len(sids)}] → {fpath}")
325
+
326
+ conn.close()
327
+
328
+
329
+ # ---- MCP server ----
330
+
331
+ def main():
332
+ # CLI mode when args are present
333
+ if len(sys.argv) > 1 and sys.argv[1] in ("list", "export", "export-all"):
334
+ cli_main()
335
+ return
336
+
337
+ # MCP server mode
338
+ try:
339
+ from mcp.server import Server, NotificationOptions
340
+ from mcp.server.models import InitializationOptions
341
+ import mcp.server.stdio
342
+ import mcp.types as types
343
+ except ImportError:
344
+ print("MCP SDK not found. Install with: pip install mcp", file=sys.stderr)
345
+ sys.exit(1)
346
+
347
+ server = Server("opencode-chat-export")
348
+
349
+ @server.list_tools()
350
+ async def list_tools() -> list[types.Tool]:
351
+ return [
352
+ types.Tool(
353
+ name="list_sessions",
354
+ description="List recent OpenCode chat sessions",
355
+ inputSchema={
356
+ "type": "object",
357
+ "properties": {
358
+ "limit": {
359
+ "type": "integer",
360
+ "description": "Max sessions to show (default 20)",
361
+ "default": 20,
362
+ }
363
+ },
364
+ },
365
+ ),
366
+ types.Tool(
367
+ name="export_session",
368
+ description="Export a specific session to Markdown",
369
+ inputSchema={
370
+ "type": "object",
371
+ "required": ["session_id"],
372
+ "properties": {
373
+ "session_id": {
374
+ "type": "string",
375
+ "description": "Session ID (full or prefix) or slug",
376
+ },
377
+ "output_dir": {
378
+ "type": "string",
379
+ "description": "Output directory (default: ~/opencode-exports/)",
380
+ },
381
+ },
382
+ },
383
+ ),
384
+ types.Tool(
385
+ name="export_recent",
386
+ description="Export the most recent N sessions to Markdown",
387
+ inputSchema={
388
+ "type": "object",
389
+ "properties": {
390
+ "count": {
391
+ "type": "integer",
392
+ "description": "Number of recent sessions (default 5)",
393
+ "default": 5,
394
+ },
395
+ "output_dir": {
396
+ "type": "string",
397
+ "description": "Output directory",
398
+ },
399
+ },
400
+ },
401
+ ),
402
+ types.Tool(
403
+ name="export_current_session",
404
+ description="Export the currently active session (most recently updated)",
405
+ inputSchema={
406
+ "type": "object",
407
+ "properties": {
408
+ "output_dir": {
409
+ "type": "string",
410
+ "description": "Output directory (default: ~/opencode-exports/)",
411
+ },
412
+ },
413
+ },
414
+ ),
415
+ ]
416
+
417
+ @server.call_tool()
418
+ async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
419
+ if name == "list_sessions":
420
+ conn = get_db()
421
+ c = conn.cursor()
422
+ c.execute(
423
+ """SELECT id, title, slug, model, agent, cost, time_updated
424
+ FROM session ORDER BY time_updated DESC LIMIT ?""",
425
+ (arguments.get("limit", 20),),
426
+ )
427
+ rows = c.fetchall()
428
+ conn.close()
429
+ lines = [
430
+ "| # | ID (prefix) | Title | Agent | Updated |",
431
+ "|---|------------|-------|-------|---------|",
432
+ ]
433
+ for i, r in enumerate(rows, 1):
434
+ ts = r["time_updated"]
435
+ time_str = datetime.fromtimestamp(ts / 1000).strftime("%m-%d %H:%M") if ts else "?"
436
+ lines.append(
437
+ f"| {i} | `{r['id'][:12]}` | {(r['title'] or 'Untitled')[:40]} | "
438
+ f"{(r['agent'] or '?')[:12]} | {time_str} |"
439
+ )
440
+ return [types.TextContent(type="text", text="\n".join(lines))]
441
+
442
+ elif name == "export_session":
443
+ session = _resolve_session(arguments["session_id"])
444
+ if not session:
445
+ return [types.TextContent(type="text", text=f"Session not found: {arguments['session_id']}")]
446
+ out_dir = arguments.get("output_dir", OUTPUT_DIR)
447
+ fpath = _export_one(session, out_dir)
448
+ msg_count = len(get_session_tree(session["id"]))
449
+ return [
450
+ types.TextContent(
451
+ type="text",
452
+ text=f"Exported `{session['title']}` → {fpath} \n{msg_count} messages",
453
+ )
454
+ ]
455
+
456
+ elif name == "export_current_session":
457
+ out_dir = arguments.get("output_dir", OUTPUT_DIR)
458
+ # fall through to export_recent with count=1
459
+ arguments["count"] = 1
460
+
461
+ if name in ("export_recent", "export_current_session"):
462
+ count = arguments.get("count", 5)
463
+ out_dir = arguments.get("output_dir", OUTPUT_DIR)
464
+ conn = get_db()
465
+ c = conn.cursor()
466
+ c.execute("SELECT id FROM session ORDER BY time_updated DESC LIMIT ?", (count,))
467
+ rows = c.fetchall()
468
+ conn.close()
469
+ results = []
470
+ for (sid,) in rows:
471
+ conn2 = get_db()
472
+ c2 = conn2.cursor()
473
+ c2.execute("SELECT * FROM session WHERE id = ?", (sid,))
474
+ session = c2.fetchone()
475
+ conn2.close()
476
+ if not session:
477
+ continue
478
+ fpath = _export_one(dict(session), out_dir)
479
+ results.append(f" `{session['title']}` → {fpath}")
480
+ return [
481
+ types.TextContent(
482
+ type="text",
483
+ text=f"Exported {len(results)} session(s) to {out_dir}:\n" + "\n".join(results),
484
+ )
485
+ ]
486
+
487
+ raise ValueError(f"Unknown tool: {name}")
488
+
489
+ async def run():
490
+ async with mcp.server.stdio.stdio_server() as (read_stream, write_stream):
491
+ await server.run(
492
+ read_stream,
493
+ write_stream,
494
+ InitializationOptions(
495
+ server_name="opencode-chat-export",
496
+ server_version="1.0.0",
497
+ capabilities=server.get_capabilities(
498
+ notification_options=NotificationOptions(),
499
+ experimental_capabilities={},
500
+ ),
501
+ ),
502
+ )
503
+
504
+ import asyncio
505
+ asyncio.run(run())
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: opencode-chat-export
3
+ Version: 1.0.0
4
+ Summary: MCP server to export OpenCode chat history to Markdown
5
+ Author-email: Serenity Zhou <serenity.jingjing@gmail.com>
6
+ License: MIT
7
+ Requires-Python: >=3.10
8
+ Requires-Dist: mcp>=1.0.0
@@ -0,0 +1,6 @@
1
+ opencode_chat_export/__init__.py,sha256=DxPQZMMr-IRrCfKf-pjsaVHbAApEyeBI4YyPyYe89jM,464
2
+ opencode_chat_export/server.py,sha256=KRbfGrln66lYHqbb2ULnoEvQnnSZW5OUwCFHGb-CUJU,17951
3
+ opencode_chat_export-1.0.0.dist-info/METADATA,sha256=WyUVnQU8DLfKCUQ7bu8ls9k3hJdi-0DCnYfMyr4bqXw,249
4
+ opencode_chat_export-1.0.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
5
+ opencode_chat_export-1.0.0.dist-info/entry_points.txt,sha256=6g1_PnAin-BS19OTKiFm_QKEkwhY0m0nfLmQeW78suo,67
6
+ opencode_chat_export-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ opencode-chat-export = opencode_chat_export:main