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
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue