CursorのAIチャット内容のサルベージ

Last Updated on 2026年5月28日 by num46

Cursorは閉じたらAIとの会話内容を復元できない、と書いたが有志の方がログを抽出して、さらにマークダウン形式にエクスポートしてくれるコードを書いていました。

ただ、そのままだとエラーが出たのでCursorのAIにお伺いを立ててAgentモードで自動でコードの修正をしてもらいました。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()
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”
に変えて実行してみてください。

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チャットログが生成されます。

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