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.
This commit is contained in:
teknium1 2026-07-07 06:15:53 -07:00 committed by Teknium
parent 51dd5695ec
commit acfefa4fda
7 changed files with 339 additions and 117 deletions

View file

@ -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: <hermes home>/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: <hermes home>/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:

View file

@ -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:

View file

@ -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:

View file

@ -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

View file

@ -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()

View file

@ -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

View file

@ -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