fix(sessions): transcript exports keep compacted turns, so --delete-after-verified no longer deletes them unseen
In-place compaction is the default. It soft-archives every earlier row of a session under the same id (active = 0, compacted = 1), and Desktop and the dashboard still show those turns. The md/qmd export read the session through export_session -> get_messages with the default live-only clause, so it wrote only the compaction summary and the carried tail. verify_export_file then compared the file with that same dict, and delete_session removed every row of the session, including the archived turns that never reached the file. The md/qmd export now reads the display history (include_compacted), for a single session and for --lineage logical. export_session and export_session_lineage take include_compacted, off by default: import_sessions inserts every message as live context, so the JSON export and stranded-session adoption keep reading live rows only. The other transcripts people read had the same hole without the delete: `/save md` and `/save html` (CLI and gateway), `sessions export --format html` (one session or all of them) and `--only user-prompts` each held 1 of 6 answers on a six-turn session after one compaction. They now read the display history too (SAVE_TRANSCRIPT_FORMATS; export_all gains include_compacted and reads per session then, since the display read dedupes per session). `/save json`, JSONL and the dashboard's JSON export stay live-only for the import reason above. Before deleting, the verify step also re-counts the store's display rows for every session the file covers and refuses on a mismatch. A message that lands while the files are written, or a later export change that reads a narrower view, now refuses the delete instead of being removed unseen. Like the adoption retire loop, the re-count runs just before delete_session, not inside its transaction. Rewind rows (undone turns, the superseded originals of a carried tail) are still deleted without being exported, as `hermes sessions delete` does: they are not part of the history the session shows. Measured through the real CLI on a session with 6 turns and one default in-place compaction (15 rows, 13 shown): before, 3 messages were exported and all 15 rows deleted, with answers 1-5 missing from the file; after, 13 messages are exported in display order, then deleted. (cherry picked from commit adeaff1e33f1ae2b8a066ef2374bb29ade50b3b8)
This commit is contained in:
committed by
kshitij
parent
55138d85b2
commit
fe8b643db6
@@ -708,7 +708,8 @@ class GatewaySessionCommandsMixin:
|
||||
"""Handle /save — export the current session and send it as a document."""
|
||||
import tempfile
|
||||
from hermes_cli.session_export import (
|
||||
SAVE_USAGE, default_save_filename, normalize_save_format, render_session_for_save)
|
||||
SAVE_TRANSCRIPT_FORMATS, SAVE_USAGE, default_save_filename, normalize_save_format,
|
||||
render_session_for_save)
|
||||
|
||||
parts = event.get_command_args().split()
|
||||
redact = bool(parts) and parts[-1].lower() in ("redact", "--redact")
|
||||
@@ -729,7 +730,7 @@ class GatewaySessionCommandsMixin:
|
||||
# Never trust path separators from chat input; the filename is only echoed to the platform.
|
||||
filename = parts[1] if len(parts) > 1 else default_save_filename(session_id, fmt)
|
||||
filename = os.path.basename(filename) or default_save_filename(session_id, fmt)
|
||||
export_data = await self._session_db.export_session(session_id)
|
||||
export_data = await self._session_db.export_session(session_id, include_compacted=fmt in SAVE_TRANSCRIPT_FORMATS)
|
||||
if not export_data:
|
||||
return f"No stored messages found for this session ({session_id})."
|
||||
if redact:
|
||||
|
||||
@@ -626,7 +626,7 @@ class CLISessionMixin:
|
||||
"""
|
||||
from cli import datetime
|
||||
from hermes_cli.session_export import (
|
||||
SAVE_USAGE, normalize_save_format, render_session_for_save)
|
||||
SAVE_TRANSCRIPT_FORMATS, SAVE_USAGE, normalize_save_format, render_session_for_save)
|
||||
|
||||
parts = cmd.split()[1:]
|
||||
redact = bool(parts) and parts[-1].lower() in ("redact", "--redact")
|
||||
@@ -650,7 +650,7 @@ class CLISessionMixin:
|
||||
_sid = getattr(self, "session_id", None)
|
||||
if _db and _sid:
|
||||
try:
|
||||
session_data = _db.export_session(_sid)
|
||||
session_data = _db.export_session(_sid, include_compacted=fmt in SAVE_TRANSCRIPT_FORMATS)
|
||||
except Exception:
|
||||
session_data = None
|
||||
if not session_data:
|
||||
|
||||
@@ -207,6 +207,9 @@ def _fenced_text(text: str, *, language: str = "text") -> str:
|
||||
# --- Current-session save helper (shared by CLI /save and gateway /save) ---
|
||||
|
||||
SAVE_FORMATS = ("json", "md", "html")
|
||||
# Transcripts show what the user sees, compaction-archived turns included. JSON stays the live rows that
|
||||
# import_sessions restores: it would replay archived turns as live context.
|
||||
SAVE_TRANSCRIPT_FORMATS = frozenset({"md", "html"})
|
||||
|
||||
SAVE_USAGE = """/save — export the current session to a file
|
||||
Usage: /save <format> [filename] [redact]
|
||||
|
||||
@@ -332,11 +332,15 @@ def _cmd_export(db, args):
|
||||
from hermes_cli.session_export_md import redact_session_data
|
||||
return redact_session_data(data)
|
||||
|
||||
# HTML and --only are read by people, so they carry the turns in-place compaction archived; JSONL stays the
|
||||
# live rows import_sessions restores.
|
||||
shown = args.format == "html" or bool(getattr(args, "only", None))
|
||||
|
||||
def _collect_sessions():
|
||||
"""--session-id / filters / bare export -> redacted session dicts, or None after printing an error."""
|
||||
if args.session_id:
|
||||
resolved = db.resolve_session_id(args.session_id)
|
||||
data = _redact(db.export_session(resolved)) if resolved else None
|
||||
data = _redact(db.export_session(resolved, include_compacted=shown)) if resolved else None
|
||||
if not data:
|
||||
_not_found(args.session_id)
|
||||
return None
|
||||
@@ -345,10 +349,10 @@ def _cmd_export(db, args):
|
||||
candidates = db.list_prune_candidates(**filters)
|
||||
if args.dry_run:
|
||||
return _print_dry_run_preview(candidates, filters)
|
||||
return [s for s in (_redact(db.export_session(row["id"])) for row in candidates) if s]
|
||||
return [s for s in (_redact(db.export_session(row["id"], include_compacted=shown)) for row in candidates) if s]
|
||||
if args.dry_run:
|
||||
return print("--dry-run requires at least one filter.")
|
||||
return [_redact(s) for s in db.export_all(source=None)]
|
||||
return [_redact(s) for s in db.export_all(source=None, include_compacted=shown)]
|
||||
if getattr(args, "only", None):
|
||||
return _export_flat("only", args, _collect_sessions)
|
||||
if args.format == "trace":
|
||||
@@ -472,7 +476,10 @@ def _export_markdown(db, args, filters, redact):
|
||||
output_dir = _export_dir(args.output)
|
||||
|
||||
def _export_one(session_id: str, *, include_lineage: bool = False):
|
||||
data = db.export_session_lineage(session_id) if include_lineage else db.export_session(session_id)
|
||||
# The history the user sees, not only the live rows: in-place compaction archives earlier turns under
|
||||
# the same id, and --delete-after-verified removes every row of it.
|
||||
export = db.export_session_lineage if include_lineage else db.export_session
|
||||
data = export(session_id, include_compacted=True)
|
||||
if not data:
|
||||
return None, None
|
||||
data = redact(data)
|
||||
@@ -538,6 +545,14 @@ def _export_markdown_single(db, args, export_one, output_dir, lineage_is_logical
|
||||
return
|
||||
for data, exported_path in exported_items:
|
||||
ok, reason = verify_export_file(exported_path, data)
|
||||
# The file only proves it matches the dict it was written from; the delete removes what the store holds
|
||||
# now, so re-count the store just before it (like the adoption retire loop, outside its transaction).
|
||||
exported = len(data.get("messages") or [])
|
||||
shown = sum(len(db.get_messages(sid, include_compacted=True))
|
||||
for sid in data.get("lineage_session_ids") or [data["id"]])
|
||||
if ok and shown != exported:
|
||||
ok, reason = False, (f"the session changed after it was exported ({shown} messages now, {exported} in "
|
||||
"the file); run the export again")
|
||||
if not ok:
|
||||
print(f"Export verification failed; not deleting session '{data.get('id')}': {reason}")
|
||||
return
|
||||
|
||||
@@ -276,21 +276,23 @@ class SessionPortabilityMixin:
|
||||
|
||||
# ── Export ─────────────────────────────────────────────────────────────
|
||||
|
||||
def _with_messages(self, session: Dict[str, Any]) -> Dict[str, Any]:
|
||||
messages = self.get_messages(session["id"])
|
||||
def _with_messages(self, session: Dict[str, Any], include_compacted: bool = False) -> Dict[str, Any]:
|
||||
messages = self.get_messages(session["id"], include_compacted=include_compacted)
|
||||
return {**session, "messages": messages, "timings": _export_timings(messages, session["id"])}
|
||||
|
||||
def export_session(self, session_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Export a single session with all its messages as a dict."""
|
||||
def export_session(self, session_id: str, include_compacted: bool = False) -> Optional[Dict[str, Any]]:
|
||||
"""Export a single session with all its messages as a dict. ``include_compacted`` adds the turns
|
||||
in-place compaction archived (the history the user still sees); it stays off for payloads that go
|
||||
back through :meth:`import_sessions`, which would insert those turns as live context."""
|
||||
session = self.get_session(session_id)
|
||||
return self._with_messages(session) if session else None
|
||||
return self._with_messages(session, include_compacted) if session else None
|
||||
|
||||
def export_session_lineage(self, session_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Export a compression lineage as one logical session dict."""
|
||||
def export_session_lineage(self, session_id: str, include_compacted: bool = False) -> Optional[Dict[str, Any]]:
|
||||
"""Export a compression lineage as one logical session dict (``include_compacted`` as in :meth:`export_session`)."""
|
||||
lineage_ids = self.get_compression_lineage(session_id)
|
||||
if not lineage_ids:
|
||||
return None
|
||||
segments = [seg for seg in map(self.export_session, lineage_ids) if seg]
|
||||
segments = [seg for seg in (self.export_session(sid, include_compacted) for sid in lineage_ids) if seg]
|
||||
if not segments:
|
||||
return None
|
||||
messages = [msg for seg in segments for msg in (seg.get("messages") or [])]
|
||||
@@ -300,9 +302,12 @@ class SessionPortabilityMixin:
|
||||
"messages": messages, "timings": _export_timings(messages, session_id),
|
||||
}
|
||||
|
||||
def export_all(self, source: str = None) -> List[Dict[str, Any]]:
|
||||
"""Export all sessions (with messages) as dicts, e.g. for JSONL backup."""
|
||||
def export_all(self, source: str = None, include_compacted: bool = False) -> List[Dict[str, Any]]:
|
||||
"""Export all sessions (with messages) as dicts, e.g. for JSONL backup (``include_compacted`` as in
|
||||
:meth:`export_session`; that display read dedupes per session, so it skips the batched read)."""
|
||||
sessions = self.search_sessions(source=source, limit=100000)
|
||||
if include_compacted:
|
||||
return [self._with_messages(session, True) for session in sessions]
|
||||
messages_by_session = {session["id"]: [] for session in sessions}
|
||||
session_ids = list(messages_by_session)
|
||||
# Stay below SQLite's legacy 999-variable limit while replacing the per-session N+1 reads.
|
||||
|
||||
67
tests/hermes_cli/test_save_transcript_history.py
Normal file
67
tests/hermes_cli/test_save_transcript_history.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""/save md|html is a transcript: after in-place compaction it still holds every turn the chat shows.
|
||||
/save json stays the live rows import_sessions restores."""
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _compacted_store(path):
|
||||
from hermes_state import SessionDB
|
||||
|
||||
db = SessionDB(db_path=path)
|
||||
db.create_session("s1", "telegram")
|
||||
for i in range(1, 7):
|
||||
db.append_message("s1", "user", f"question {i}")
|
||||
db.append_message("s1", "assistant", f"answer {i}")
|
||||
tail = [{"role": "user", "content": "question 6"}, {"role": "assistant", "content": "answer 6"}]
|
||||
db.archive_and_compact("s1", [{"role": "user", "content": "[CONTEXT COMPACTION] summary"}, *tail],
|
||||
watermark=db.get_active_message_watermark("s1"), tail_count=len(tail))
|
||||
return db
|
||||
|
||||
|
||||
def _cli_save(db, fmt, out):
|
||||
import cli
|
||||
|
||||
stub = SimpleNamespace(_session_db=db, session_id="s1", conversation_history=[], model="m",
|
||||
session_start=datetime(2026, 1, 1))
|
||||
cli.HermesCLI.save_conversation(stub, f"/save {fmt} {out}")
|
||||
return out.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _gateway_save(db, fmt, out):
|
||||
from gateway.config import Platform
|
||||
from gateway.platforms.event import MessageEvent
|
||||
from gateway.run import GatewayRunner
|
||||
from gateway.session import SessionEntry, SessionSource, build_session_key
|
||||
from hermes_state import AsyncSessionDB
|
||||
|
||||
source = SessionSource(platform=Platform.TELEGRAM, user_id="u1", chat_id="c1", user_name="t", chat_type="dm")
|
||||
runner = object.__new__(GatewayRunner)
|
||||
delivered = {}
|
||||
adapter = MagicMock()
|
||||
adapter.send_document = AsyncMock(side_effect=lambda **kw: delivered.update(
|
||||
text=open(kw["file_path"], encoding="utf-8").read()))
|
||||
runner.adapters, runner._profile_adapters = {Platform.TELEGRAM: adapter}, {}
|
||||
runner.session_store = MagicMock()
|
||||
runner.session_store.get_or_create_session.return_value = SessionEntry(
|
||||
session_key=build_session_key(source), session_id="s1", created_at=datetime.now(),
|
||||
updated_at=datetime.now(), platform=Platform.TELEGRAM, chat_type="dm")
|
||||
runner._session_db = AsyncSessionDB(db)
|
||||
event = MessageEvent(text=f"/save {fmt} {out.name}", source=source, message_id="m1")
|
||||
assert asyncio.run(runner._handle_save_command(event)) == "Export complete."
|
||||
return delivered["text"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("save", [_cli_save, _gateway_save], ids=["cli", "gateway"])
|
||||
@pytest.mark.parametrize("fmt, expected", [("md", 6), ("html", 6), ("json", 1)])
|
||||
def test_save_transcript_holds_every_turn_the_chat_shows(tmp_path, monkeypatch, save, fmt, expected):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
db = _compacted_store(tmp_path / "state.db")
|
||||
try:
|
||||
text = save(db, fmt, tmp_path / f"saved.{fmt}")
|
||||
finally:
|
||||
db.close()
|
||||
assert sum(f"answer {i}" in text for i in range(1, 7)) == expected
|
||||
@@ -106,7 +106,7 @@ def test_sessions_export_cli_prompt_only_stdout(monkeypatch, capsys):
|
||||
captured["resolved_from"] = session_id
|
||||
return "sess-123"
|
||||
|
||||
def export_session(self, session_id):
|
||||
def export_session(self, session_id, include_compacted=False):
|
||||
captured["exported"] = session_id
|
||||
return _sample_session()
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_sessions_export_md_writes_single_session(monkeypatch, tmp_path, capsys):
|
||||
import hermes_cli.main as main_mod
|
||||
@@ -12,7 +14,7 @@ def test_sessions_export_md_writes_single_session(monkeypatch, tmp_path, capsys)
|
||||
captured["resolved_from"] = session_id
|
||||
return "20260706_123456_abcd1234"
|
||||
|
||||
def export_session(self, session_id):
|
||||
def export_session(self, session_id, include_compacted=False):
|
||||
captured["exported"] = session_id
|
||||
return {
|
||||
"id": session_id,
|
||||
@@ -74,7 +76,7 @@ def test_sessions_export_redact_scrubs_secrets(monkeypatch, tmp_path):
|
||||
def resolve_session_id(self, session_id):
|
||||
return "s1"
|
||||
|
||||
def export_session(self, session_id):
|
||||
def export_session(self, session_id, include_compacted=False):
|
||||
return {
|
||||
"id": "s1",
|
||||
"title": "Redact",
|
||||
@@ -101,3 +103,110 @@ def test_sessions_export_redact_scrubs_secrets(monkeypatch, tmp_path):
|
||||
text = next(tmp_path.glob("*.md")).read_text(encoding="utf-8")
|
||||
assert secret not in text
|
||||
assert "api key:" in text
|
||||
|
||||
|
||||
def _real_store(monkeypatch, tmp_path):
|
||||
"""Point the CLI's SessionDB at one real file; returns an opener for the test's own handles."""
|
||||
import hermes_state
|
||||
|
||||
real_session_db = hermes_state.SessionDB
|
||||
db_path = tmp_path / "state.db"
|
||||
|
||||
class _StoreAtTmp(real_session_db):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(db_path=db_path)
|
||||
|
||||
monkeypatch.setattr(hermes_state, "SessionDB", _StoreAtTmp)
|
||||
return _StoreAtTmp
|
||||
|
||||
|
||||
def _seed_six_turns(open_db, session_id, *, compact):
|
||||
db = open_db()
|
||||
try:
|
||||
db.create_session(session_id, "cli")
|
||||
for i in range(1, 7):
|
||||
db.append_message(session_id, "user", f"question {i}")
|
||||
db.append_message(session_id, "assistant", f"answer {i}")
|
||||
if compact:
|
||||
# Default in-place compaction, production shape: watermark from compression start, last turn carried.
|
||||
watermark = db.get_active_message_watermark(session_id)
|
||||
tail = [{"role": "user", "content": "question 6"}, {"role": "assistant", "content": "answer 6"}]
|
||||
db.archive_and_compact(session_id, [{"role": "user", "content": "[CONTEXT COMPACTION] summary"}, *tail],
|
||||
watermark=watermark, tail_count=len(tail))
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _export_and_delete(monkeypatch, out_dir, session_id, *extra):
|
||||
import hermes_cli.main as main_mod
|
||||
|
||||
monkeypatch.setattr(sys, "argv", [
|
||||
"hermes", "sessions", "export", "--format", "md", "--session-id", session_id,
|
||||
"--delete-after-verified", "--yes", *extra, str(out_dir),
|
||||
])
|
||||
main_mod.main()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("lineage", ["single", "logical"])
|
||||
def test_delete_after_verified_exports_the_turns_in_place_compaction_archived(monkeypatch, tmp_path, capsys, lineage):
|
||||
open_db = _real_store(monkeypatch, tmp_path)
|
||||
_seed_six_turns(open_db, "s1", compact=True)
|
||||
|
||||
_export_and_delete(monkeypatch, tmp_path / "out", "s1", "--lineage", lineage)
|
||||
|
||||
text = next((tmp_path / "out").glob("*.md")).read_text(encoding="utf-8")
|
||||
assert [f"answer {i}" in text for i in range(1, 7)] == [True] * 6
|
||||
assert "Deleted exported session 's1'." in capsys.readouterr().out
|
||||
db = open_db()
|
||||
try:
|
||||
assert db.get_session("s1") is None
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_delete_after_verified_keeps_a_session_that_gained_a_message_after_the_export(monkeypatch, tmp_path, capsys):
|
||||
import hermes_cli.session_export_md as session_export_md
|
||||
|
||||
open_db = _real_store(monkeypatch, tmp_path)
|
||||
_seed_six_turns(open_db, "s1", compact=False)
|
||||
write_session_markdown = session_export_md.write_session_markdown
|
||||
|
||||
def write_then_a_turn_lands(*args, **kwargs):
|
||||
path = write_session_markdown(*args, **kwargs)
|
||||
writer = open_db()
|
||||
try:
|
||||
writer.append_message("s1", "user", "sent after the export was read")
|
||||
finally:
|
||||
writer.close()
|
||||
return path
|
||||
|
||||
monkeypatch.setattr(session_export_md, "write_session_markdown", write_then_a_turn_lands)
|
||||
_export_and_delete(monkeypatch, tmp_path / "out", "s1")
|
||||
|
||||
assert "Export verification failed; not deleting session 's1'" in capsys.readouterr().out
|
||||
db = open_db()
|
||||
try:
|
||||
assert db.get_messages("s1")[-1]["content"] == "sent after the export was read"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("argv, marker, expected", [
|
||||
pytest.param(["--format", "html", "--session-id", "s1"], "answer", 6, id="html"),
|
||||
pytest.param(["--format", "html"], "answer", 6, id="html-every-session"),
|
||||
pytest.param(["--format", "md", "--only", "user-prompts", "--session-id", "s1"], "question", 6, id="only-prompts"),
|
||||
# The importable payload keeps the live rows: import_sessions would replay archived turns as live context.
|
||||
pytest.param(["--format", "jsonl", "--session-id", "s1"], "answer", 1, id="jsonl-live-only"),
|
||||
])
|
||||
def test_human_readable_exports_carry_the_turns_in_place_compaction_archived(
|
||||
monkeypatch, tmp_path, argv, marker, expected):
|
||||
import hermes_cli.main as main_mod
|
||||
|
||||
open_db = _real_store(monkeypatch, tmp_path)
|
||||
_seed_six_turns(open_db, "s1", compact=True)
|
||||
out = tmp_path / "export.out"
|
||||
monkeypatch.setattr(sys, "argv", ["hermes", "sessions", "export", *argv, str(out)])
|
||||
main_mod.main()
|
||||
|
||||
text = out.read_text(encoding="utf-8")
|
||||
assert sum(f"{marker} {i}" in text for i in range(1, 7)) == expected
|
||||
|
||||
@@ -11,7 +11,7 @@ class _FakeDB:
|
||||
def resolve_session_id(self, session_id):
|
||||
return "sess-123"
|
||||
|
||||
def export_session(self, session_id):
|
||||
def export_session(self, session_id, include_compacted=False):
|
||||
return {"id": "sess-123", "source": "cli", "messages": [{"role": "user", "content": "hi"}]}
|
||||
|
||||
def close(self):
|
||||
|
||||
@@ -456,7 +456,7 @@ hermes sessions export --format md --model sonnet --min-messages 50 --redact
|
||||
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 at least one filter; a bare bulk export is refused. `--delete-after-verified` is intentionally limited to `--session-id` and requires `--yes`. Because deleting a parent session also removes its delegate/subagent sessions, this mode exports and verifies each delegate in a separate file before deleting anything. If the delegate set changes during export, deletion is refused. `--redact` scrubs secrets (API keys, tokens, credentials) from message content and tool output before writing — recommended for any export you plan to share.
|
||||
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`. Because deleting a parent session also removes its delegate/subagent sessions, this mode exports and verifies each delegate in a separate file before deleting anything. If the delegate set changes during export, deletion is refused. Markdown/QMD files hold the full history you see in the session, including turns that in-place compaction summarized away, and deletion is also refused if a session's message count no longer matches its file. The same holds for `--format html`, `--only user-prompts` and `/save md` or `/save html`; JSON and JSONL exports carry only the live rows, because importing them would restore the summarized turns as live context. `--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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user