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.fix/verification-admin-route-recovery
parent
51dd5695ec
commit
acfefa4fda
|
|
@ -13433,65 +13433,6 @@ def main():
|
||||||
"--limit", type=int, default=20, help="Max sessions to show"
|
"--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):
|
def _add_session_filter_args(p, default_older_help):
|
||||||
p.add_argument(
|
p.add_argument(
|
||||||
"--older-than",
|
"--older-than",
|
||||||
|
|
@ -13589,6 +13530,61 @@ def main():
|
||||||
"--yes", "-y", action="store_true", help="Skip confirmation"
|
"--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(
|
sessions_prune = sessions_subparsers.add_parser(
|
||||||
"prune",
|
"prune",
|
||||||
help="Delete old sessions (filterable by time window, source, title, ...)",
|
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}")
|
print(f"{preview:<50} {last_active:<13} {s['source']:<6} {sid}")
|
||||||
|
|
||||||
elif action == "export":
|
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 args.format == "jsonl":
|
||||||
if not args.output:
|
if not args.output:
|
||||||
print("JSONL export requires an output path (use - for stdout).")
|
print("JSONL export requires an output path (use - for stdout).")
|
||||||
|
|
@ -13768,7 +13797,7 @@ def main():
|
||||||
if not resolved_session_id:
|
if not resolved_session_id:
|
||||||
print(f"Session '{args.session_id}' not found.")
|
print(f"Session '{args.session_id}' not found.")
|
||||||
return
|
return
|
||||||
data = db.export_session(resolved_session_id)
|
data = _redact(db.export_session(resolved_session_id))
|
||||||
if not data:
|
if not data:
|
||||||
print(f"Session '{args.session_id}' not found.")
|
print(f"Session '{args.session_id}' not found.")
|
||||||
return
|
return
|
||||||
|
|
@ -13781,15 +13810,42 @@ def main():
|
||||||
f.write(line)
|
f.write(line)
|
||||||
print(f"Exported 1 session to {args.output}")
|
print(f"Exported 1 session to {args.output}")
|
||||||
else:
|
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 == "-":
|
if args.output == "-":
|
||||||
|
|
||||||
for s in sessions:
|
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:
|
else:
|
||||||
with open(args.output, "w", encoding="utf-8") as f:
|
with open(args.output, "w", encoding="utf-8") as f:
|
||||||
for s in sessions:
|
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}")
|
print(f"Exported {len(sessions)} sessions to {args.output}")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
@ -13814,6 +13870,7 @@ def main():
|
||||||
)
|
)
|
||||||
if not data:
|
if not data:
|
||||||
return None, None
|
return None, None
|
||||||
|
data = _redact(data)
|
||||||
path = write_session_markdown(
|
path = write_session_markdown(
|
||||||
data,
|
data,
|
||||||
output_dir,
|
output_dir,
|
||||||
|
|
@ -13865,16 +13922,19 @@ def main():
|
||||||
db.close()
|
db.close()
|
||||||
return
|
return
|
||||||
|
|
||||||
if args.older_than is None and not args.source:
|
if not filters:
|
||||||
print("Refusing bulk export without a filter. Pass --session-id, --older-than, or --source.")
|
print(
|
||||||
|
"Refusing bulk export without a filter. Pass --session-id or "
|
||||||
|
"at least one filter (e.g. --older-than 90, --source telegram)."
|
||||||
|
)
|
||||||
db.close()
|
db.close()
|
||||||
return
|
return
|
||||||
candidates = db.list_export_candidates(
|
candidates = db.list_prune_candidates(**filters)
|
||||||
older_than_days=args.older_than,
|
|
||||||
source=args.source,
|
|
||||||
)
|
|
||||||
if args.dry_run:
|
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]:
|
for row in candidates[:100]:
|
||||||
print(f" {row.get('id')} {row.get('source', '')}")
|
print(f" {row.get('id')} {row.get('source', '')}")
|
||||||
if len(candidates) > 100:
|
if len(candidates) > 100:
|
||||||
|
|
|
||||||
|
|
@ -216,6 +216,34 @@ def verify_export_file(path: Path | str, session: dict[str, Any]) -> tuple[bool,
|
||||||
return True, "ok"
|
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(
|
def write_session_markdown(
|
||||||
session: dict[str, Any], output_dir: Path | str, *, fmt: str = "md", force: bool = False
|
session: dict[str, Any], output_dir: Path | str, *, fmt: str = "md", force: bool = False
|
||||||
) -> Path:
|
) -> Path:
|
||||||
|
|
|
||||||
|
|
@ -4958,39 +4958,6 @@ class SessionDB:
|
||||||
# Export and cleanup
|
# 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:
|
def _is_branch_child_row(self, session: Dict[str, Any]) -> bool:
|
||||||
raw = session.get("model_config")
|
raw = session.get("model_config")
|
||||||
if not raw:
|
if not raw:
|
||||||
|
|
|
||||||
|
|
@ -187,8 +187,14 @@ def test_sessions_export_md_bulk_dry_run_lists_candidates(monkeypatch, tmp_path,
|
||||||
import hermes_state
|
import hermes_state
|
||||||
|
|
||||||
class FakeDB:
|
class FakeDB:
|
||||||
def list_export_candidates(self, **kwargs):
|
def list_prune_candidates(self, **kwargs):
|
||||||
assert kwargs == {"older_than_days": 30, "source": "cron"}
|
# 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"}]
|
return [{"id": "s1", "source": "cron"}, {"id": "s2", "source": "cron"}]
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
|
|
@ -227,7 +233,7 @@ def test_sessions_export_md_bulk_requires_filter(monkeypatch, tmp_path, capsys):
|
||||||
import hermes_state
|
import hermes_state
|
||||||
|
|
||||||
class FakeDB:
|
class FakeDB:
|
||||||
def list_export_candidates(self, **kwargs):
|
def list_prune_candidates(self, **kwargs):
|
||||||
raise AssertionError("bulk export without filters should refuse")
|
raise AssertionError("bulk export without filters should refuse")
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
|
|
@ -250,7 +256,7 @@ def test_sessions_export_md_bulk_writes_manifest(monkeypatch, tmp_path, capsys):
|
||||||
import hermes_state
|
import hermes_state
|
||||||
|
|
||||||
class FakeDB:
|
class FakeDB:
|
||||||
def list_export_candidates(self, **kwargs):
|
def list_prune_candidates(self, **kwargs):
|
||||||
return [{"id": "s1"}, {"id": "s2"}]
|
return [{"id": "s1"}, {"id": "s2"}]
|
||||||
|
|
||||||
def export_session_lineage(self, session_id):
|
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 captured == {"deleted": "s1"}
|
||||||
assert len(list(tmp_path.glob("*.md"))) == 1
|
assert len(list(tmp_path.glob("*.md"))) == 1
|
||||||
assert "Deleted exported session 's1'" in capsys.readouterr().out
|
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
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import hermes_state
|
||||||
from hermes_state import SessionDB
|
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")
|
db = SessionDB(db_path=tmp_path / "state.db")
|
||||||
monkeypatch.setattr(hermes_state.time, "time", lambda: 2_000_000.0)
|
monkeypatch.setattr(hermes_state.time, "time", lambda: 2_000_000.0)
|
||||||
try:
|
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.execute("UPDATE sessions SET started_at=? WHERE id=?", (1_000_000.0, "old_active"))
|
||||||
db._conn.commit()
|
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"]
|
assert [c["id"] for c in candidates] == ["old_cli"]
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
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")
|
db = SessionDB(db_path=tmp_path / "state.db")
|
||||||
monkeypatch.setattr(hermes_state.time, "time", lambda: 2_000_000.0)
|
monkeypatch.setattr(hermes_state.time, "time", lambda: 2_000_000.0)
|
||||||
try:
|
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.execute("UPDATE sessions SET started_at=?, ended_at=? WHERE id=?", (1_000_000.0, 1_000_010.0, sid))
|
||||||
db._conn.commit()
|
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"]
|
assert [c["id"] for c in candidates] == ["old_telegram"]
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
|
||||||
|
|
@ -303,10 +303,15 @@ hermes sessions export telegram-history.jsonl --source telegram
|
||||||
|
|
||||||
# Export a single session
|
# Export a single session
|
||||||
hermes sessions export session.jsonl --session-id 20250305_091523_a1b2c3d4
|
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.
|
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
|
### 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`).
|
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
|
# Preview ended sessions older than 90 days without writing files
|
||||||
hermes sessions export --format md --older-than 90 --dry-run
|
hermes sessions export --format md --older-than 90 --dry-run
|
||||||
|
|
||||||
# Export ended Telegram sessions older than 30 days to QMD files
|
# Export ended Telegram sessions older than 2 weeks to QMD files
|
||||||
hermes sessions export --format qmd --older-than 30 --source telegram
|
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
|
# Only after verification, export and delete one explicitly named session
|
||||||
hermes sessions export --format md --session-id 20250305_091523_a1b2c3d4 --delete-after-verified --yes
|
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
|
### Delete a Session
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -280,10 +280,15 @@ hermes sessions export telegram-history.jsonl --source telegram
|
||||||
|
|
||||||
# 导出单个 session
|
# 导出单个 session
|
||||||
hermes sessions export session.jsonl --session-id 20250305_091523_a1b2c3d4
|
hermes sessions export session.jsonl --session-id 20250305_091523_a1b2c3d4
|
||||||
|
|
||||||
|
# 从导出内容中脱敏 API key/token/凭据
|
||||||
|
hermes sessions export backup.jsonl --redact
|
||||||
```
|
```
|
||||||
|
|
||||||
导出文件每行包含一个 JSON 对象,包含完整的 session 元数据和所有消息。
|
导出文件每行包含一个 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 为 Markdown/QMD
|
||||||
|
|
||||||
当你想在隐藏或删除旧 session 之前保留一份可读的文件归档时,传入 `--format md` 或 `--format qmd`。Markdown/QMD 导出会为每个 session 写入一个文件到目录中(默认:`~/.hermes/session-exports`)。
|
当你想在隐藏或删除旧 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,不写入文件
|
# 预览 90 天前已结束的 session,不写入文件
|
||||||
hermes sessions export --format md --older-than 90 --dry-run
|
hermes sessions export --format md --older-than 90 --dry-run
|
||||||
|
|
||||||
# 将 30 天前已结束的 Telegram session 导出为 QMD 文件
|
# 将 2 周前已结束的 Telegram session 导出为 QMD 文件
|
||||||
hermes sessions export --format qmd --older-than 30 --source telegram
|
hermes sessions export --format qmd --older-than 2w --source telegram
|
||||||
|
|
||||||
|
# 导出长的 Claude session,并脱敏
|
||||||
|
hermes sessions export --format md --model sonnet --min-messages 50 --redact
|
||||||
|
|
||||||
# 导出并在校验通过后删除一个明确指定的 session
|
# 导出并在校验通过后删除一个明确指定的 session
|
||||||
hermes sessions export --format md --session-id 20250305_091523_a1b2c3d4 --delete-after-verified --yes
|
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
|
### 删除 Session
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue