Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,13 @@ The proxy binary is automatically downloaded from [CLIProxyAPI](https://github.c
| `sessions.max_sessions` | int | `500` | Max active (non-archived) sessions before cleanup |
| `sessions.cron_session_mode` | string | `per_run` | `per_run` (unique session per cron run) or `reuse` (shared session per job) |

**Starred sessions are exempt from all auto-archival.** A session starred via
the star toggle (web sidebar, or the Telegram `/sessions` list / `/star`) is
never auto-closed: it is skipped by the idle cutoff and the
`archive_after_days` backstop, and is off-budget for `max_sessions` — neither
counted toward the cap nor evicted. It stays resumable until explicitly
unstarred, archived, or deleted.

## Retention

Opt-in `nerve.db` maintenance. Disabled by default. When enabled, a background
Expand Down
45 changes: 42 additions & 3 deletions nerve/agent/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -609,6 +609,37 @@ async def list_sessions(
limit=limit, include_archived=include_archived,
)

async def set_starred(self, session_id: str, starred: bool) -> bool:
"""Star or unstar a session.

Starred sessions are exempt from every auto-archival path (the idle
cutoff, the age backstop, and the overflow eviction), so they stay
resumable until explicitly unstarred or deleted. Returns the new
starred state; raises ``ValueError`` if the session does not exist.
"""
session = await self.db.get_session(session_id)
if not session:
raise ValueError(f"Session {session_id} not found")
await self.db.update_session_fields(
session_id, {"starred": 1 if starred else 0},
)
return starred

async def toggle_starred(self, session_id: str) -> bool:
"""Flip a session's starred flag.

Returns the new starred state; raises ``ValueError`` if the session
does not exist.
"""
session = await self.db.get_session(session_id)
if not session:
raise ValueError(f"Session {session_id} not found")
new_starred = not session.get("starred")
await self.db.update_session_fields(
session_id, {"starred": 1 if new_starred else 0},
)
return new_starred

# ------------------------------------------------------------------ #
# Archive & cleanup #
# ------------------------------------------------------------------ #
Expand Down Expand Up @@ -652,7 +683,13 @@ async def run_cleanup(
ones parked on an unanswered ``ask_user`` question, whose pending
notification is expired so it doesn't dangle on a closed session.
Cron/persistent sessions are excluded from the short cutoff and only
hit the ``archive_after_days`` backstop. Returns cleanup statistics.
hit the ``archive_after_days`` backstop. Starred sessions are exempt
from every auto-archival path (short cutoff, backstop, and the
max-session overflow eviction) and are off-budget for the
``max_sessions`` cap — they are neither counted toward it nor evicted,
so the cap governs only cap-eligible (non-starred) sessions. A starred
session is kept until it is explicitly unstarred or deleted. Returns
cleanup statistics.
"""
now = datetime.now(timezone.utc)

Expand Down Expand Up @@ -682,8 +719,10 @@ async def run_cleanup(
for s in stale:
await self.archive_session(s["id"])

# Enforce max session count (archive oldest beyond limit)
count = await self.db.count_active_sessions()
# Enforce max session count (archive oldest beyond limit). Starred
# sessions are off-budget: excluded from the count and never evicted,
# so the cap governs only cap-eligible (non-starred) sessions.
count = await self.db.count_active_sessions(exclude_starred=True)
overflow = 0
if count > max_sessions:
excess = await self.db.get_oldest_sessions(
Expand Down
8 changes: 8 additions & 0 deletions nerve/channels/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,14 @@ async def list_sessions(self, limit: int = 20) -> list[dict[str, Any]]:
"""List sessions, most recently updated first."""
return await self.engine.sessions.list_sessions(limit=limit)

async def set_session_starred(self, session_id: str, starred: bool) -> bool:
"""Star/unstar a session. Starred sessions are never auto-archived."""
return await self.engine.sessions.set_starred(session_id, starred)

async def toggle_session_starred(self, session_id: str) -> bool:
"""Toggle a session's starred flag. Returns the new state."""
return await self.engine.sessions.toggle_starred(session_id)

async def get_session(self, session_id: str) -> dict[str, Any] | None:
"""Fetch a session row (title/status/…), or None if it is gone."""
return await self.engine.db.get_session(session_id)
Expand Down
78 changes: 73 additions & 5 deletions nerve/channels/telegram.py
Original file line number Diff line number Diff line change
Expand Up @@ -228,11 +228,14 @@ def _format_reply_context(message: Any) -> str:


def _session_label(session: dict, current_id: str | None) -> str:
"""Button label for one session: title (or short id), current marked ✓."""
"""Button label for one session: title (or short id), current marked ✓,
starred (kept-alive) marked ⭐."""
title = (session.get("title") or "").strip() or session.get("id", "?")
if len(title) > _SESSION_LABEL_MAX:
title = title[: _SESSION_LABEL_MAX - 1] + "…"
prefix = "✓ " if session.get("id") == current_id else ""
if session.get("starred"):
prefix += "⭐ "
return f"{prefix}{title}"


Expand All @@ -255,9 +258,17 @@ def build_sessions_view(
# ids are short, but guard defensively so a button always round-trips.
if sid is None or len(cb.encode("utf-8")) > 64:
continue
rows.append(
[InlineKeyboardButton(_session_label(s, current_id), callback_data=cb)]
)
row = [InlineKeyboardButton(_session_label(s, current_id), callback_data=cb)]
# Per-session star toggle: ⭐ = kept alive (never auto-closed),
# ☆ = normal. Add it only when its callback also round-trips.
star_cb = f"sessstar:{sid}"
if len(star_cb.encode("utf-8")) <= 64:
row.append(
InlineKeyboardButton(
"⭐" if s.get("starred") else "☆", callback_data=star_cb,
)
)
rows.append(row)
rows.append([InlineKeyboardButton("➕ New session", callback_data="sess:new")])

# The New-session button switches routing to a fresh session WITHOUT
Expand All @@ -277,6 +288,7 @@ def build_sessions_view(
if current_title:
text += f"\nCurrent: {current_title}"
text += "\n➕ New session keeps the current one running."
text += "\n⭐ keeps a session alive (never auto-closed); tap ☆/⭐ to toggle."
else:
text = "No sessions yet — tap ➕ to start one."
return text, InlineKeyboardMarkup(rows)
Expand Down Expand Up @@ -505,6 +517,8 @@ def _build_application(self) -> Application:
app.add_handler(CommandHandler("pair", self._handle_pair))
app.add_handler(CommandHandler("session", self._handle_session))
app.add_handler(CommandHandler("sessions", self._handle_sessions))
app.add_handler(CommandHandler("star", self._handle_star))
app.add_handler(CommandHandler("unstar", self._handle_unstar))
app.add_handler(CommandHandler("new", self._handle_new_session))
app.add_handler(CommandHandler("stop", self._handle_stop))
app.add_handler(CommandHandler("restart", self._handle_restart))
Expand Down Expand Up @@ -1091,6 +1105,37 @@ async def _handle_sessions(self, update: Update, context: Any) -> None:
text, markup = await self._sessions_view_for(channel_key)
await update.message.reply_text(text, reply_markup=markup)

async def _handle_star(self, update: Update, context: Any) -> None:
"""Handle /star — star the current session so it never auto-closes."""
await self._set_current_starred(update, True)

async def _handle_unstar(self, update: Update, context: Any) -> None:
"""Handle /unstar — clear the current session's kept-alive flag."""
await self._set_current_starred(update, False)

async def _set_current_starred(self, update: Update, starred: bool) -> None:
self._touch()
if not self._is_authorized(update.effective_user.id):
return
channel_key = f"telegram:{update.effective_chat.id}"
current = await self.router.get_last_session(channel_key)
if not current:
await update.message.reply_text("No active session to star.")
return
try:
await self.router.set_session_starred(current, starred)
except ValueError as e:
await update.message.reply_text(str(e))
return
if starred:
await update.message.reply_text(
"⭐ Session starred — it won't auto-close when idle."
)
else:
await update.message.reply_text(
"☆ Session unstarred — normal auto-close applies."
)

async def _handle_new_session(self, update: Update, context: Any) -> None:
"""Handle /new [title] — stop current session, create and switch to a new one."""
self._touch()
Expand Down Expand Up @@ -1755,6 +1800,7 @@ async def _handle_session_button(self, query: Any) -> None:
``sess:new`` — create a fresh session (current keeps running)
``sess:<id>`` — switch routing to <id>, then show its history
``sesstail:<id>:<win>`` — widen the catch-up window (informational only)
``sessstar:<id>`` — toggle the session's starred (kept-alive) flag

After a switch/create the card is replaced by that session's recent
history (native order, oldest→newest) so the user can catch up — which
Expand Down Expand Up @@ -1784,6 +1830,24 @@ async def _handle_session_button(self, query: Any) -> None:
await self._edit_session_tail(query, sid, window)
return

if data.startswith("sessstar:"):
sid = data.split(":", 1)[1]
try:
now_starred = await self.router.toggle_session_starred(sid)
except ValueError:
await query.answer(
"That session is no longer available", show_alert=True,
)
else:
await query.answer(
"⭐ Kept alive — won't auto-close"
if now_starred
else "☆ Normal — may auto-close when idle"
)
text, markup = await self._sessions_view_for(channel_key)
await self._safe_edit(query, text, markup)
return

# sess:new / sess:<id>
target = data.split(":", 1)[1]
sid = target
Expand Down Expand Up @@ -1817,7 +1881,11 @@ async def _handle_callback_query(self, update: Update, context: Any) -> None:
return

# /sessions inline keyboard: switch/create/list + history buttons.
if query.data.startswith("sess:") or query.data.startswith("sesstail:"):
if (
query.data.startswith("sess:")
or query.data.startswith("sesstail:")
or query.data.startswith("sessstar:")
):
await self._handle_session_button(query)
return

Expand Down
2 changes: 1 addition & 1 deletion nerve/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -635,7 +635,7 @@ def from_dict(cls, d: dict) -> BackupConfig:
@dataclass
class SessionsConfig:
archive_after_days: int = 30
interactive_archive_after_hours: int = 0 # Interactive (web/telegram/…) sessions auto-close after this many idle hours (0 = disabled; opt in via config)
interactive_archive_after_hours: int = 0 # Interactive (web/telegram/…) sessions auto-close after this many idle hours (0 = disabled; opt in via config). Starred sessions are exempt and never auto-close.
max_sessions: int = 500
cron_session_mode: str = "per_run" # "per_run" or "reuse"
memorize_interval_minutes: int = 30 # Background memorization sweep interval
Expand Down
36 changes: 28 additions & 8 deletions nerve/db/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -302,13 +302,18 @@ async def get_sessions_by_status(self, statuses: list[str]) -> list[dict]:
async def get_stale_sessions(
self, before_iso: str, exclude_ids: list[str] | None = None,
) -> list[dict]:
"""Get idle/stopped/error sessions not updated since before_iso."""
"""Get idle/stopped/error sessions not updated since before_iso.

Starred sessions are excluded: a starred session is never
auto-archived, regardless of how long it has been idle.
"""
excludes = exclude_ids or []
if excludes:
placeholders = ",".join("?" for _ in excludes)
query = f"""
SELECT * FROM sessions
WHERE status IN ('idle', 'stopped', 'error')
AND starred = 0
AND updated_at < ?
AND id NOT IN ({placeholders})
"""
Expand All @@ -317,6 +322,7 @@ async def get_stale_sessions(
query = """
SELECT * FROM sessions
WHERE status IN ('idle', 'stopped', 'error')
AND starred = 0
AND updated_at < ?
"""
params = (before_iso,)
Expand All @@ -330,38 +336,51 @@ async def get_stale_interactive_sessions(

Scoped by ``source`` (web/telegram/…) so cron and persistent sessions
keep their own long rotation and are never archived at the short
interactive cutoff. Returns [] when ``sources`` is empty.
interactive cutoff. Starred sessions are also excluded — a starred
session is never auto-archived. Returns [] when ``sources`` is empty.
"""
if not sources:
return []
src = ",".join("?" for _ in sources)
query = f"""
SELECT * FROM sessions
WHERE status IN ('idle', 'stopped', 'error')
AND starred = 0
AND source IN ({src})
AND updated_at < ?
"""
async with self.db.execute(query, (*sources, before_iso)) as cursor:
return [dict(row) async for row in cursor]

async def count_active_sessions(self) -> int:
"""Count non-archived sessions."""
async with self.db.execute(
"SELECT COUNT(*) FROM sessions WHERE status != 'archived'"
) as cursor:
async def count_active_sessions(self, exclude_starred: bool = False) -> int:
"""Count non-archived sessions.

When ``exclude_starred`` is set, starred sessions are omitted: they are
off-budget for the ``max_sessions`` cap, so the overflow check counts
only cap-eligible sessions.
"""
query = "SELECT COUNT(*) FROM sessions WHERE status != 'archived'"
if exclude_starred:
query += " AND starred = 0"
async with self.db.execute(query) as cursor:
row = await cursor.fetchone()
return row[0] if row else 0

async def get_oldest_sessions(
self, count: int, exclude_ids: list[str] | None = None,
) -> list[dict]:
"""Get the oldest non-active, non-archived sessions for cleanup."""
"""Get the oldest non-active, non-archived sessions for cleanup.

Starred sessions are excluded: they are never evicted to enforce the
session-count limit.
"""
excludes = exclude_ids or []
if excludes:
placeholders = ",".join("?" for _ in excludes)
query = f"""
SELECT * FROM sessions
WHERE status NOT IN ('active', 'archived')
AND starred = 0
AND id NOT IN ({placeholders})
ORDER BY updated_at ASC LIMIT ?
"""
Expand All @@ -370,6 +389,7 @@ async def get_oldest_sessions(
query = """
SELECT * FROM sessions
WHERE status NOT IN ('active', 'archived')
AND starred = 0
ORDER BY updated_at ASC LIMIT ?
"""
params = (count,)
Expand Down
Loading
Loading