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:验证
- 运行
python3 ~/.claude/export_session_md.py --poll确认脚本无报错 - 告知用户输出目录位置及如何查看文件
同步到容器外部的 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 路径。