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

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