mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
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:
parent
c416d9ae0a
commit
f16b80362c
7 changed files with 499 additions and 5 deletions
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue