mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-08 13:12:08 +00:00
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:
parent
51dd5695ec
commit
acfefa4fda
7 changed files with 339 additions and 117 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue