feat(sessions): opt-in auto-archive of stale sessions + durable pin flag

New sessions.auto_archive / auto_archive_days config: soft-hide (never
delete) sessions with no activity for N days, aging on last activity
rather than creation so an old-but-active chat is spared. Sweeps are
throttled through state_meta and fire from CLI startup, gateway startup
+ hourly housekeeping, and the serve/dashboard backend (opportunistic
on session list + an hourly lifespan ticker), so every surface honours
one setting.

A new pinned column (declaratively migrated) exempts sessions from the
sweep; PATCH /api/sessions/{id} accepts pinned and flips the whole
compression lineage as a unit, mirroring set_session_archived.
This commit is contained in:
Brooklyn Nicholson 2026-07-24 10:17:40 -05:00
parent c416d9ae0a
commit f16b80362c
7 changed files with 499 additions and 5 deletions

10
cli.py
View file

@ -1972,6 +1972,16 @@ def _run_state_db_auto_maintenance(session_db) -> None:
logger.debug("Orphan compression finalize skipped: %s", _finalize_exc)
cfg = (_load_full_config().get("sessions") or {})
# Auto-archive (soft-hide stale sessions) is independent of the
# destructive auto_prune sweep — run it first, before prune's early
# return, so enabling one doesn't require the other.
if cfg.get("auto_archive", False):
session_db.maybe_auto_archive(
idle_days=float(cfg.get("auto_archive_days", 3)),
min_interval_hours=int(cfg.get("min_interval_hours", 24)),
)
if not cfg.get("auto_prune", False):
return
session_db.maybe_auto_prune_and_vacuum(

View file

@ -3598,6 +3598,12 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
try:
from hermes_cli.config import load_config as _load_full_config
_sess_cfg = (_load_full_config().get("sessions") or {})
# Non-destructive stale-session archive, independent of prune.
if _sess_cfg.get("auto_archive", False):
self._session_db._db.maybe_auto_archive(
idle_days=float(_sess_cfg.get("auto_archive_days", 3)),
min_interval_hours=int(_sess_cfg.get("min_interval_hours", 24)),
)
if _sess_cfg.get("auto_prune", False):
# Construction-time, before the loop serves traffic; sync DB is fine.
self._session_db._db.maybe_auto_prune_and_vacuum(
@ -23360,6 +23366,7 @@ def _start_gateway_housekeeping(stop_event: threading.Event, adapters=None, loop
CHANNEL_DIR_EVERY = 5 # ticks — every 5 minutes
PASTE_SWEEP_EVERY = 60 # ticks — once per hour
CURATOR_EVERY = 60 # ticks — poll hourly (inner gate handles the real cadence)
AUTO_ARCHIVE_EVERY = 60 # ticks — poll hourly (state_meta gate owns the real cadence)
logger.info("Gateway housekeeping started (interval=%ds)", interval)
tick_count = 0
@ -23424,6 +23431,28 @@ def _start_gateway_housekeeping(stop_event: threading.Event, adapters=None, loop
except Exception as e:
logger.debug("Curator tick error: %s", e)
# Stale-session auto-archive — a live timer, so gateways that stay up
# for weeks keep sweeping on schedule (the startup hook fires once).
# maybe_auto_archive() is gated by sessions.min_interval_hours in
# state_meta; this is just the poll rate. Opens its own SessionDB —
# SQLite connections are thread-bound and this runs off-loop.
if tick_count % AUTO_ARCHIVE_EVERY == 0:
try:
from hermes_cli.config import load_config as _load_full_config
from hermes_state import SessionDB
_sess_cfg = (_load_full_config().get("sessions") or {})
if _sess_cfg.get("auto_archive", False):
_adb = SessionDB()
try:
_adb.maybe_auto_archive(
idle_days=float(_sess_cfg.get("auto_archive_days", 3)),
min_interval_hours=int(_sess_cfg.get("min_interval_hours", 24)),
)
finally:
_adb.close()
except Exception as e:
logger.debug("Auto-archive tick error: %s", e)
stop_event.wait(timeout=interval)
logger.info("Gateway housekeeping stopped")

View file

@ -3226,6 +3226,15 @@ DEFAULT_CONFIG = {
# How many days of ended-session history to keep. Matches the
# default of ``hermes sessions prune``.
"retention_days": 90,
# When true, auto-archive (soft-hide, never delete) sessions that
# haven't been touched in ``auto_archive_days`` days, once per
# (roughly) min_interval_hours. "Touched" is last activity, not
# creation, so an old-but-recently-used session is spared. Pinned
# sessions are always exempt. Off by default — opt in explicitly.
"auto_archive": False,
# Idle threshold (days of no activity) before auto-archive hides a
# session. Only applies when auto_archive is true.
"auto_archive_days": 3,
# VACUUM after a prune that actually deleted rows. SQLite does not
# reclaim disk space on DELETE — freed pages are just reused on
# subsequent INSERTs — so without VACUUM the file stays bloated

View file

@ -223,11 +223,16 @@ async def _lifespan(app: "FastAPI"):
# /api/status). The loop exits immediately when httpx is unavailable.
selftest_task = asyncio.create_task(_dashboard_selftest_loop())
# Live auto-archive timer — keeps a backend that stays up for days
# sweeping stale sessions on schedule, independent of list requests.
auto_archive_task = asyncio.create_task(_auto_archive_ticker_loop())
try:
yield
finally:
pty_reaper_task.cancel()
selftest_task.cancel()
auto_archive_task.cancel()
await PTY_REGISTRY.close_all()
if cron_stop is not None:
cron_stop.set()
@ -4776,6 +4781,10 @@ def get_sessions(
try:
db = _open_session_db_for_profile(profile)
try:
# Opportunistic, config-gated, double-throttled stale-session
# sweep — the only auto_archive hook that fires for Desktop's
# `hermes serve` backend. No-op when disabled or run recently.
_maybe_auto_archive_for_profile(db, profile)
min_message_count = max(0, min_messages)
archived_only = archived == "only"
include_archived = archived == "include"
@ -11446,6 +11455,71 @@ def _open_session_db_for_profile(profile: Optional[str]):
return SessionDB(db_path=Path(home) / "state.db")
# In-process throttle for the opportunistic auto-archive trigger, keyed by
# profile. Bounds the config.yaml read to at most once per this window per
# profile; the actual sweep is throttled far more coarsely by state_meta
# (sessions.min_interval_hours) inside maybe_auto_archive.
_AUTO_ARCHIVE_CHECK_INTERVAL_S = 300.0
_last_auto_archive_check: Dict[str, float] = {}
def _maybe_auto_archive_for_profile(db, profile: Optional[str]) -> None:
"""Run the config-gated stale-session auto-archive for ``profile``.
The Desktop backend is spawned as ``hermes serve`` it runs neither the
interactive CLI nor the messaging gateway, so neither of those startup
hooks fire for Desktop users. Triggering the (double-throttled, config-off
by default) sweep from the session-list path is what makes
``sessions.auto_archive`` take effect there. Never raises.
"""
try:
key = profile or ""
now = time.monotonic()
last = _last_auto_archive_check.get(key)
if last is not None and now - last < _AUTO_ARCHIVE_CHECK_INTERVAL_S:
return
_last_auto_archive_check[key] = now
from hermes_cli.config import load_config as _load_full_config
cfg = (_load_full_config().get("sessions") or {})
if not cfg.get("auto_archive", False):
return
db.maybe_auto_archive(
idle_days=float(cfg.get("auto_archive_days", 3)),
min_interval_hours=int(cfg.get("min_interval_hours", 24)),
)
except Exception as exc:
_log.debug("opportunistic auto-archive skipped: %s", exc)
async def _auto_archive_ticker_loop(
interval_s: float = 3600.0, initial_delay_s: float = 90.0
) -> None:
"""Live timer for the stale-session auto-archive (primary profile).
A long-running Desktop/serve backend must keep sweeping on schedule even
when no ``/api/sessions`` request arrives to fire the opportunistic
trigger e.g. the app sits open for days on an idle chat. The real
cadence is still owned by state_meta (``sessions.min_interval_hours``)
inside ``maybe_auto_archive``; this loop is only the poll rate.
"""
def _sweep() -> None:
db = _open_session_db_for_profile(None)
try:
_maybe_auto_archive_for_profile(db, None)
finally:
db.close()
await asyncio.sleep(initial_delay_s)
while True:
try:
await asyncio.to_thread(_sweep)
except Exception as exc:
_log.debug("auto-archive tick skipped: %s", exc)
await asyncio.sleep(interval_s)
@app.get("/api/sessions/{session_id}")
async def get_session_detail(session_id: str, profile: Optional[str] = None):
db = _open_session_db_for_profile(profile)
@ -11550,6 +11624,9 @@ async def delete_session_endpoint(session_id: str, profile: Optional[str] = None
class SessionRename(BaseModel):
title: Optional[str] = None
archived: Optional[bool] = None
# Durable "keep" flag mirrored from the Desktop sidebar's pins; pinned
# sessions are exempt from the sessions.auto_archive stale sweep.
pinned: Optional[bool] = None
# Mutate a session belonging to another profile (opens its state.db). Omit
# for the current/default profile.
profile: Optional[str] = None
@ -11557,21 +11634,22 @@ class SessionRename(BaseModel):
@app.patch("/api/sessions/{session_id}")
async def rename_session_endpoint(session_id: str, body: SessionRename):
"""Update a session: rename (or clear its title) and/or archive it.
"""Update a session: rename, archive, and/or pin it.
``title`` renames (empty/null clears the title); ``archived`` soft-hides or
restores the session. Either field may be omitted. ``profile`` targets
another profile's session.
restores the session; ``pinned`` sets the durable keep flag (exempts the
session from the auto-archive sweep). Any field may be omitted. ``profile``
targets another profile's session.
"""
db = _open_session_db_for_profile(body.profile)
try:
sid = db.resolve_session_id(session_id)
if not sid:
raise HTTPException(status_code=404, detail="Session not found")
if body.title is None and body.archived is None:
if body.title is None and body.archived is None and body.pinned is None:
raise HTTPException(
status_code=400,
detail="Nothing to update; provide 'title' and/or 'archived'.",
detail="Nothing to update; provide 'title', 'archived', and/or 'pinned'.",
)
if body.title is not None:
try:
@ -11581,9 +11659,13 @@ async def rename_session_endpoint(session_id: str, body: SessionRename):
raise HTTPException(status_code=400, detail=str(e))
if body.archived is not None:
db.set_session_archived(sid, body.archived)
if body.pinned is not None:
db.set_session_pinned(sid, body.pinned)
result = {"ok": True, "title": db.get_session_title(sid) or ""}
if body.archived is not None:
result["archived"] = bool(body.archived)
if body.pinned is not None:
result["pinned"] = bool(body.pinned)
return result
finally:
db.close()

View file

@ -1101,6 +1101,7 @@ CREATE TABLE IF NOT EXISTS sessions (
profile_name TEXT,
rewind_count INTEGER NOT NULL DEFAULT 0,
archived INTEGER NOT NULL DEFAULT 0,
pinned INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (parent_session_id) REFERENCES sessions(id)
);
@ -5074,6 +5075,58 @@ class SessionDB:
rowcount = self._execute_write(_do)
return rowcount > 0
def set_session_pinned(self, session_id: str, pinned: bool) -> bool:
"""Pin or unpin a session (and its whole compression lineage).
``pinned`` is a durable "keep" flag: pinned sessions are exempt from
the ``sessions.auto_archive`` stale sweep (see
:meth:`archive_stale_sessions`). Desktop is the current writer its
sidebar pins mirror here so a backend/other-surface sweep honours
them. Like :meth:`set_session_archived` the whole compression chain is
flipped as a unit, so pinning the surfaced tip protects the root (and
vice-versa) no matter which id the caller holds. Returns True when at
least one row changed.
"""
def _do(conn):
cursor = conn.execute(
"""
WITH RECURSIVE
ancestors(id) AS (
SELECT ?
UNION
SELECT parent.id
FROM ancestors a
JOIN sessions child ON child.id = a.id
JOIN sessions parent ON parent.id = child.parent_session_id
WHERE parent.end_reason = 'compression'
),
descendants(id) AS (
SELECT ?
UNION
SELECT child.id
FROM descendants d
JOIN sessions parent ON parent.id = d.id
JOIN sessions child ON child.parent_session_id = parent.id
WHERE parent.end_reason = 'compression'
),
lineage(id) AS (
SELECT id FROM ancestors
UNION
SELECT id FROM descendants
)
UPDATE sessions
SET pinned = ?
WHERE id IN (SELECT id FROM lineage)
""",
(session_id, session_id, 1 if pinned else 0),
)
rowcount = cursor.rowcount
if rowcount is None or rowcount < 0:
rowcount = conn.execute("SELECT changes()").fetchone()[0]
return rowcount
rowcount = self._execute_write(_do)
return rowcount > 0
def get_session_by_title(self, title: str) -> Optional[Dict[str, Any]]:
"""Look up a session by exact title. Returns session dict or None."""
with self._lock:
@ -9032,6 +9085,55 @@ class SessionDB:
self.set_session_archived(row["id"], True)
return len(rows)
def archive_stale_sessions(
self, idle_days: float, *, exclude_pinned: bool = True
) -> int:
"""Archive every session untouched for at least ``idle_days`` days.
"Touched" is the latest message timestamp (falling back to
``started_at``) i.e. real recency, not creation time so a session
created long ago but active yesterday is spared, while an old
abandoned one (even a still-open one) is swept. This differs from
:meth:`archive_sessions`, which ages on ``started_at`` and only ended
sessions.
Guards:
* ``pinned = 0`` when ``exclude_pinned`` (the Desktop "keep" flag).
* ``archived = 0`` so repeat runs are idempotent no-ops.
* only lineage *tips* / standalone rows are candidates
(``end_reason <> 'compression'``); a stale tip archives its whole
chain via :meth:`set_session_archived`, so we never resurrect an
active conversation by matching an old compressed-away root whose
live continuation is recent.
Returns the number of sessions archived. Never raises for an empty or
non-positive ``idle_days`` it simply archives nothing.
"""
if idle_days is None or idle_days < 0:
return 0
cutoff = time.time() - float(idle_days) * 86400.0
pin_clause = "AND s.pinned = 0" if exclude_pinned else ""
with self._lock:
rows = self._conn.execute(
f"""
SELECT s.id FROM sessions s
WHERE s.archived = 0
AND COALESCE(s.end_reason, '') <> 'compression'
{pin_clause}
AND COALESCE(
(SELECT MAX(m.timestamp) FROM messages m
WHERE m.session_id = s.id),
s.started_at
) < ?
ORDER BY s.started_at ASC
""",
(cutoff,),
).fetchall()
ids = [(r["id"] if isinstance(r, sqlite3.Row) else r[0]) for r in rows]
for sid in ids:
self.set_session_archived(sid, True)
return len(ids)
def prune_sessions(
self,
older_than_days: Optional[float] = 90,
@ -9849,6 +9951,59 @@ class SessionDB:
return result
def maybe_auto_archive(
self,
idle_days: float = 3,
min_interval_hours: int = 24,
exclude_pinned: bool = True,
) -> Dict[str, Any]:
"""Idempotent auto-archive: soft-hide sessions idle for ``idle_days``.
Sibling of :meth:`maybe_auto_prune_and_vacuum` but non-destructive
it archives (hides) rather than deletes, and ages on last activity
(see :meth:`archive_stale_sessions`) rather than creation. Records the
last run in ``state_meta['last_auto_archive']`` so calls within
``min_interval_hours`` no-op; safe to call opportunistically (startup
hooks, or when the Desktop backend lists sessions).
Never raises. Returns a dict with:
- ``"skipped"`` (bool) within min_interval_hours of last run
- ``"archived"`` (int) sessions archived this run
- ``"error"`` (str, optional) present only on failure
"""
result: Dict[str, Any] = {"skipped": False, "archived": 0}
try:
last_raw = self.get_meta("last_auto_archive")
now = time.time()
if last_raw:
try:
if now - float(last_raw) < min_interval_hours * 3600:
result["skipped"] = True
return result
except (TypeError, ValueError):
pass # corrupt meta; treat as no prior run
archived = self.archive_stale_sessions(
idle_days, exclude_pinned=exclude_pinned
)
result["archived"] = archived
# Record even a zero-archive run so we don't re-sweep every call
# within the interval window.
self.set_meta("last_auto_archive", str(now))
if archived > 0:
logger.info(
"state.db auto-archive: archived %d session(s) idle >= %s days",
archived,
idle_days,
)
except Exception as exc:
logger.warning("state.db auto-archive failed: %s", exc)
result["error"] = str(exc)
return result
# ── Handoff (cross-platform session transfer) ──────────────────────────
#
# State machine:

View file

@ -1878,6 +1878,88 @@ class TestWebServerEndpoints:
resp = self.client.patch("/api/sessions/no-fields", json={})
assert resp.status_code == 400
def test_patch_session_pins_and_exempts_from_auto_archive(self):
"""PATCH pinned=true sets the keep flag; a pinned stale session is
spared by the auto-archive sweep while an unpinned one is hidden."""
import time as _time
from hermes_state import SessionDB
old = _time.time() - 30 * 86400
db = SessionDB()
try:
for sid in ("keep-me", "drop-me"):
db.create_session(session_id=sid, source="cli")
db.append_message(session_id=sid, role="user", content="hi")
db._conn.execute(
"UPDATE sessions SET started_at = ? WHERE id = ?", (old, sid)
)
db._conn.execute(
"UPDATE messages SET timestamp = ? WHERE session_id = ?", (old, sid)
)
db._conn.commit()
finally:
db.close()
resp = self.client.patch("/api/sessions/keep-me", json={"pinned": True})
assert resp.status_code == 200
assert resp.json()["pinned"] is True
db = SessionDB()
try:
archived = db.archive_stale_sessions(3)
finally:
db.close()
assert archived == 1
listed = self.client.get("/api/sessions").json()["sessions"]
ids = {s["id"] for s in listed}
assert "keep-me" in ids # pinned -> spared
assert "drop-me" not in ids # unpinned + stale -> archived
def test_list_triggers_config_gated_auto_archive(self):
"""With sessions.auto_archive on, listing sessions opportunistically
sweeps stale ones (the Desktop `hermes serve` code path)."""
import time as _time
import hermes_cli.web_server as ws
from hermes_state import SessionDB
old = _time.time() - 30 * 86400
db = SessionDB()
try:
db.create_session(session_id="stale-serve", source="cli")
db.append_message(session_id="stale-serve", role="user", content="hi")
db._conn.execute(
"UPDATE sessions SET started_at = ? WHERE id = ?", (old, "stale-serve")
)
db._conn.execute(
"UPDATE messages SET timestamp = ? WHERE session_id = ?",
(old, "stale-serve"),
)
db._conn.commit()
finally:
db.close()
# Reset the in-process throttle so the trigger actually evaluates config.
ws._last_auto_archive_check.clear()
# The helper imports load_config lazily from hermes_cli.config; patch there.
import hermes_cli.config as _config_mod
def _cfg():
return {"sessions": {"auto_archive": True, "auto_archive_days": 3, "min_interval_hours": 0}}
prev = _config_mod.load_config
_config_mod.load_config = _cfg # type: ignore[assignment]
try:
listed = self.client.get("/api/sessions").json()["sessions"]
finally:
_config_mod.load_config = prev # type: ignore[assignment]
ws._last_auto_archive_check.clear()
assert all(s["id"] != "stale-serve" for s in listed)
def test_profiles_sessions_tags_default_profile(self):
"""The cross-profile aggregator returns the default profile's rows
tagged profile="default" (single-profile parity with /api/sessions)."""

View file

@ -6488,6 +6488,133 @@ class TestSessionArchive:
assert db.session_count(include_archived=True) == 2
class TestSessionPinAndStaleArchive:
"""Pin as a durable keep flag + last-activity-based stale auto-archive."""
def _pinned(self, db, sid):
row = db._conn.execute(
"SELECT pinned FROM sessions WHERE id = ?", (sid,)
).fetchone()
return row["pinned"] if row is not None else None
def _make_idle(self, db, sid, *, days_idle, source="cli"):
"""A session whose latest activity was ``days_idle`` days ago."""
db.create_session(session_id=sid, source=source)
db.append_message(session_id=sid, role="user", content=f"msg {sid}")
old = time.time() - days_idle * 86400
db._conn.execute("UPDATE sessions SET started_at = ? WHERE id = ?", (old, sid))
db._conn.execute(
"UPDATE messages SET timestamp = ? WHERE session_id = ?", (old, sid)
)
db._conn.commit()
# ── pin flag ──────────────────────────────────────────────────────────
def test_set_session_pinned_roundtrip(self, db):
db.create_session(session_id="s1", source="cli")
assert db.set_session_pinned("s1", True) is True
assert self._pinned(db, "s1") == 1
assert db.set_session_pinned("s1", False) is True
assert self._pinned(db, "s1") == 0
def test_set_session_pinned_missing_row(self, db):
assert db.set_session_pinned("nope", True) is False
def test_pin_propagates_across_compression_lineage(self, db):
db.create_session(session_id="root", source="cli")
db.end_session("root", end_reason="compression")
db.create_session(session_id="tip", source="cli", parent_session_id="root")
# Pinning the surfaced tip pins the compressed-away root too.
assert db.set_session_pinned("tip", True) is True
assert self._pinned(db, "root") == 1
assert self._pinned(db, "tip") == 1
# ── stale archive ─────────────────────────────────────────────────────
def test_archives_only_sessions_idle_past_threshold(self, db):
self._make_idle(db, "stale", days_idle=5)
self._make_idle(db, "fresh", days_idle=1)
assert db.archive_stale_sessions(3) == 1
assert db.get_session("stale")["archived"] == 1
assert db.get_session("fresh")["archived"] == 0
def test_recency_uses_last_activity_not_creation(self, db):
# Created long ago, but a message landed today -> not stale.
db.create_session(session_id="old_but_active", source="cli")
db._conn.execute(
"UPDATE sessions SET started_at = ? WHERE id = ?",
(time.time() - 60 * 86400, "old_but_active"),
)
db._conn.commit()
db.append_message(
session_id="old_but_active", role="user", content="fresh activity"
)
assert db.archive_stale_sessions(3) == 0
assert db.get_session("old_but_active")["archived"] == 0
def test_pinned_sessions_are_spared(self, db):
self._make_idle(db, "keep", days_idle=10)
db.set_session_pinned("keep", True)
assert db.archive_stale_sessions(3) == 0
assert db.get_session("keep")["archived"] == 0
# Opting out of the pin guard sweeps it.
assert db.archive_stale_sessions(3, exclude_pinned=False) == 1
assert db.get_session("keep")["archived"] == 1
def test_already_archived_is_idempotent(self, db):
self._make_idle(db, "stale", days_idle=5)
assert db.archive_stale_sessions(3) == 1
assert db.archive_stale_sessions(3) == 0
def test_stale_tip_archives_whole_compression_chain(self, db):
# A compressed-away root is never a candidate on its own (its live
# continuation may be fresh); the tip drives the decision and archives
# the chain as a unit.
db.create_session(session_id="root", source="cli")
db.end_session("root", end_reason="compression")
db.create_session(session_id="tip", source="cli", parent_session_id="root")
old = time.time() - 9 * 86400
db._conn.execute(
"UPDATE sessions SET started_at = ? WHERE id IN ('root', 'tip')", (old,)
)
db._conn.commit()
assert db.archive_stale_sessions(3) == 1 # one candidate (the tip)
assert db.get_session("root")["archived"] == 1
assert db.get_session("tip")["archived"] == 1
def test_non_positive_threshold_archives_nothing(self, db):
self._make_idle(db, "stale", days_idle=100)
assert db.archive_stale_sessions(-1) == 0
assert db.get_session("stale")["archived"] == 0
# ── throttled wrapper ─────────────────────────────────────────────────
def test_maybe_auto_archive_runs_then_throttles(self, db):
self._make_idle(db, "stale", days_idle=5)
first = db.maybe_auto_archive(idle_days=3, min_interval_hours=24)
assert first["skipped"] is False
assert first["archived"] == 1
assert db.get_meta("last_auto_archive") is not None
# A newly-stale session within the interval is left untouched.
self._make_idle(db, "stale2", days_idle=5)
second = db.maybe_auto_archive(idle_days=3, min_interval_hours=24)
assert second["skipped"] is True
assert second["archived"] == 0
assert db.get_session("stale2")["archived"] == 0
def test_maybe_auto_archive_reruns_after_interval(self, db):
self._make_idle(db, "stale", days_idle=5)
db.maybe_auto_archive(idle_days=3, min_interval_hours=24)
db.set_meta("last_auto_archive", str(time.time() - 48 * 3600))
self._make_idle(db, "stale2", days_idle=5)
result = db.maybe_auto_archive(idle_days=3, min_interval_hours=24)
assert result["skipped"] is False
assert result["archived"] == 1
class TestSessionIdSearch:
"""Session id search backs Desktop's Search Sessions UX."""