From acfefa4fdacc8dfc16aed3766c1f7e2db8eda76b Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Tue, 7 Jul 2026 06:15:53 -0700 Subject: [PATCH] feat(sessions): full prune-filter set + --redact on sessions export - export now shares _add_session_filter_args / build_prune_filters with prune/archive: AGE grammar (5h/2d/1w/ISO) on --older-than plus the full filter set (--model, --provider, --min-messages, --min-cost, --branch, --chat-id, ...) for both JSONL and md/qmd bulk exports; --dry-run works on JSONL too; removes the one-off list_export_candidates helper. - new --redact flag runs exported message content and tool output through force-mode secret redaction (agent.redact) for jsonl, md, and qmd. - docs EN + zh-Hans updated; new tests for AGE grammar, extended filters, filtered JSONL, and redaction. --- hermes_cli/main.py | 200 ++++++++++++------ hermes_cli/session_export_md.py | 28 +++ hermes_state.py | 33 --- .../hermes_cli/test_sessions_export_md_cli.py | 152 ++++++++++++- tests/hermes_state/test_session_md_export.py | 15 +- website/docs/user-guide/sessions.md | 14 +- .../current/user-guide/sessions.md | 14 +- 7 files changed, 339 insertions(+), 117 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 49558253993..c7120483a4b 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -13433,65 +13433,6 @@ def main(): "--limit", type=int, default=20, help="Max sessions to show" ) - sessions_export = sessions_subparsers.add_parser( - "export", help="Export sessions to JSONL, Markdown, or QMD" - ) - sessions_export.add_argument( - "output", - nargs="?", - help=( - "Output path. JSONL: file path (use - for stdout, required). " - "md/qmd: output directory (default: /session-exports)" - ), - ) - sessions_export.add_argument( - "--format", - choices=["jsonl", "md", "qmd"], - default="jsonl", - help="Export format (default: jsonl)", - ) - sessions_export.add_argument("--source", help="Filter by source") - sessions_export.add_argument( - "--session-id", help="Session ID or unique prefix to export" - ) - sessions_export.add_argument( - "--older-than", - type=int, - help="md/qmd only: bulk-export ended sessions older than N days", - ) - sessions_export.add_argument( - "--dry-run", - action="store_true", - help="md/qmd only: preview matching sessions without writing files", - ) - sessions_export.add_argument( - "--lineage", - choices=["single", "logical"], - default="single", - help="md/qmd only: export one row or its compression lineage", - ) - sessions_export.add_argument( - "--delete-after-verified", - action="store_true", - help="md/qmd only: after verified single-session export, delete that session", - ) - sessions_export.add_argument( - "--yes", "-y", action="store_true", help="Required with --delete-after-verified" - ) - sessions_export.add_argument( - "--force", - action="store_true", - help="md/qmd only: overwrite an existing export file", - ) - - sessions_delete = sessions_subparsers.add_parser( - "delete", help="Delete a specific session" - ) - sessions_delete.add_argument("session_id", help="Session ID to delete") - sessions_delete.add_argument( - "--yes", "-y", action="store_true", help="Skip confirmation" - ) - def _add_session_filter_args(p, default_older_help): p.add_argument( "--older-than", @@ -13589,6 +13530,61 @@ def main(): "--yes", "-y", action="store_true", help="Skip confirmation" ) + sessions_export = sessions_subparsers.add_parser( + "export", help="Export sessions to JSONL, Markdown, or QMD" + ) + sessions_export.add_argument( + "output", + nargs="?", + help=( + "Output path. JSONL: file path (use - for stdout, required). " + "md/qmd: output directory (default: /session-exports)" + ), + ) + sessions_export.add_argument( + "--format", + choices=["jsonl", "md", "qmd"], + default="jsonl", + help="Export format (default: jsonl)", + ) + sessions_export.add_argument( + "--session-id", help="Session ID or unique prefix to export" + ) + _add_session_filter_args( + sessions_export, + "Only export sessions older than AGE (duration like '5h'/'2d', " + "bare number of days, or an ISO timestamp)", + ) + sessions_export.add_argument( + "--redact", + action="store_true", + help="Redact secrets (API keys, tokens, credentials) from exported content", + ) + sessions_export.add_argument( + "--lineage", + choices=["single", "logical"], + default="single", + help="md/qmd only: export one row or its compression lineage", + ) + sessions_export.add_argument( + "--delete-after-verified", + action="store_true", + help="md/qmd only: after verified single-session export, delete that session (needs --yes)", + ) + sessions_export.add_argument( + "--force", + action="store_true", + help="md/qmd only: overwrite an existing export file", + ) + + sessions_delete = sessions_subparsers.add_parser( + "delete", help="Delete a specific session" + ) + sessions_delete.add_argument("session_id", help="Session ID to delete") + sessions_delete.add_argument( + "--yes", "-y", action="store_true", help="Skip confirmation" + ) + sessions_prune = sessions_subparsers.add_parser( "prune", help="Delete old sessions (filterable by time window, source, title, ...)", @@ -13759,6 +13755,39 @@ def main(): print(f"{preview:<50} {last_active:<13} {s['source']:<6} {sid}") elif action == "export": + from hermes_cli.session_filters import ( + build_prune_filters, + describe_filters, + ) + + _filter_arg_names = ( + "older_than", "newer_than", "before", "after", + "source", "title", "end_reason", "cwd", + "min_messages", "max_messages", "model", "provider", + "user", "chat_id", "chat_type", "branch", + "min_tokens", "max_tokens", "min_cost", "max_cost", + "min_tool_calls", "max_tool_calls", + ) + _any_filters = any( + getattr(args, a, None) is not None for a in _filter_arg_names + ) + filters = None + if _any_filters: + try: + filters = build_prune_filters(args) + except ValueError as e: + print(f"Error: {e}") + return + # Unlike prune/archive, export includes archived sessions. + filters["archived"] = None + + def _redact(data): + if not args.redact or data is None: + return data + from hermes_cli.session_export_md import redact_session_data + + return redact_session_data(data) + if args.format == "jsonl": if not args.output: print("JSONL export requires an output path (use - for stdout).") @@ -13768,7 +13797,7 @@ def main(): if not resolved_session_id: print(f"Session '{args.session_id}' not found.") return - data = db.export_session(resolved_session_id) + data = _redact(db.export_session(resolved_session_id)) if not data: print(f"Session '{args.session_id}' not found.") return @@ -13781,15 +13810,42 @@ def main(): f.write(line) print(f"Exported 1 session to {args.output}") else: - sessions = db.export_all(source=args.source) + if filters: + candidates = db.list_prune_candidates(**filters) + if args.dry_run: + print( + f"Would export {len(candidates)} session(s) " + f"({describe_filters(filters)})." + ) + for row in candidates[:100]: + print(f" {row.get('id')} {row.get('source', '')}") + if len(candidates) > 100: + print(f" ... {len(candidates) - 100} more") + return + sessions = [ + s + for s in ( + db.export_session(row["id"]) for row in candidates + ) + if s + ] + else: + if args.dry_run: + print("--dry-run requires at least one filter.") + return + sessions = db.export_all(source=None) if args.output == "-": for s in sessions: - sys.stdout.write(_json.dumps(s, ensure_ascii=False) + "\n") + sys.stdout.write( + _json.dumps(_redact(s), ensure_ascii=False) + "\n" + ) else: with open(args.output, "w", encoding="utf-8") as f: for s in sessions: - f.write(_json.dumps(s, ensure_ascii=False) + "\n") + f.write( + _json.dumps(_redact(s), ensure_ascii=False) + "\n" + ) print(f"Exported {len(sessions)} sessions to {args.output}") return @@ -13814,6 +13870,7 @@ def main(): ) if not data: return None, None + data = _redact(data) path = write_session_markdown( data, output_dir, @@ -13865,16 +13922,19 @@ def main(): db.close() return - if args.older_than is None and not args.source: - print("Refusing bulk export without a filter. Pass --session-id, --older-than, or --source.") + if not filters: + print( + "Refusing bulk export without a filter. Pass --session-id or " + "at least one filter (e.g. --older-than 90, --source telegram)." + ) db.close() return - candidates = db.list_export_candidates( - older_than_days=args.older_than, - source=args.source, - ) + candidates = db.list_prune_candidates(**filters) if args.dry_run: - print(f"Would export {len(candidates)} session(s).") + print( + f"Would export {len(candidates)} session(s) " + f"({describe_filters(filters)})." + ) for row in candidates[:100]: print(f" {row.get('id')} {row.get('source', '')}") if len(candidates) > 100: diff --git a/hermes_cli/session_export_md.py b/hermes_cli/session_export_md.py index 80619ed094b..e2ab0c8a6d3 100644 --- a/hermes_cli/session_export_md.py +++ b/hermes_cli/session_export_md.py @@ -216,6 +216,34 @@ def verify_export_file(path: Path | str, session: dict[str, Any]) -> tuple[bool, return True, "ok" +def redact_session_data(session: dict[str, Any]) -> dict[str, Any]: + """Return a deep copy of a session export dict with secrets redacted. + + Runs every message's content and tool-call arguments through the + force-mode redaction pass (``agent.redact.redact_sensitive_text``), so + API keys, tokens, and credentials that appeared in tool output never + land in plaintext export files. Force mode ignores the user's global + ``security.redact_secrets`` preference — an explicit ``--redact`` export + must never emit raw secrets. + """ + from agent.redact import redact_sensitive_text + + def _clean(value: Any) -> Any: + if isinstance(value, str): + return redact_sensitive_text(value, force=True) + if isinstance(value, list): + return [_clean(v) for v in value] + if isinstance(value, dict): + return {k: _clean(v) for k, v in value.items()} + return value + + redacted = dict(session) + for key in ("messages", "segments"): + if key in redacted and redacted[key] is not None: + redacted[key] = _clean(redacted[key]) + return redacted + + def write_session_markdown( session: dict[str, Any], output_dir: Path | str, *, fmt: str = "md", force: bool = False ) -> Path: diff --git a/hermes_state.py b/hermes_state.py index fe8bc5949fd..9071f4b34b0 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -4958,39 +4958,6 @@ class SessionDB: # Export and cleanup # ========================================================================= - def list_export_candidates( - self, - older_than_days: Optional[int] = None, - source: str = None, - limit: int = 100000, - ) -> List[Dict[str, Any]]: - """List ended sessions that match read-only export filters. - - This helper intentionally does not delete, archive, or mutate rows. It - backs `hermes sessions export --format md/qmd` bulk exports. - """ - clauses = ["ended_at IS NOT NULL"] - params: list[Any] = [] - if older_than_days is not None: - cutoff = time.time() - (older_than_days * 86400) - clauses.append("started_at < ?") - params.append(cutoff) - if source: - clauses.append("source = ?") - params.append(source) - params.append(limit) - with self._lock: - rows = self._conn.execute( - f""" - SELECT * FROM sessions - WHERE {' AND '.join(clauses)} - ORDER BY started_at ASC - LIMIT ? - """, - params, - ).fetchall() - return [dict(row) for row in rows] - def _is_branch_child_row(self, session: Dict[str, Any]) -> bool: raw = session.get("model_config") if not raw: diff --git a/tests/hermes_cli/test_sessions_export_md_cli.py b/tests/hermes_cli/test_sessions_export_md_cli.py index 4c4fdc0194e..eed3cb24433 100644 --- a/tests/hermes_cli/test_sessions_export_md_cli.py +++ b/tests/hermes_cli/test_sessions_export_md_cli.py @@ -187,8 +187,14 @@ def test_sessions_export_md_bulk_dry_run_lists_candidates(monkeypatch, tmp_path, import hermes_state class FakeDB: - def list_export_candidates(self, **kwargs): - assert kwargs == {"older_than_days": 30, "source": "cron"} + def list_prune_candidates(self, **kwargs): + # Export flows through the shared prune-filter machinery: + # --older-than 30 becomes a started_before epoch bound, source + # passes through, and archived is tri-state None (export includes + # archived sessions). + assert kwargs.get("source") == "cron" + assert kwargs.get("started_before") is not None + assert kwargs.get("archived") is None return [{"id": "s1", "source": "cron"}, {"id": "s2", "source": "cron"}] def close(self): @@ -227,7 +233,7 @@ def test_sessions_export_md_bulk_requires_filter(monkeypatch, tmp_path, capsys): import hermes_state class FakeDB: - def list_export_candidates(self, **kwargs): + def list_prune_candidates(self, **kwargs): raise AssertionError("bulk export without filters should refuse") def close(self): @@ -250,7 +256,7 @@ def test_sessions_export_md_bulk_writes_manifest(monkeypatch, tmp_path, capsys): import hermes_state class FakeDB: - def list_export_candidates(self, **kwargs): + def list_prune_candidates(self, **kwargs): return [{"id": "s1"}, {"id": "s2"}] def export_session_lineage(self, session_id): @@ -360,3 +366,141 @@ def test_sessions_export_md_delete_after_verified_deletes_after_file_check(monke assert captured == {"deleted": "s1"} assert len(list(tmp_path.glob("*.md"))) == 1 assert "Deleted exported session 's1'" in capsys.readouterr().out + + +def test_sessions_export_md_accepts_duration_age_grammar(monkeypatch, tmp_path, capsys): + """--older-than accepts the same AGE grammar as prune ('2w', '5h', ISO).""" + import hermes_cli.main as main_mod + import hermes_state + + class FakeDB: + def list_prune_candidates(self, **kwargs): + assert kwargs.get("started_before") is not None + return [{"id": "s1", "source": "cli"}] + + def close(self): + pass + + monkeypatch.setattr(hermes_state, "SessionDB", lambda: FakeDB()) + monkeypatch.setattr( + sys, + "argv", + [ + "hermes", "sessions", "export", "--format", "md", + "--older-than", "2w", "--dry-run", str(tmp_path), + ], + ) + + main_mod.main() + + assert "Would export 1 session(s)" in capsys.readouterr().out + + +def test_sessions_export_md_supports_extended_prune_filters(monkeypatch, tmp_path, capsys): + """Filters like --model/--min-messages pass through the shared machinery.""" + import hermes_cli.main as main_mod + import hermes_state + + captured = {} + + class FakeDB: + def list_prune_candidates(self, **kwargs): + captured.update(kwargs) + return [] + + def close(self): + pass + + monkeypatch.setattr(hermes_state, "SessionDB", lambda: FakeDB()) + monkeypatch.setattr( + sys, + "argv", + [ + "hermes", "sessions", "export", "--format", "md", + "--model", "sonnet", "--min-messages", "5", "--dry-run", + str(tmp_path), + ], + ) + + main_mod.main() + + assert captured.get("model_like") == "sonnet" + assert captured.get("min_messages") == 5 + assert "Would export 0 session(s)" in capsys.readouterr().out + + +def test_sessions_export_jsonl_honors_filters(monkeypatch, tmp_path, capsys): + """JSONL bulk export uses the same filter machinery as md/qmd.""" + import json + + import hermes_cli.main as main_mod + import hermes_state + + class FakeDB: + def list_prune_candidates(self, **kwargs): + assert kwargs.get("source") == "telegram" + return [{"id": "s1", "source": "telegram"}] + + def export_session(self, session_id): + return {"id": session_id, "messages": [{"role": "user", "content": "hi"}]} + + def export_all(self, **kwargs): + raise AssertionError("filtered jsonl export must not fall back to export_all") + + def close(self): + pass + + out = tmp_path / "out.jsonl" + monkeypatch.setattr(hermes_state, "SessionDB", lambda: FakeDB()) + monkeypatch.setattr( + sys, + "argv", + ["hermes", "sessions", "export", "--source", "telegram", str(out)], + ) + + main_mod.main() + + lines = out.read_text(encoding="utf-8").splitlines() + assert len(lines) == 1 + assert json.loads(lines[0])["id"] == "s1" + assert "Exported 1 sessions" in capsys.readouterr().out + + +def test_sessions_export_redact_scrubs_secrets(monkeypatch, tmp_path): + """--redact runs exported content through force-mode secret redaction.""" + import hermes_cli.main as main_mod + import hermes_state + + secret = "sk-proj-Zz12345678901234567890123456789012345678" + + class FakeDB: + def resolve_session_id(self, session_id): + return "s1" + + def export_session(self, session_id): + return { + "id": "s1", + "title": "Redact", + "messages": [ + {"role": "tool", "name": "terminal", "content": f"api key: {secret}"} + ], + } + + def close(self): + pass + + monkeypatch.setattr(hermes_state, "SessionDB", lambda: FakeDB()) + monkeypatch.setattr( + sys, + "argv", + [ + "hermes", "sessions", "export", "--format", "md", + "--session-id", "s1", "--redact", str(tmp_path), + ], + ) + + main_mod.main() + + text = next(tmp_path.glob("*.md")).read_text(encoding="utf-8") + assert secret not in text + assert "api key:" in text diff --git a/tests/hermes_state/test_session_md_export.py b/tests/hermes_state/test_session_md_export.py index 50d6cf322bb..8efbd2f50c7 100644 --- a/tests/hermes_state/test_session_md_export.py +++ b/tests/hermes_state/test_session_md_export.py @@ -4,7 +4,7 @@ import hermes_state from hermes_state import SessionDB -def test_list_export_candidates_filters_ended_old_sessions(tmp_path, monkeypatch): +def test_export_candidates_via_prune_filters_ended_old_sessions(tmp_path, monkeypatch): db = SessionDB(db_path=tmp_path / "state.db") monkeypatch.setattr(hermes_state.time, "time", lambda: 2_000_000.0) try: @@ -20,13 +20,16 @@ def test_list_export_candidates_filters_ended_old_sessions(tmp_path, monkeypatch db._conn.execute("UPDATE sessions SET started_at=? WHERE id=?", (1_000_000.0, "old_active")) db._conn.commit() - candidates = db.list_export_candidates(older_than_days=5) + # Export uses the shared prune/archive candidate selection. + candidates = db.list_prune_candidates( + started_before=2_000_000.0 - 5 * 86400, archived=None + ) assert [c["id"] for c in candidates] == ["old_cli"] finally: db.close() -def test_list_export_candidates_ands_source_filter(tmp_path, monkeypatch): +def test_export_candidates_via_prune_ands_source_filter(tmp_path, monkeypatch): db = SessionDB(db_path=tmp_path / "state.db") monkeypatch.setattr(hermes_state.time, "time", lambda: 2_000_000.0) try: @@ -36,7 +39,11 @@ def test_list_export_candidates_ands_source_filter(tmp_path, monkeypatch): db._conn.execute("UPDATE sessions SET started_at=?, ended_at=? WHERE id=?", (1_000_000.0, 1_000_010.0, sid)) db._conn.commit() - candidates = db.list_export_candidates(older_than_days=5, source="telegram") + candidates = db.list_prune_candidates( + started_before=2_000_000.0 - 5 * 86400, + source="telegram", + archived=None, + ) assert [c["id"] for c in candidates] == ["old_telegram"] finally: db.close() diff --git a/website/docs/user-guide/sessions.md b/website/docs/user-guide/sessions.md index 2c6b91a77a3..fdbea52cbed 100644 --- a/website/docs/user-guide/sessions.md +++ b/website/docs/user-guide/sessions.md @@ -303,10 +303,15 @@ hermes sessions export telegram-history.jsonl --source telegram # Export a single session hermes sessions export session.jsonl --session-id 20250305_091523_a1b2c3d4 + +# Redact API keys/tokens/credentials from the exported content +hermes sessions export backup.jsonl --redact ``` Exported files contain one JSON object per line with full session metadata and all messages. +`export` accepts the same filters as `prune` / `archive` — `--older-than` / `--newer-than` / `--before` / `--after` (durations like `5h`/`2d`/`1w`, bare days, or ISO timestamps), `--source`, `--title`, `--model`, `--provider`, `--cwd`, `--min-messages` / `--max-messages`, `--min-tokens` / `--max-tokens`, `--min-cost` / `--max-cost`, `--min-tool-calls` / `--max-tool-calls`, `--user`, `--chat-id`, `--chat-type`, `--branch`, and `--end-reason`. Add `--dry-run` to preview which sessions match without writing anything. Note: bulk filters match *ended* sessions; unfiltered `export` dumps everything, including active ones. + ### Export Sessions to Markdown/QMD Pass `--format md` or `--format qmd` when you want a readable, file-based archive before hiding or deleting old sessions. Markdown/QMD exports write one file per session into a directory (default: `~/.hermes/session-exports`). @@ -321,14 +326,17 @@ hermes sessions export --format md --session-id 20250305_091523_a1b2c3d4 --linea # Preview ended sessions older than 90 days without writing files hermes sessions export --format md --older-than 90 --dry-run -# Export ended Telegram sessions older than 30 days to QMD files -hermes sessions export --format qmd --older-than 30 --source telegram +# Export ended Telegram sessions older than 2 weeks to QMD files +hermes sessions export --format qmd --older-than 2w --source telegram + +# Export long Claude sessions, secrets redacted +hermes sessions export --format md --model sonnet --min-messages 50 --redact # Only after verification, export and delete one explicitly named session hermes sessions export --format md --session-id 20250305_091523_a1b2c3d4 --delete-after-verified --yes ``` -Markdown/QMD export writes one `.md` or `.qmd` file per exported session plus a `manifest.jsonl` with the file path, message count, lineage ids, and SHA-256. Bulk export requires a filter such as `--older-than` or `--source`; a bare bulk export is refused. `--delete-after-verified` is intentionally limited to `--session-id` and requires `--yes`. +Markdown/QMD export writes one `.md` or `.qmd` file per exported session plus a `manifest.jsonl` with the file path, message count, lineage ids, and SHA-256. Bulk export requires at least one filter; a bare bulk export is refused. `--delete-after-verified` is intentionally limited to `--session-id` and requires `--yes`. `--redact` scrubs secrets (API keys, tokens, credentials) from message content and tool output before writing — recommended for any export you plan to share. ### Delete a Session diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/sessions.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/sessions.md index bcaac1277bf..3b0da17196e 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/sessions.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/sessions.md @@ -280,10 +280,15 @@ hermes sessions export telegram-history.jsonl --source telegram # 导出单个 session hermes sessions export session.jsonl --session-id 20250305_091523_a1b2c3d4 + +# 从导出内容中脱敏 API key/token/凭据 +hermes sessions export backup.jsonl --redact ``` 导出文件每行包含一个 JSON 对象,包含完整的 session 元数据和所有消息。 +`export` 接受与 `prune` / `archive` 相同的过滤器 — `--older-than` / `--newer-than` / `--before` / `--after`(时长如 `5h`/`2d`/`1w`、纯数字天数或 ISO 时间戳)、`--source`、`--title`、`--model`、`--provider`、`--cwd`、`--min-messages` / `--max-messages`、`--min-tokens` / `--max-tokens`、`--min-cost` / `--max-cost`、`--min-tool-calls` / `--max-tool-calls`、`--user`、`--chat-id`、`--chat-type`、`--branch` 和 `--end-reason`。加 `--dry-run` 可预览匹配的 session 而不写入任何内容。注意:带过滤器的批量导出只匹配*已结束*的 session;不带过滤器的 `export` 会导出所有 session(包括活跃的)。 + ### 导出 Session 为 Markdown/QMD 当你想在隐藏或删除旧 session 之前保留一份可读的文件归档时,传入 `--format md` 或 `--format qmd`。Markdown/QMD 导出会为每个 session 写入一个文件到目录中(默认:`~/.hermes/session-exports`)。 @@ -298,14 +303,17 @@ hermes sessions export --format md --session-id 20250305_091523_a1b2c3d4 --linea # 预览 90 天前已结束的 session,不写入文件 hermes sessions export --format md --older-than 90 --dry-run -# 将 30 天前已结束的 Telegram session 导出为 QMD 文件 -hermes sessions export --format qmd --older-than 30 --source telegram +# 将 2 周前已结束的 Telegram session 导出为 QMD 文件 +hermes sessions export --format qmd --older-than 2w --source telegram + +# 导出长的 Claude session,并脱敏 +hermes sessions export --format md --model sonnet --min-messages 50 --redact # 导出并在校验通过后删除一个明确指定的 session hermes sessions export --format md --session-id 20250305_091523_a1b2c3d4 --delete-after-verified --yes ``` -Markdown/QMD 导出为每个 session 写入一个 `.md` 或 `.qmd` 文件,并附带一个 `manifest.jsonl`,记录文件路径、消息数量、lineage id 和 SHA-256。批量导出必须带过滤条件(如 `--older-than` 或 `--source`),不带过滤条件的批量导出会被拒绝。`--delete-after-verified` 仅限与 `--session-id` 搭配使用,且必须加 `--yes`。 +Markdown/QMD 导出为每个 session 写入一个 `.md` 或 `.qmd` 文件,并附带一个 `manifest.jsonl`,记录文件路径、消息数量、lineage id 和 SHA-256。批量导出必须带至少一个过滤条件,不带过滤条件的批量导出会被拒绝。`--delete-after-verified` 仅限与 `--session-id` 搭配使用,且必须加 `--yes`。`--redact` 会在写入前从消息内容和工具输出中清除密钥(API key、token、凭据)— 任何打算分享的导出都建议加上。 ### 删除 Session