diff --git a/hermes_cli/main.py b/hermes_cli/main.py index c7120483a4b..3593d78fdbc 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -13543,10 +13543,18 @@ def main(): ) sessions_export.add_argument( "--format", - choices=["jsonl", "md", "qmd"], + choices=["jsonl", "md", "qmd", "html"], default="jsonl", help="Export format (default: jsonl)", ) + sessions_export.add_argument( + "--only", + choices=["user-prompts"], + help=( + "Export only a filtered view (user-prompts: one prompt record " + "per line for jsonl, headed sections for md)" + ), + ) sessions_export.add_argument( "--session-id", help="Session ID or unique prefix to export" ) @@ -13788,6 +13796,99 @@ def main(): return redact_session_data(data) + def _collect_sessions(): + """Resolve --session-id / filters / bare export into a list + of redacted session dicts, or None after printing an error.""" + if args.session_id: + resolved = db.resolve_session_id(args.session_id) + data = _redact(db.export_session(resolved)) if resolved else None + if not data: + print(f"Session '{args.session_id}' not found.") + return None + return [data] + 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 None + return [ + s + for s in ( + _redact(db.export_session(row["id"])) for row in candidates + ) + if s + ] + if args.dry_run: + print("--dry-run requires at least one filter.") + return None + return [_redact(s) for s in db.export_all(source=None)] + + # Prompt-only export (--only user-prompts): one prompt record per + # line (jsonl) or headed sections (md). Delegates rendering to + # hermes_cli.session_export. + if getattr(args, "only", None): + if args.format not in ("jsonl", "md"): + print("--only user-prompts supports --format jsonl or md.") + return + from hermes_cli.session_export import ( + export_record_count, + render_sessions_export, + ) + + sessions = _collect_sessions() + if sessions is None: + db.close() + return + rendered = render_sessions_export( + sessions, + fmt="markdown" if args.format == "md" else "jsonl", + only=args.only, + ) + if not args.output or args.output == "-": + sys.stdout.write(rendered) + db.close() + return + with open(args.output, "w", encoding="utf-8") as f: + f.write(rendered) + count, noun = export_record_count(sessions, only=args.only) + suffix = "" if count == 1 else "s" + print(f"Exported {count} {noun}{suffix} to {args.output}") + db.close() + return + + # Standalone HTML export: one self-contained file (single session + # or multi-session with sidebar navigation). + if args.format == "html": + if not args.output or args.output == "-": + print("HTML export requires an output file path.") + return + from hermes_cli.session_export_html import ( + generate_html_export, + generate_multi_session_html_export, + ) + + sessions = _collect_sessions() + if sessions is None: + db.close() + return + if len(sessions) == 1: + content = generate_html_export(sessions[0]) + else: + content = generate_multi_session_html_export(sessions) + with open(args.output, "w", encoding="utf-8") as f: + f.write(content) + suffix = "" if len(sessions) == 1 else "s" + print(f"Exported {len(sessions)} session{suffix} to {args.output} (HTML)") + db.close() + return + if args.format == "jsonl": if not args.output: print("JSONL export requires an output path (use - for stdout).") diff --git a/scripts/release.py b/scripts/release.py index 65001e564fb..2b4c3d2199b 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -839,6 +839,9 @@ AUTHOR_MAP = { "27719690+Mirac1eSky@users.noreply.github.com": "Mirac1eSky", "web3blind@users.noreply.github.com": "web3blind", "264741654+web3blind@users.noreply.github.com": "web3blind", + # Sessions export format cluster (July 2026): HTML + prompt-only salvages + "840004959@qq.com": "simplast", + "catbearlove1@gmail.com": "catbearlove1-lang", "julia@alexland.us": "alexg0bot", "christian@scheid.tech": "scheidti", # Moonshot schema anyOf+enum salvage (May 2026) diff --git a/tests/hermes_cli/test_session_export.py b/tests/hermes_cli/test_session_export.py index c737ccd6861..10bb4b0435c 100644 --- a/tests/hermes_cli/test_session_export.py +++ b/tests/hermes_cli/test_session_export.py @@ -188,7 +188,7 @@ def test_sessions_export_cli_prompt_only_markdown_file(monkeypatch, capsys, tmp_ "--session-id", "sess", "--format", - "markdown", + "md", "--only", "user-prompts", ], @@ -203,13 +203,13 @@ def test_sessions_export_cli_prompt_only_markdown_file(monkeypatch, capsys, tmp_ assert "I will inspect the auth middleware." not in content -def test_sessions_export_rejects_full_session_markdown(monkeypatch, capsys): +def test_sessions_export_only_rejects_unsupported_format(monkeypatch, capsys): import hermes_cli.main as main_mod import hermes_state class FakeDB: def export_all(self, source=None): - return [_sample_session()] + raise AssertionError("should refuse before touching the DB") def close(self): pass @@ -218,92 +218,9 @@ def test_sessions_export_rejects_full_session_markdown(monkeypatch, capsys): monkeypatch.setattr( sys, "argv", - ["hermes", "sessions", "export", "-", "--format", "markdown"], + ["hermes", "sessions", "export", "-", "--format", "html", "--only", "user-prompts"], ) main_mod.main() - assert ( - "Error: --format markdown currently requires --only user-prompts." - in capsys.readouterr().out - ) - - -def test_sessions_export_prompts_cli_stdout(monkeypatch, capsys): - import hermes_cli.main as main_mod - import hermes_state - - captured = {} - - class FakeDB: - def resolve_session_id(self, session_id): - captured["resolved_from"] = session_id - return "sess-123" - - def export_session(self, session_id): - captured["exported"] = session_id - return _sample_session() - - def close(self): - captured["closed"] = True - - monkeypatch.setattr(hermes_state, "SessionDB", lambda: FakeDB()) - monkeypatch.setattr( - sys, - "argv", - ["hermes", "sessions", "export-prompts", "sess"], - ) - - main_mod.main() - - output = capsys.readouterr().out - records = [json.loads(line) for line in output.splitlines()] - assert [record["text"] for record in records] == [ - "Why is login broken?", - "Only show me the prompts.", - ] - assert captured == { - "resolved_from": "sess", - "exported": "sess-123", - "closed": True, - } - - -def test_sessions_export_prompts_cli_markdown_file(monkeypatch, capsys, tmp_path): - import hermes_cli.main as main_mod - import hermes_state - - class FakeDB: - def resolve_session_id(self, _session_id): - return "sess-123" - - def export_session(self, _session_id): - return _sample_session() - - def close(self): - pass - - output_path = tmp_path / "prompts.md" - monkeypatch.setattr(hermes_state, "SessionDB", lambda: FakeDB()) - monkeypatch.setattr( - sys, - "argv", - [ - "hermes", - "sessions", - "export-prompts", - "sess", - "--format", - "md", - "--output", - str(output_path), - ], - ) - - main_mod.main() - - assert f"Exported 2 prompts to {output_path}" in capsys.readouterr().out - content = output_path.read_text(encoding="utf-8") - assert "# User prompts for session sess-123" in content - assert "Why is login broken?" in content - assert "I will inspect the auth middleware." not in content + assert "--only user-prompts supports --format jsonl or md." in capsys.readouterr().out diff --git a/website/docs/user-guide/sessions.md b/website/docs/user-guide/sessions.md index fdbea52cbed..e2ae75ea7d4 100644 --- a/website/docs/user-guide/sessions.md +++ b/website/docs/user-guide/sessions.md @@ -312,6 +312,32 @@ Exported files contain one JSON object per line with full session metadata and a `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 HTML + +`--format html` writes a single self-contained HTML file — no remote dependencies — with styled message bubbles, collapsible tool output, and (for multi-session exports) a sidebar to switch between sessions: + +```bash +# One session as a standalone HTML page +hermes sessions export --format html --session-id 20250305_091523_a1b2c3d4 transcript.html + +# All Telegram sessions from the last week in one file, secrets redacted +hermes sessions export --format html --newer-than 1w --source telegram --redact archive.html +``` + +### Export Only Your Prompts + +`--only user-prompts` exports just the prompts you wrote — no assistant replies, tool output, or system context. Useful for building prompt libraries or reviewing what you asked: + +```bash +# One JSONL record per prompt (session id, index, timestamp, text) +hermes sessions export prompts.jsonl --session-id 20250305_091523_a1b2c3d4 --only user-prompts + +# Markdown, straight to stdout +hermes sessions export - --session-id 20250305_091523_a1b2c3d4 --only user-prompts --format md +``` + +Works with `--format jsonl` (default) or `md`, honors the same filters for bulk export, and combines with `--redact`. + ### 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`). 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 3b0da17196e..78c8d58be7e 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 @@ -289,6 +289,32 @@ hermes sessions export backup.jsonl --redact `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 为 HTML + +`--format html` 生成一个完全独立的 HTML 文件 — 无远程依赖 — 带样式化的消息气泡、可折叠的工具输出,多 session 导出时还带侧边栏导航: + +```bash +# 将一个 session 导出为独立 HTML 页面 +hermes sessions export --format html --session-id 20250305_091523_a1b2c3d4 transcript.html + +# 将最近一周的所有 Telegram session 导出到一个文件,并脱敏 +hermes sessions export --format html --newer-than 1w --source telegram --redact archive.html +``` + +### 只导出你的 Prompt + +`--only user-prompts` 只导出你写的 prompt — 不含助手回复、工具输出或系统上下文。适合构建 prompt 库或回顾你问过什么: + +```bash +# 每个 prompt 一条 JSONL 记录(session id、序号、时间戳、文本) +hermes sessions export prompts.jsonl --session-id 20250305_091523_a1b2c3d4 --only user-prompts + +# Markdown 格式,直接输出到 stdout +hermes sessions export - --session-id 20250305_091523_a1b2c3d4 --only user-prompts --format md +``` + +支持 `--format jsonl`(默认)或 `md`,批量导出时同样支持全部过滤器,也可与 `--redact` 组合。 + ### 导出 Session 为 Markdown/QMD 当你想在隐藏或删除旧 session 之前保留一份可读的文件归档时,传入 `--format md` 或 `--format qmd`。Markdown/QMD 导出会为每个 session 写入一个文件到目录中(默认:`~/.hermes/session-exports`)。