Last Updated on 2026年5月28日 by num46
Cursorは閉じたらAIとの会話内容を復元できない、と書いたが有志の方がログを抽出して、さらにマークダウン形式にエクスポートしてくれるコードを書いていました。
ただ、そのままだとエラーが出たのでCursorのAIにお伺いを立ててAgentモードで自動でコードの修正をしてもらいました。AIすんげぇ~
AIにポン出ししてもらってあまり精査していないので自己責任で利用してください。
コード
cursor2md.pyのAI修正版
import os
import json
import sqlite3
import argparse
from datetime import datetime
from pathlib import Path
def get_default_db_path():
appdata = os.getenv("APPDATA")
return os.path.join(appdata, "Cursor", "User", "globalStorage", "state.vscdb") if appdata else None
def normalize_timestamp(ts):
"""Return millisecond timestamp as int, or 0 if invalid."""
if ts is None:
return 0
if isinstance(ts, (int, float)):
return int(ts) if ts > 0 else 0
if isinstance(ts, str):
ts = ts.strip()
if not ts:
return 0
try:
if ts.isdigit():
return int(ts)
if ts.endswith("Z"):
ts = ts[:-1] + "+00:00"
dt = datetime.fromisoformat(ts)
return int(dt.timestamp() * 1000)
except (ValueError, OSError):
return 0
return 0
def format_timestamp(ts):
ms = normalize_timestamp(ts)
if ms > 0:
return datetime.fromtimestamp(ms / 1000)
return None
def load_kv_map(db_path):
if not os.path.exists(db_path):
return None
with sqlite3.connect(db_path) as conn:
cursor = conn.cursor()
cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
tables = {r[0] for r in cursor.fetchall()}
# Cursor stores chat history in (at least) two known KV tables depending on scope/version.
# - globalStorage/state.vscdb: cursorDiskKV
# - workspaceStorage/*/state.vscdb: ItemTable (VS Code style)
if "cursorDiskKV" in tables:
cursor.execute("SELECT key, value FROM cursorDiskKV")
elif "ItemTable" in tables:
cursor.execute("SELECT key, value FROM ItemTable")
else:
raise sqlite3.OperationalError(
f"no supported KV table found (tables={sorted(tables)})"
)
rows = cursor.fetchall()
kv_map = {}
for k, raw in rows:
if not isinstance(raw, (str, bytes)):
continue
if isinstance(raw, bytes):
try:
v = raw.decode("utf-8")
except UnicodeDecodeError:
continue
else:
v = raw
if not v or v.strip() == "[]":
continue
kv_map[k] = v
return kv_map
class ChatRecord:
def __init__(self, key, value):
self.key = key
self.value = value
try:
self.data = json.loads(value)
# Handle new data structure with _v field, 仅当存在嵌套 data 时才解包
if isinstance(self.data, dict) and "_v" in self.data and "data" in self.data:
self.data = self.data.get("data", {})
# Handle checkpoint data
if "files" in self.data:
self.data = {"context": {"fileSelections": self.data.get("files", [])}}
except json.JSONDecodeError:
self.data = {}
def has_valid_content(self):
# 检查对话内容是否实际有效
conversation = self.data.get("conversation", [])
if not conversation and isinstance(self.data, dict):
conversation = self.data.get("messages", [])
# 检查对话内容是否为空或只有空白字符
has_valid_messages = False
for msg in conversation:
text = msg.get("text", "").strip()
if text:
has_valid_messages = True
break
created_at = normalize_timestamp(self.created_at)
ended_at = normalize_timestamp(self.ended_at)
if created_at > 0 and ended_at > 0:
valid_timestamps = ended_at >= created_at
else:
valid_timestamps = True
# 检查文件名是否存在
files = self.data.get("context", {}).get("fileSelections", [])
has_valid_files = len(files) > 0
# 记录必须满足以下条件之一并且时间戳合理
return (has_valid_messages or has_valid_files) and valid_timestamps
@property
def name(self):
return self.data.get("name", "untitled")
@property
def conversation(self):
# Handle both old and new conversation structures
conversation = self.data.get("conversation", [])
if not conversation and isinstance(self.data, dict):
conversation = self.data.get("messages", [])
return conversation
@property
def created_at(self):
return self.data.get("createdAt", 0)
@property
def ended_at(self):
if self.conversation:
return self.conversation[-1].get("timingInfo", {}).get("clientEndTime", 0)
return 0
def collect_sessions(kv_map):
sessions = []
for key, val in kv_map.items():
if not key.startswith("composerData:"):
continue
try:
comp = json.loads(val)
except json.JSONDecodeError:
continue
if isinstance(comp, dict) and "conversation" in comp and isinstance(comp["conversation"], list):
has_content = False
first_msg_text = ""
for msg in comp["conversation"]:
text = msg.get("text", "").strip()
if text:
has_content = True
if not first_msg_text:
first_msg_text = text
if has_content:
if not comp.get("name") or comp.get("name", "").lower() == "untitled":
comp["name"] = first_msg_text.split("\n", 1)[0][:50]
rec = ChatRecord(key, val)
sessions.append({"type": "old", "key": key, "record": rec})
continue
if isinstance(comp, dict) and "_v" in comp and "data" in comp:
comp = comp["data"]
if comp.get("fullConversationHeadersOnly"):
cid = comp.get("composerId")
headers = comp.get("fullConversationHeadersOnly", [])
msgs = []
for h in headers:
bid = h.get("bubbleId")
tp = h.get("type")
bubble_key = f"bubbleId:{cid}:{bid}"
bv = kv_map.get(bubble_key)
if not bv:
continue
try:
bub = json.loads(bv)
except json.JSONDecodeError:
continue
if isinstance(bub, dict) and "_v" in bub and "data" in bub:
bub = bub["data"]
text = bub.get("text", "")
msgs.append({
"type": tp,
"text": text,
"context": bub.get("context", {}),
"codeBlocks": bub.get("codeBlocks", []),
})
if msgs:
file_selections = comp.get("context", {}).get("mentions", {}).get("fileSelections", {})
files = []
for file_uri in file_selections.keys():
path = file_uri.replace("file:///", "").replace("%3A", ":").replace("%2F", "/")
files.append({"uri": file_uri, "path": path})
sessions.append({
"type": "new",
"key": key,
"name": comp.get("name", ""),
"start_time": comp.get("createdAt", 0),
"end_time": comp.get("lastUpdatedAt", comp.get("createdAt", 0)),
"messages": msgs,
"files": files,
})
def _start(s):
if s["type"] == "old":
ts = normalize_timestamp(s["record"].created_at)
return ts if 0 < ts < 1735660800000 else 0
return normalize_timestamp(s["start_time"])
sessions.sort(key=_start)
return sessions
def session_title(session):
if session["type"] == "old":
rec = session["record"]
title = rec.name.strip()
if not title or title.lower() == "untitled":
first = next((m["text"] for m in rec.conversation if m.get("text")), "")
title = first.split("\n", 1)[0].strip()[:50] or "untitled"
return title
title = session["name"].strip()
if not title:
first = session["messages"][0]["text"].split("\n", 1)[0].strip()
title = first[:50]
return title or "untitled"
def list_sessions(db_path):
kv_map = load_kv_map(db_path)
if kv_map is None:
print(f"数据库文件不存在: {db_path}")
return
sessions = collect_sessions(kv_map)
if not sessions:
print("没有找到会话")
return
for session in sessions:
title = session_title(session)
if session["type"] == "old":
start_time = format_timestamp(session["record"].created_at)
end_time = format_timestamp(session["record"].ended_at)
else:
start_time = format_timestamp(session["start_time"])
end_time = format_timestamp(session["end_time"])
print(f"Hash: {session['key']}\n"
f"Title: {title}\n"
f"Start Time: {start_time or 'unknown'}\n"
f"End Time: {end_time or 'unknown'}\n"
f"---")
def generate_markdown(record):
md_content = f"# {record.name}\n\n"
md_content += "## 会话信息\n\n"
created_at = normalize_timestamp(record.created_at)
ended_at = normalize_timestamp(record.ended_at)
if created_at > 0:
created_time = datetime.fromtimestamp(created_at / 1000)
md_content += f"- 开始时间: {created_time}\n"
if ended_at > 0:
ended_time = datetime.fromtimestamp(ended_at / 1000)
md_content += f"- 结束时间: {ended_time}\n"
context_files = record.data.get("context", {}).get("fileSelections", [])
if context_files:
md_content += "- 相关文件:\n"
for file in context_files:
# 处理新的URI结构
uri = file.get("uri", {})
path = None
if isinstance(uri, dict):
if "fsPath" in uri:
path = uri["fsPath"]
elif "external" in uri:
path = uri["external"].replace("file:///", "").replace("%3A", ":")
if path:
filename = path.split("\\")[-1] if "\\" in path else path.split("/")[-1]
md_content += f"- [{filename}]({path})\n"
for msg in record.conversation:
msg_type = msg.get("type", 0)
text = msg.get("text", "")
selections = msg.get("context", {}).get("selections", [])
code_blocks = msg.get("codeBlocks", [])
if msg_type == 1:
md_content += "\n## User\n\n"
if selections:
md_content += "引用的文件:\n"
for sel in selections:
uri = sel.get("uri", {})
path = None
if isinstance(uri, dict):
if "fsPath" in uri:
path = uri["fsPath"]
elif "external" in uri:
path = uri["external"].replace("file:///", "").replace("%3A", ":")
if path:
filename = path.split("\\")[-1] if "\\" in path else path.split("/")[-1]
md_content += f"- From [{filename}]({path}): {sel.get('text', '')}\n"
md_content += f"> {text}\n"
elif msg_type == 2:
md_content += "\n## Cursor\n\n"
md_content += f"{text}\n"
if code_blocks:
for block in code_blocks:
uri = block.get("uri", {})
path = None
if isinstance(uri, dict):
if "fsPath" in uri:
path = uri["fsPath"]
elif "external" in uri:
path = uri["external"].replace("file:///", "").replace("%3A", ":")
if path:
language = block.get("languageId", "text")
filename = path.split("\\")[-1] if "\\" in path else path.split("/")[-1]
code_content = block.get("content", "")
md_content += f"\n```{language} [{filename}]({path})\n"
md_content += code_content
md_content += "\n```\n"
return md_content
def export_sessions(db_path, output_dir):
os.makedirs(output_dir, exist_ok=True)
kv_map = load_kv_map(db_path)
if kv_map is None:
print(f"数据库文件不存在: {db_path}")
return
sessions = collect_sessions(kv_map)
for s in sessions:
try:
if s["type"] == "old":
rec = s["record"]
title = session_title(s)[:25]
md = generate_markdown(rec)
else:
title = session_title(s)[:25]
lines = [f"# {title}", "", "## 会话信息", ""]
start_time = format_timestamp(s["start_time"])
end_time = format_timestamp(s["end_time"])
if start_time:
lines.append(f"- 开始时间: {start_time}")
if end_time:
lines.append(f"- 结束时间: {end_time}")
if s.get("files"):
lines.append("- 相关文件:")
for file in s["files"]:
path = file.get("path", "")
if path:
filename = path.split("\\")[-1] if "\\" in path else path.split("/")[-1]
lines.append(f"- [{filename}]({path})")
lines.append("")
for m in s["messages"]:
if m["type"] == 1:
lines += ["## User", "", f"> {m['text']}", ""]
elif m["type"] == 2:
lines += ["## Cursor", "", m["text"], ""]
for cb in m.get("codeBlocks", []):
uri = cb.get("uri", {})
path = None
if isinstance(uri, dict):
path = uri.get("fsPath") or uri.get("external", "").replace("file:///", "").replace("%3A", ":")
lang = cb.get("languageId", "text")
fname = Path(path).name if path else ""
lines += [f"```{lang} [{fname}]({path})", cb.get("content", ""), "```", ""]
md = "\n".join(lines)
if s["type"] == "old":
end_time = normalize_timestamp(rec.ended_at)
create_time = normalize_timestamp(rec.created_at)
else:
end_time = normalize_timestamp(s["end_time"])
create_time = normalize_timestamp(s["start_time"])
if end_time > 0:
time_str = datetime.fromtimestamp(end_time / 1000).strftime("%Y%m%d%H%M")
elif create_time > 0:
time_str = datetime.fromtimestamp(create_time / 1000).strftime("%Y%m%d%H%M")
else:
time_str = "unknown"
safe = title.replace("<", "_").replace(">", "_").replace(":", "_")\
.replace("/", "_").replace("\\", "_").replace("|", "_")\
.replace("?", "_").replace("*", "_").replace('"', "_")\
.replace("'", "_").replace("\n", "_").replace("\r", "_")
if len(safe) > 40:
safe = safe[:37] + "..."
fn = f"{time_str}_{safe}.md"
out = Path(output_dir) / fn
with open(out, "w", encoding="utf-8") as f:
f.write(md)
print(f"已导出: {out}")
except Exception as e:
print(f"导出失败: {e}")
def main():
parser = argparse.ArgumentParser(description="Cursor 聊天记录导出工具")
parser.add_argument("-ls", "--list", action="store_true", help="列出所有会话")
parser.add_argument("-db", "--db-path", help="数据库文件路径")
parser.add_argument("-o", "--output", default="markdown_output", help="markdown 文件输出目录")
args = parser.parse_args()
db_path = args.db_path if args.db_path else get_default_db_path()
if args.list:
list_sessions(db_path)
else:
export_sessions(db_path, args.output)
if __name__ == "__main__":
main()
export_cursor_data.batを良い感じに修正したもの
cd /d "%~dp0"
python cursor2md.py --output ".\markdown_output" --db-path "%APPDATA%\Cursor\User\globalStorage\state.vscdb"
@rem 仮想環境が有効化された後もコマンドプロンプトを開いたままにする。
cmd /k
cd /d “%~dp0″は実行されたバッチファイルのディレクトリに移動。/dフラグを使用することで、異なるドライブにいても移動可能。
python cursor2md.py –output “.\markdown_output” –db-path “%APPDATA%\Cursor\User\globalStorage\state.vscdb”
は、’python’ は、内部コマンドまたは~云々のエラーが出たら
py cursor2md.py –output “.\markdown_output” –db-path “C:\Users\Y\AppData\Roaming\Cursor\User\globalStorage\state.vscdb”
に変えて実行してみてください。
list_cursor_data.batを良い感じに修正したもの
cd /d "%~dp0"
python cursor2md.py --list --db-path "%APPDATA%\Cursor\User\globalStorage\state.vscdb"
@rem 仮想環境が有効化された後もコマンドプロンプトを開いたままにする。
cmd /k
これも、’python’ は、内部コマンドまたは~云々のエラーが出たら
py cursor2md.py –list –db-path “%APPDATA%\Cursor\User\globalStorage\state.vscdb”
に変えて実行してみてください。
やり方
Git、Pythonをインストール、Git Bash起動、フォルダ移動し任意の場所で、git clone https://github.com/changlehu/cursor-chat-export-to-markdown.gitを実行。
上で書いたコードに差し替えてexport_cursor_data.batを実行することでmarkdown_outputが作成されて中にMarkdownファイルに変換されたAIチャットログが生成されます。
LISENCE
元となったGo版:
© 2024 M6ZeroG
https://github.com/M6ZeroG/cursor2md
Licensed under the MIT License.
今回使用したPython版:
© 2025 changlehu
https://github.com/changlehu/cursor-chat-export-to-markdown
For the full text of the license, please refer to the attached URL:
https://opensource.org/licenses/mit-license.php
その他
https://github.com/changlehu/cursor-chat-export-to-markdown
をフォークしようとしたけどAIポン出しで精査してないからあんまり表に出したくないなぁ、と思ったのでやめました。
責任を取ってくれるAIが求められる・・・
C:\Users\(ユーザー名)\AppData\Roaming\Cursor\User\globalStorage\state.vscdb
がAIとのログだけど
C:\Users\(ユーザー名)\AppData\Roaming\Cursor\User\workspaceStorage
内にもstate.vscdbがある。
これは読み込めないので注意。AI曰くDBのスキーマ(テーブル構成)が違うらしい。
2026.5.28実行時のCursorのバージョン
Version: 3.5.33 (user setup)
VSCode Version: 1.105.1