Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/opc-lab.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ jobs:
run: ./scripts/verify.sh
- name: Verify OPC fixtures
run: python3 labs/opc-company/tests/run.py
- name: Verify Chat Export fixtures
run: python3 labs/chat-export/tests/run.py
Comment on lines +22 to +23

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Trigger the workflow for Chat Export changes

When a later PR changes only labs/chat-export/**, this fixture step will never run because this workflow's pull_request.paths filter includes labs/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 👍 / 👎.

- name: Parse OPC JSON
run: |
find labs/opc-company -name '*.json' -print0 | while IFS= read -r -d '' file; do
Expand Down
2 changes: 2 additions & 0 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ If you are exploring how multiple long-lived owners/workers can share canonical

If your agents can already keep working but keep interrupting you for the wrong reasons, see the experimental [Attention Inbox Lab](labs/attention-inbox/README.md). It keeps routine completions and internal waiting states quiet, and surfaces only concrete human decisions or meaningful risks. It is a derived attention layer over existing task/receipt state, not another queue or scheduler.

If you use several AI tools and want each of them to find what you discussed in the others, see the experimental [Chat Export Lab](labs/chat-export/README.md). It turns local Claude Code, Codex, Cursor and Grok Bot chat history into one Markdown format: read-only, incremental, with common credentials redacted. Chat logs stay retrieval hints; files remain the source of truth.

## Try it inside one isolated project

```bash
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ QuietHarness 不会给每个请求套一层仪式,也不是要求你复制我

如果你的 Agent 已经能持续工作,但经常因为错误的 `waiting_for_user`、常规完成或重复状态打断你,可以看实验性的 [Attention Inbox Lab](labs/attention-inbox/README.md)。它只把真正的人工决策和有意义的风险送到注意力入口;任务事实、调度和执行仍留在现有系统里,不另造一套队列或控制面。

如果你同时用几家 AI,想让它们都能翻到之前在别家聊过什么,可以看实验性的 [Chat Export Lab](labs/chat-export/README.md)。它把 Claude Code、Codex、Cursor、Grok Bot 的本地聊天记录导成同一种 Markdown,只读、增量、导出前去掉常见凭证;聊天记录只当线索,事实仍以文件为准。

## 先在一个隔离项目试用

```bash
Expand Down Expand Up @@ -276,6 +278,7 @@ docs/ # 我的系统架构、任务、收尾与日报
examples/first-success/ # 单客户端隔离试用与行为验收
examples/leo-system/ # 脱敏的真实结构示例
labs/attention-inbox/ # 实验性的 Agent 注意力协议与策略
labs/chat-export/ # 实验性的多 AI 聊天记录导出
scripts/ # 盘点、安装、验证
tests/ # 隔离 HOME 冒烟测试
media/launch-v3/ # 系统地图与发布素材
Expand Down
73 changes: 73 additions & 0 deletions labs/chat-export/README.md
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.
108 changes: 108 additions & 0 deletions labs/chat-export/scripts/common.py
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>"),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Redact PEM private keys before writing exports

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)
20 changes: 20 additions & 0 deletions labs/chat-export/scripts/export_all.py
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())
96 changes: 96 additions & 0 deletions labs/chat-export/scripts/export_claude.py
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())
Loading
Loading