[skill] agent 自动保存聊天记录到md文件 skill auto-save-md


name: auto-save-md

description: Install auto-save of Claude sessions to Markdown files. Sets up export script and Stop hook (saves after each response). Invoke with /auto-save-md.

Skill: auto-save-md

将 Claude Code 每次会话的对话记录自动保存为 Markdown 文件。

功能:

  • 每次回答完毕即追加保存(Stop hook)
  • 只记录用户提问和助手回答,过滤工具调用

使用方法

执行以下步骤完成安装:


User Request

当用户调用此 skill 时,按顺序完成下面所有步骤,每一步都告知用户进度。

步骤 1:确认输出目录

询问用户希望把 Markdown 文件存到哪个目录。

默认建议:

  • Docker 容器内(有持久化 volume):/workspaces/aimde/.claude/claude_sessions_md
  • 本地机器:~/claude_sessions_md

用户确认后记录为 OUT_DIR,后续步骤使用。


步骤 2:写入导出脚本

将以下内容写入 ~/.claude/export_session_md.py,把脚本中的 OUT_DIR_PLACEHOLDER 替换为用户确认的 OUT_DIR

python 复制代码
#!/usr/bin/env python3
"""
Export Claude Code session transcripts to Markdown.

Two modes:
  --stop  : export the current session (session_id from stdin JSON), then exit.
  --poll  : scan all project dirs, append any new messages to existing .md files.
"""
import json
import os
import re
import sys
from datetime import datetime, timezone
from pathlib import Path

OUT_DIR = Path("/workspaces/aimde/.claude/claude_sessions_md")
STATE_FILE = Path.home() / ".claude" / ".export_state.json"

# 自动 poll 消息的特征关键词,过滤掉 cron 触发的例行导出请求和对应回复
_AUTOPOLL_USER_RE = re.compile(
    r'python3.*export_session_md\.py.*--poll|'
    r'appends any new conversation messages',
    re.IGNORECASE,
)
_AUTOPOLL_ASST_RE = re.compile(
    r'^\d+ files? updated\.$|^No new messages\.$',
    re.IGNORECASE,
)

def _is_autopoll(text: str) -> bool:
    first_line = text.split('\n')[0]
    return bool(_AUTOPOLL_USER_RE.search(first_line) or _AUTOPOLL_ASST_RE.match(first_line))


def extract_text(content):
    """只提取纯文本,跳过工具调用和工具结果。"""
    if isinstance(content, str):
        return content
    if isinstance(content, list):
        parts = []
        for c in content:
            if not isinstance(c, dict):
                continue
            if c.get("type") == "text":
                t = c.get("text", "").strip()
                if t:
                    parts.append(t)
        return "\n\n".join(p for p in parts if p.strip())
    return str(content)


def parse_jsonl(path: Path):
    ai_title = ""
    first_ts = ""
    messages = []

    with open(path, encoding="utf-8") as f:
        for line in f:
            line = line.strip()
            if not line:
                continue
            try:
                d = json.loads(line)
            except json.JSONDecodeError:
                continue

            t = d.get("type")

            if t == "ai-title":
                ai_title = d.get("aiTitle", "")

            elif t in ("user", "assistant"):
                msg = d.get("message", {})
                role = msg.get("role", t)
                content = msg.get("content", "")
                text = extract_text(content).strip()
                if not text:
                    continue
                if _is_autopoll(text):
                    continue
                ts_raw = d.get("timestamp", "")
                if not first_ts and ts_raw:
                    first_ts = ts_raw
                messages.append((role, text, ts_raw))

    return ai_title, first_ts, messages


def ts_to_local(ts_raw: str) -> str:
    if not ts_raw:
        return ""
    try:
        dt = datetime.fromisoformat(ts_raw.replace("Z", "+00:00"))
        local_dt = dt.astimezone()
        return local_dt.strftime("%Y-%m-%d %H:%M:%S")
    except Exception:
        return ts_raw


def sanitize(s: str) -> str:
    s = s.strip()
    s = re.sub(r'[\\/:*?"<>|]', "_", s)
    s = re.sub(r'\s+', " ", s)
    return s[:60]


def md_filename(session_id: str, ai_title: str, first_ts: str) -> str:
    date_part = ""
    if first_ts:
        try:
            dt = datetime.fromisoformat(first_ts.replace("Z", "+00:00")).astimezone()
            date_part = dt.strftime("%Y%m%d_%H%M%S")
        except Exception:
            pass
    title_part = sanitize(ai_title) if ai_title else "untitled"
    parts = [p for p in [date_part, title_part, session_id[:8]] if p]
    return "_".join(parts) + ".md"


def load_state() -> dict:
    if STATE_FILE.exists():
        try:
            return json.loads(STATE_FILE.read_text())
        except Exception:
            pass
    return {}


def save_state(state: dict):
    OUT_DIR.mkdir(parents=True, exist_ok=True)
    STATE_FILE.write_text(json.dumps(state, indent=2))


def build_md_header(session_id: str, ai_title: str, first_ts: str) -> str:
    date_str = ts_to_local(first_ts) if first_ts else ""
    lines = [f"# {ai_title or 'Claude Session'}"]
    if date_str:
        lines.append(f"\n*Started: {date_str}*")
    lines.append(f"\n*Session ID: `{session_id}`*\n")
    return "\n".join(lines)


def messages_to_md(messages) -> str:
    lines = []
    for role, text, ts_raw in messages:
        ts_str = ts_to_local(ts_raw)
        ts_tag = f" <sub>{ts_str}</sub>" if ts_str else ""
        if role == "user":
            lines.append(f"## User{ts_tag}\n\n{text}\n\n---\n")
        else:
            lines.append(f"## Assistant{ts_tag}\n\n{text}\n\n---\n")
    return "\n".join(lines)


def export_session(jsonl_path: Path, force_full=False) -> str | None:
    OUT_DIR.mkdir(parents=True, exist_ok=True)
    state = load_state()
    session_id = jsonl_path.stem

    ai_title, first_ts, messages = parse_jsonl(jsonl_path)

    if not messages:
        return None

    existing = list(OUT_DIR.glob(f"*{session_id[:8]}*.md"))
    existing = [f for f in existing if not f.name.startswith(".")]

    out_filename = md_filename(session_id, ai_title, first_ts)
    out_path = OUT_DIR / out_filename

    if existing:
        exact = [f for f in existing if session_id[:8] in f.name]
        if exact:
            out_path = exact[0]

    prev_count = state.get(session_id, 0)
    new_count = len(messages)

    if new_count <= prev_count and not force_full and out_path.exists():
        return None

    if not out_path.exists() or force_full:
        header = build_md_header(session_id, ai_title, first_ts)
        body = messages_to_md(messages)
        out_path.write_text(header + "\n" + body, encoding="utf-8")
    else:
        new_messages = messages[prev_count:]
        if not new_messages:
            return None
        addition = messages_to_md(new_messages)
        with open(out_path, "a", encoding="utf-8") as f:
            f.write("\n" + addition)

    state[session_id] = new_count
    save_state(state)
    return str(out_path)


def mode_stop():
    try:
        data = json.loads(sys.stdin.read())
        session_id = data.get("session_id", "")
    except Exception:
        session_id = ""

    projects_dir = Path.home() / ".claude" / "projects"
    target = None

    if session_id:
        for f in projects_dir.rglob(f"{session_id}.jsonl"):
            target = f
            break

    if target is None:
        all_files = list(projects_dir.rglob("*.jsonl"))
        if not all_files:
            return
        target = max(all_files, key=lambda f: f.stat().st_mtime)

    out = export_session(target, force_full=False)

    # 二次检查:等待 1 秒后重新读取,捕捉首次导出后才写入的尾部消息
    import time
    time.sleep(1)
    out2 = export_session(target, force_full=False)

    final = out2 or out
    if final:
        print(json.dumps({"systemMessage": f"Session saved → {final}"}))


def mode_poll():
    projects_dir = Path.home() / ".claude" / "projects"
    if not projects_dir.exists():
        return

    updated = []
    for jsonl_path in projects_dir.rglob("*.jsonl"):
        try:
            out = export_session(jsonl_path, force_full=False)
            if out:
                updated.append(out)
        except Exception:
            pass

    if updated:
        for p in updated:
            print(f"Updated: {p}")


if __name__ == "__main__":
    mode = sys.argv[1] if len(sys.argv) > 1 else "--stop"
    if mode == "--poll":
        mode_poll()
    else:
        mode_stop()

步骤 3:配置 Stop hook

读取 ~/.claude/settings.json,在 hooks.Stop 中追加(不替换已有内容):

json 复制代码
{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "sleep 3 && python3 ~/.claude/export_session_md.py --stop",
            "timeout": 30,
            "statusMessage": "SessionSaving..."
          }
        ]
      }
    ],
    "UserPromptSubmit": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "python3 ~/.claude/export_session_md.py --stop",
            "timeout": 15
          }
        ]
      }
    ]
  }
}

Stop 在 Claude 每次回答完毕后触发;UserPromptSubmit 在用户发新消息前触发,确保 Esc 中断后的内容也能被捕捉。


步骤 4:验证

  1. 运行 python3 ~/.claude/export_session_md.py --poll 确认脚本无报错
  2. 告知用户输出目录位置及如何查看文件

同步到容器外部的 Claude

方法:把 skill 文件复制到本地机器

此 skill 文件存于项目 .claude/skills/auto-save-md.md,进 git 后可通过以下任一方式在本地 Claude 中使用:

方法 A(推荐):复制到用户级 skills 目录

bash 复制代码
# 在本地机器执行
cp <项目路径>/.claude/skills/auto-save-md.md ~/.claude/skills/auto-save-md.md

然后在任意 Claude Code 会话中运行 /auto-save-md 完成安装。

方法 B:从项目目录启动 Claude

在包含该 .claude/skills/ 的项目目录下启动 Claude Code,skill 自动对当前会话可用。

注意:本地安装时建议把 OUT_DIR 改为本地路径(如 ~/claude_sessions_md),不要用 Docker volume 路径。

相关推荐
怕浪猫1 小时前
一行命令复刻爆款视频,我把 Hypit 从安装跑到了出片
人工智能·设计模式·程序员
Raas1001 小时前
MAI Gateway(魔芋企业级AI网关)功能全解:AI网关支持本地模型吗?一文看懂AI网关能力矩阵
大数据·人工智能·网关·ai网关·mai gateway·企业级产品
蜗牛互联网1 小时前
AI给面试打分不够,求职者更需要可核对的证据
java·人工智能·后端
不开大的凯20771 小时前
跑分已死,交付为王
人工智能·ai·麦当秀aippt·ai office
AI多Agent协作实战派1 小时前
【AI探索历程19】飞牛版:给NAS用户的一键部署
人工智能
爱签AI电子合同1 小时前
电子合同服务稳定性怎么测?可用性保障维度专项测评
大数据·人工智能·电子合同·电子签名
IC一站式服务2 小时前
AI时代数据中心互联:PCIe重定时器、重驱动器与交换机全解析
人工智能
正经教主2 小时前
【FDE系列】阶段2:Day 35:Python + SQL — 工单接入 MySQL + 本周收官
人工智能·python·fde
I Am a robert girl2 小时前
谷歌迈出RSI一大步:从Gemini模型迭代看AI工程化的新风向
人工智能·大模型·gemini·ai工程化·上下文工程·rsi·工具链集成