opencode-chat-export 1.0.0__tar.gz
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.
- opencode_chat_export-1.0.0/.gitignore +18 -0
- opencode_chat_export-1.0.0/PKG-INFO +8 -0
- opencode_chat_export-1.0.0/README.md +63 -0
- opencode_chat_export-1.0.0/pyproject.toml +20 -0
- opencode_chat_export-1.0.0/src/opencode_chat_export/__init__.py +22 -0
- opencode_chat_export-1.0.0/src/opencode_chat_export/server.py +505 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# opencode-chat-export
|
|
2
|
+
|
|
3
|
+
MCP server to export [OpenCode](https://opencode.ai) chat history to Markdown files.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **`export_current_session`** — Export the currently active conversation
|
|
8
|
+
- **`export_session`** — Export a specific session by ID or slug
|
|
9
|
+
- **`export_recent`** — Export the N most recent sessions
|
|
10
|
+
- **`list_sessions`** — Browse available sessions
|
|
11
|
+
- Chronological ordering with interleaved user/assistant messages
|
|
12
|
+
- Tool calls rendered as collapsible `<details>` blocks
|
|
13
|
+
- Reasoning displayed as blockquotes
|
|
14
|
+
- Code patches with syntax highlighting
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install opencode-chat-export
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Or with uv:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
uv tool install opencode-chat-export
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Setup
|
|
29
|
+
|
|
30
|
+
Add to your `~/.config/opencode/opencode.jsonc`:
|
|
31
|
+
|
|
32
|
+
```jsonc
|
|
33
|
+
"mcp": {
|
|
34
|
+
"chat-export": {
|
|
35
|
+
"type": "local",
|
|
36
|
+
"command": ["opencode-chat-export"],
|
|
37
|
+
"enabled": false
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Set `"enabled": true` when you need it.
|
|
43
|
+
|
|
44
|
+
## CLI Usage
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
# List recent sessions
|
|
48
|
+
opencode-chat-export list
|
|
49
|
+
|
|
50
|
+
# Export a session by ID prefix
|
|
51
|
+
opencode-chat-export export ses_189058eb
|
|
52
|
+
|
|
53
|
+
# Export all sessions
|
|
54
|
+
opencode-chat-export export-all -o ~/my-exports
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## How it works
|
|
58
|
+
|
|
59
|
+
It reads directly from OpenCode's SQLite database at `~/.local/share/opencode/opencode.db` (or `%APPDATA%/ai.opencode.desktop/opencode.db` on Windows), queries the `session`, `message`, and `part` tables, and reconstructs conversations in chronological order.
|
|
60
|
+
|
|
61
|
+
## License
|
|
62
|
+
|
|
63
|
+
MIT
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "opencode-chat-export"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "MCP server to export OpenCode chat history to Markdown"
|
|
5
|
+
authors = [{ name = "Serenity Zhou", email = "serenity.jingjing@gmail.com" }]
|
|
6
|
+
license = { text = "MIT" }
|
|
7
|
+
requires-python = ">=3.10"
|
|
8
|
+
dependencies = [
|
|
9
|
+
"mcp>=1.0.0",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
[project.scripts]
|
|
13
|
+
opencode-chat-export = "opencode_chat_export:main"
|
|
14
|
+
|
|
15
|
+
[build-system]
|
|
16
|
+
requires = ["hatchling"]
|
|
17
|
+
build-backend = "hatchling.build"
|
|
18
|
+
|
|
19
|
+
[tool.uv]
|
|
20
|
+
dev-dependencies = []
|
|
@@ -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())
|