Repository navigation
feat: add experimental Chat Export lab #20
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,73 @@ | ||
| # Chat Export Lab | ||
|
|
||
| 把 Claude Code、Codex、Cursor、Grok Bot 的本地聊天记录导出成 Markdown,一会话一个文件,任何 AI 都能用 grep 或全文检索查到。 | ||
|
|
||
| > 实验性模块,不是 QuietHarness 的安装前提。只用 Python 3 标准库,只读本地文件,不联网、不登录、不改任何 App 状态。 | ||
|
|
||
| ## 解决什么问题 | ||
|
|
||
| 同时用好几家 AI 时,聊天记录各存各的:Claude Code 是 jsonl,Codex 是 rollout 文件加 SQLite,Cursor 是两个 SQLite 库,Grok Bot 是 App 里的二进制 blob。你在 Claude 里讨论过的东西,Codex 查不到;换一家 AI,上下文就断了。 | ||
|
|
||
| 这个模块只做一件事:把它们统一成同一种 Markdown,放进同一个目录。之后用什么检索都行:`rg`、qmd、Obsidian,或者让 Agent 直接读。 | ||
|
|
||
| ## 先试一次 | ||
|
|
||
| ```bash | ||
| cd labs/chat-export | ||
|
|
||
| # 1. 只看会导出多少,不写任何文件 | ||
| python3 scripts/export_all.py --dry-run | ||
|
|
||
| # 2. 真正导出到 ~/.local/share/chat-export/<来源>/ | ||
| python3 scripts/export_all.py | ||
|
|
||
| # 3. 查一下 | ||
| rg -l "上次那个部署问题" ~/.local/share/chat-export | ||
| ``` | ||
|
|
||
| 重复运行只处理有变化的会话(按文件修改时间和大小记水位),可以放进你自己的定时任务。`--full` 忽略水位全部重导。 | ||
|
|
||
| ## 支持的来源 | ||
|
|
||
| | 来源 | 读取位置(默认) | 单独运行 | | ||
| |---|---|---| | ||
| | Claude Code | `~/.claude/projects/*/*.jsonl` | `scripts/export_claude.py` | | ||
| | Codex | `~/.codex/thread_history_*.sqlite`,以及 `~/.codex/sessions/**/rollout-*.jsonl` | `scripts/export_codex.py` | | ||
| | Cursor | App:`globalStorage/state.vscdb`;CLI:`~/.cursor/chats/*/*/store.db` | `scripts/export_cursor.py` | | ||
| | Grok Bot(macOS 桌面 App) | `~/Library/Application Support/Grok Bot/sand-client-persistence/*.blob` | `scripts/export_grok.py` | | ||
|
|
||
| 本机没有的来源会自动跳过。每个脚本都能用 `--help` 查看参数,路径都可以覆盖,`--dest` 改输出目录。 | ||
|
|
||
| ## 留什么、丢什么 | ||
|
|
||
| 留:你发的消息,和 AI 给你看的文字回复。 | ||
|
|
||
| 丢:系统提示和注入的上下文(`<system-reminder>`、整份 AGENTS.md、环境信息)、思考过程、工具调用与结果、子 Agent 的旁支对话。不丢的话,每个会话都带一份 AGENTS.md,任何关键词一搜就命中全部会话。 | ||
|
|
||
| 导出前会把常见凭证替换成 `<REDACTED_…>`:私钥、`sk-` 开头的 API key、GitHub / AWS / Slack token、Bearer 头、`*_TOKEN=` 这类赋值、超长 base64。这是尽力而为的规则匹配,**不能保证把所有敏感内容都清掉**。导出的目录和文件都只有你本人能读写(0700 / 0600),只适合留在本机,不要提交到仓库,也不要上传。 | ||
|
|
||
| ## 踩过的坑 | ||
|
|
||
| - **Codex 新版一轮对话会写好几条回复**:先是「我先看看」这类进度,最后才是结论。只取第一条回复,所有结论都会丢。这里保留一轮里的全部回复。新版 Codex 自己的 `thread_history` 库直接记了每轮的提问和最终回答,存在时优先用它;即使 rollout 文件已被清理,结论也还在。 | ||
| - **同一个 Codex 会话会跨多个 rollout 文件**(继续、恢复对话时)。按会话 id 命名会互相覆盖,所以按 rollout 自己的 id 命名。 | ||
| - **Grok Bot 的 blob 里,你的消息和它的回复是两种结构**。只解析一种,就只看到半边对话。 | ||
| - **超过 6 万字符的一轮直接跳过**,通常是贴进去的日志或整份文件,切一半反而误导检索。 | ||
|
|
||
| ## 它不是什么 | ||
|
|
||
| 聊天记录只是线索,不是事实。决定、进展、下一步还是要写回任务卡、文档或仓库才算数;聊天记录和文件对不上时,以文件为准。这个模块让各家 AI 都能翻到「之前聊过什么」,但不替代这一层。 | ||
|
|
||
| 它也不是同步服务、记忆数据库或向量库:没有后台进程,不做语义合并,不往任何 AI 里写东西。 | ||
|
|
||
| ## 测试 | ||
|
|
||
| ```bash | ||
| python3 labs/chat-export/tests/run.py | ||
| # CHAT_EXPORT_TEST_OK | ||
| ``` | ||
|
|
||
| 测试只用临时生成的假数据,不读你的真实聊天记录。 | ||
|
|
||
| ## English summary | ||
|
|
||
| Exports local Claude Code, Codex, Cursor and Grok Bot (macOS app) chat history to one Markdown file per conversation under `~/.local/share/chat-export/`, so any agent can search past conversations with `rg` or a full-text index. Stdlib-only, read-only, incremental, with best-effort credential redaction. Run `python3 scripts/export_all.py --dry-run` first. Chat logs are retrieval hints, not the source of truth. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,108 @@ | ||
| """Shared pieces for the chat exporters: turn grouping, redaction, markdown rendering, watermark.""" | ||
| import json | ||
| import os | ||
| import re | ||
| from pathlib import Path | ||
|
|
||
| # Exports are private conversations: every directory and file this process creates is owner-only. | ||
| os.umask(0o077) | ||
|
|
||
| DEST_ROOT = Path.home() / ".local/share/chat-export" | ||
|
|
||
| # A turn this large is almost always a pasted log or file; skip it rather than cut it mid-thought. | ||
| MAX_TURN_CHARS = 60_000 | ||
|
|
||
| # Credentials are redacted before anything touches disk. Most specific patterns first. | ||
| REDACTIONS = [ | ||
| (re.compile(r"0x[a-fA-F0-9]{64}\b"), "<REDACTED_PRIVKEY>"), | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a chat contains a conventional PEM private key, it is written verbatim: this rule only covers 64-hex-character blockchain keys, while the generic base64 rule does not match PEM bodies because they are normally wrapped into short lines. This contradicts the documented promise that private keys are replaced before export, so add a multiline PEM-key rule (and a fixture) rather than relying on the contiguous-base64 pattern. AGENTS.md reference: AGENTS.md:L10-L10 Useful? React with 👍 / 👎. |
||
| (re.compile(r"\bsk-[A-Za-z0-9_\-]{20,}"), "<REDACTED_APIKEY>"), | ||
| (re.compile(r"\bghp_[A-Za-z0-9]{20,}"), "<REDACTED_GHTOKEN>"), | ||
| (re.compile(r"\bgithub_pat_[A-Za-z0-9_]{20,}"), "<REDACTED_GHTOKEN>"), | ||
| (re.compile(r"\bAKIA[0-9A-Z]{16}\b"), "<REDACTED_AWSKEY>"), | ||
| (re.compile(r"\bxox[baprs]-[A-Za-z0-9\-]{10,}"), "<REDACTED_SLACK>"), | ||
| (re.compile(r"(?i)\b(bearer)\s+[A-Za-z0-9._\-]{20,}"), r"\1 <REDACTED>"), | ||
| (re.compile(r"(?i)\b([A-Z0-9_]*(?:SECRET|PASSWORD|PRIVATE_KEY|API_KEY|TOKEN)[A-Z0-9_]*)\s*=\s*\S+"), | ||
| r"\1=<REDACTED>"), | ||
| (re.compile(r"[A-Za-z0-9+/]{300,}={0,2}"), "<REDACTED_BLOB>"), | ||
| ] | ||
|
|
||
|
|
||
| def redact(text): | ||
| for pattern, repl in REDACTIONS: | ||
| text = pattern.sub(repl, text) | ||
| return text | ||
|
|
||
|
|
||
| def group_turns(messages): | ||
| """messages: [(role, text)] in order. A user text opens a turn; every reply until the next user text | ||
| belongs to it. Keeping all replies matters: newer agents stream progress notes before the final answer.""" | ||
| turns, current = [], None | ||
| for role, text in messages: | ||
| text = (text or "").strip() | ||
| if not text: | ||
| continue | ||
| if role == "user": | ||
| if current and current["assistant"]: | ||
| turns.append(current) | ||
| current = {"user": text, "assistant": []} | ||
| elif role == "assistant" and current is not None: | ||
| current["assistant"].append(text) | ||
| if current and current["assistant"]: | ||
| turns.append(current) | ||
| return turns | ||
|
|
||
|
|
||
| def render(source, session_id, turns, *, title="", timestamp="", cwd="", source_file=""): | ||
| lines = [ | ||
| "---", | ||
| f"session_id: {session_id}", | ||
| f"source: {source}", | ||
| f"title: {json.dumps(title or '', ensure_ascii=False)}", | ||
| f"timestamp: {timestamp or ''}", | ||
| f"cwd: {cwd or ''}", | ||
| f"turns: {len(turns)}", | ||
| f"source_file: {source_file}", | ||
| "---", | ||
| "", | ||
| f"# {source} {title or str(session_id)[:8]}", | ||
| "", | ||
| ] | ||
| for i, turn in enumerate(turns, 1): | ||
| assistant = "\n\n".join(turn["assistant"]) | ||
| if len(turn["user"]) + len(assistant) > MAX_TURN_CHARS: | ||
| lines.append(f"## Turn {i} (skipped: exceeds {MAX_TURN_CHARS} chars)\n") | ||
| continue | ||
| lines += [f"## Turn {i}", "", "**User:**", "", redact(turn["user"]), "", | ||
| "**Assistant:**", "", redact(assistant), ""] | ||
| return "\n".join(lines) | ||
|
|
||
|
|
||
| def file_sig(*paths): | ||
| """mtime+size of each existing path: cheap change detection for the watermark.""" | ||
| return ":".join(f"{p.stat().st_mtime_ns}/{p.stat().st_size}" for p in paths if p.exists()) | ||
|
|
||
|
|
||
| class Watermark: | ||
| """Per-source {key: signature} file so reruns only touch what changed.""" | ||
|
|
||
| def __init__(self, dest, full=False): | ||
| self.path = dest / ".watermark.json" | ||
| try: | ||
| self.state = {} if full else json.loads(self.path.read_text()) | ||
| except (OSError, json.JSONDecodeError): | ||
| self.state = {} | ||
|
|
||
| def unchanged(self, key, sig): | ||
| return self.state.get(key) == sig | ||
|
|
||
| def mark(self, key, sig): | ||
| self.state[key] = sig | ||
|
|
||
| def save(self): | ||
| self.path.parent.mkdir(parents=True, exist_ok=True) | ||
| self.path.write_text(json.dumps(self.state, indent=0)) | ||
| # Tighten directories left world-readable by an earlier run; new ones already get 0700 from the umask. | ||
| # Only the export dir itself and the default root; never a parent the user pointed --dest into. | ||
| self.path.parent.chmod(0o700) | ||
| if self.path.parent.parent == DEST_ROOT: | ||
| DEST_ROOT.chmod(0o700) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| #!/usr/bin/env python3 | ||
| """Run every exporter whose source exists on this machine. Pass --dry-run to only report.""" | ||
| import importlib | ||
| import sys | ||
| from pathlib import Path | ||
|
|
||
| sys.path.insert(0, str(Path(__file__).resolve().parent)) | ||
|
|
||
| EXPORTERS = ["export_claude", "export_codex", "export_cursor", "export_grok"] | ||
|
|
||
|
|
||
| def main(): | ||
| passthrough = [a for a in sys.argv[1:] if a in ("--dry-run", "--full")] | ||
| for name in EXPORTERS: | ||
| importlib.import_module(name).main(passthrough) | ||
| return 0 | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| sys.exit(main()) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,96 @@ | ||
| #!/usr/bin/env python3 | ||
| """Export Claude Code sessions (~/.claude/projects/*/<session>.jsonl) to one markdown file per session. | ||
|
|
||
| Kept: what you typed and Claude's visible text replies. | ||
| Dropped: system reminders, thinking, tool calls/results, meta records and subagent sidechains. | ||
| """ | ||
| import argparse | ||
| import json | ||
| import re | ||
| import sys | ||
| from pathlib import Path | ||
|
|
||
| sys.path.insert(0, str(Path(__file__).resolve().parent)) | ||
| from common import DEST_ROOT, Watermark, file_sig, group_turns, render # noqa: E402 | ||
|
|
||
| SRC = Path.home() / ".claude/projects" | ||
| DEST = DEST_ROOT / "claude" | ||
|
|
||
| INJECTED = [re.compile(rf"<{tag}>.*?</{tag}>", re.S) for tag in ( | ||
| "system-reminder", "local-command-stdout", "local-command-caveat", "task-notification")] | ||
|
|
||
|
|
||
| def strip_injected(text): | ||
| for pattern in INJECTED: | ||
| text = pattern.sub("", text) | ||
| return text.strip() | ||
|
|
||
|
|
||
| def text_blocks(content): | ||
| if isinstance(content, str): | ||
| return content | ||
| if not isinstance(content, list): | ||
| return "" | ||
| return "\n".join(b["text"] for b in content | ||
| if isinstance(b, dict) and b.get("type") == "text" and isinstance(b.get("text"), str)) | ||
|
|
||
|
|
||
| def parse_session(path): | ||
| meta, messages = {}, [] | ||
| for line in path.open(encoding="utf-8", errors="replace"): | ||
| try: | ||
| rec = json.loads(line) | ||
| except json.JSONDecodeError: | ||
| continue | ||
| rtype = rec.get("type") | ||
| if rtype == "custom-title" and isinstance(rec.get("customTitle"), str): | ||
| meta["title"] = rec["customTitle"] | ||
| continue | ||
| if rtype not in ("user", "assistant") or rec.get("isSidechain") or rec.get("isMeta"): | ||
| continue | ||
| if not meta.get("session_id"): | ||
| meta.update(session_id=rec.get("sessionId"), cwd=rec.get("cwd"), timestamp=rec.get("timestamp")) | ||
| text = text_blocks((rec.get("message") or {}).get("content")) | ||
| if rtype == "user": | ||
| text = strip_injected(text) # empty afterwards = tool result or pure injection | ||
| messages.append((rtype, text)) | ||
| return meta, group_turns(messages) | ||
|
|
||
|
|
||
| def main(argv=None): | ||
| ap = argparse.ArgumentParser(description=__doc__) | ||
| ap.add_argument("--src", type=Path, default=SRC) | ||
| ap.add_argument("--dest", type=Path, default=DEST) | ||
| ap.add_argument("--full", action="store_true", help="ignore the watermark and reprocess everything") | ||
| ap.add_argument("--dry-run", action="store_true", help="report only, write nothing") | ||
| args = ap.parse_args(argv) | ||
|
|
||
| mark = Watermark(args.dest, args.full) | ||
| files = sorted((p for p in args.src.glob("*/*.jsonl") if "subagents" not in p.parts), | ||
| key=lambda p: p.stat().st_mtime_ns) # a session copied across project dirs: newest wins | ||
| written = empty = 0 | ||
| for f in files: | ||
| sig = file_sig(f) | ||
| if mark.unchanged(str(f), sig): | ||
| continue | ||
| meta, turns = parse_session(f) | ||
| if turns: | ||
| if not args.dry_run: | ||
| args.dest.mkdir(parents=True, exist_ok=True) | ||
| date = (meta.get("timestamp") or "")[:10] or "undated" | ||
| (args.dest / f"{date}-{f.stem}.md").write_text(render( | ||
| "claude", meta.get("session_id") or f.stem, turns, title=meta.get("title", ""), | ||
| timestamp=meta.get("timestamp") or "", cwd=meta.get("cwd") or "", source_file=str(f)), encoding="utf-8") | ||
| written += 1 | ||
| else: | ||
| empty += 1 | ||
| mark.mark(str(f), sig) | ||
| if not args.dry_run: | ||
| mark.save() | ||
| print(f"claude: {written} written, {empty} empty, {len(files)} sessions seen" | ||
| + (" (dry run)" if args.dry_run else f" -> {args.dest}")) | ||
| return 0 | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| sys.exit(main()) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a later PR changes only
labs/chat-export/**, this fixture step will never run because this workflow'spull_request.pathsfilter includeslabs/opc-company/**and the READMEs, but not the new lab. Add the Chat Export path to the filter so changes to these exporters are actually covered by their CI test.Useful? React with 👍 / 👎.