[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 路径。

相关推荐
JWASX11 小时前
【agent 开发】Agent 智能体
大数据·人工智能·python
智购科技无人售货机工厂店11 小时前
玻璃瓶饮料破损率突然上升,排查发现是取货口缓冲垫老化了~YH
java·开发语言·人工智能·python·eclipse
骇客野人11 小时前
业务系统接入AI方案及全流程实施过程
人工智能
一木 之林11 小时前
Dify学习笔记 00 · 总览:56集四模块学习地图(从低代码平台到检索底座)
人工智能·计算机视觉·langchain
镜象科技11 小时前
中小学AI心理健康解决方案服务商怎么选?2026年选型参考
android·java·人工智能
枫叶丹411 小时前
语音 AI 怎样边听边答:实时对话系统的工作原理
开发语言·人工智能·chatgpt·开源·php·agent·codex
楚楚25111 小时前
播客单声道怎么变立体声:先明确需求再选择处理方案
人工智能
田里的水稻11 小时前
IL_部署推理---工具和语言
人工智能·神经网络·机器学习
俊男无期11 小时前
【AI 和未来】工作(1)
人工智能
IT大白鼠11 小时前
彭大帅的AI运维助手实战案例 5 · 新接手的服务器,先让 AI 摸底
运维·服务器·人工智能