Files
hermes-agent/hermes_cli/web_routers/sessions.py
T

890 lines
36 KiB
Python

"""Session dashboard routes.
Three routers because global route order matters: ``list_router``
(GET /api/sessions) was registered before the profiles ``sessions_router``
include, ``search_router`` (GET /api/sessions/search) right after it, and
``manage_router`` (mutation/detail endpoints) much later — each is mounted at
its original registration point. web_server-owned helpers are reached via
the late-binding seam so ``monkeypatch.setattr(web_server, ...)`` keeps working.
"""
import asyncio
import json
import re
import sqlite3
import time
from typing import Callable, List, Optional
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.encoders import jsonable_encoder
from fastapi.responses import StreamingResponse
from hermes_cli.web_deps import late
from hermes_cli.web_models import (
BulkDeleteSessions,
SessionImport,
SessionOwnerBackfill,
SessionPrune,
SessionRename,
)
from hermes_cli.web_routers._common import log as _log, http_failure
from hermes_state import is_malformed_db_error, is_transient_sqlite_error
from typing import Any, Dict
list_router = APIRouter()
search_router = APIRouter()
manage_router = APIRouter()
_cron_default_profile = late("_cron_default_profile")
_cron_profile_home = late("_cron_profile_home")
_maybe_auto_archive_for_profile = late("_maybe_auto_archive_for_profile")
_open_session_db_for_profile = late("_open_session_db_for_profile")
_session_latest_descendant = late("_session_latest_descendant")
_strip_session_list_rows = late("_strip_session_list_rows")
# CRITICAL — every literal-path route below MUST be declared BEFORE the
# templated ``/api/sessions/{session_id}`` family that follows. FastAPI/
# Starlette match routes in registration order, and the ``{session_id}``
# pattern is unconstrained — it would otherwise swallow e.g.
# ``DELETE /api/sessions/empty``, ``POST /api/sessions/bulk-delete``, or
# ``GET /api/sessions/stats`` as "operate on the session with id
# 'empty'" / "'bulk-delete'" / "'stats'", which would 404 (or worse,
# succeed and delete the wrong row). Same story as the older
# ``/api/sessions/search`` endpoint up at line ~1191. If you split or
# reorder this block, move every route in it together.
# Keep the dashboard import endpoint stream-safe: FastAPI otherwise parses and
# buffers an arbitrarily large JSON body before SessionDB can enforce its own
# per-session and transaction-work limits.
_SESSION_IMPORT_MAX_BYTES = 25 * 1024 * 1024
async def _read_session_import_body(request: Request) -> bytes:
body = bytearray()
async for chunk in request.stream():
if len(body) + len(chunk) > _SESSION_IMPORT_MAX_BYTES:
raise HTTPException(status_code=413, detail="Session import payload is too large")
body.extend(chunk)
return bytes(body)
def _import_sessions_for_profile(profile: Optional[str], sessions: List[Dict[str, Any]]) -> Dict[str, Any]:
db = _open_session_db_for_profile(profile, read_only=False)
try:
return db.import_sessions(sessions)
finally:
db.close()
def _prune_sessions(body: SessionPrune):
"""Delete ended sessions matching filters (mirrors `hermes sessions prune`)."""
from hermes_cli.web_server import get_hermes_home
has_window = (
body.started_before is not None or body.started_after is not None
)
if body.older_than_days is not None and body.older_than_days < 1 and not has_window:
raise HTTPException(status_code=400, detail="older_than_days must be >= 1")
# Mirror the CLI: the implicit 90-day cutoff only applies to a truly bare
# prune. Any attribute filter (source, title, model, ...) suppresses it
# unless the caller explicitly sent older_than_days.
_attr_filters_set = any(
getattr(body, f) is not None
for f in (
"source", "title_like", "end_reason", "cwd_prefix",
"min_messages", "max_messages", "model_like", "provider",
"user_id", "chat_id", "chat_type", "branch_like",
"min_tokens", "max_tokens", "min_cost", "max_cost",
"min_tool_calls", "max_tool_calls",
)
)
_older_than_explicit = "older_than_days" in body.model_fields_set
_effective_older_than = body.older_than_days
if has_window or (_attr_filters_set and not _older_than_explicit):
_effective_older_than = None
profile_home = _cron_profile_home(body.profile)[1] if body.profile else get_hermes_home()
db = _open_session_db_for_profile(body.profile, read_only=False)
try:
filters = dict(
older_than_days=_effective_older_than,
source=(body.source or None),
started_before=body.started_before,
started_after=body.started_after,
title_like=(body.title_like or None),
end_reason=(body.end_reason or None),
cwd_prefix=(body.cwd_prefix or None),
min_messages=body.min_messages,
max_messages=body.max_messages,
model_like=(body.model_like or None),
provider=(body.provider or None),
user_id=(body.user_id or None),
chat_id=(body.chat_id or None),
chat_type=(body.chat_type or None),
branch_like=(body.branch_like or None),
min_tokens=body.min_tokens,
max_tokens=body.max_tokens,
min_cost=body.min_cost,
max_cost=body.max_cost,
min_tool_calls=body.min_tool_calls,
max_tool_calls=body.max_tool_calls,
archived=None if body.include_archived else False,
)
skipped_open = db.count_open_prune_matches(**filters)
if body.dry_run:
rows = db.list_prune_candidates(**filters)
return {
"ok": True,
"removed": 0,
"matched": len(rows),
"skipped_open": skipped_open,
# Rows are ordered by last activity, not creation time.
"oldest_last_active": rows[0]["last_active"] if rows else None,
"newest_last_active": rows[-1]["last_active"] if rows else None,
"oldest_started_at": (
min(r["started_at"] for r in rows) if rows else None
),
"newest_started_at": (
max(r["started_at"] for r in rows) if rows else None
),
"sessions": [
{
"id": r["id"],
"source": r["source"],
"title": r.get("title"),
"model": r.get("model"),
"started_at": r["started_at"],
"last_active": r["last_active"],
"message_count": r["message_count"],
}
for r in rows
],
}
sessions_dir = profile_home / "sessions"
removed = db.prune_sessions(
sessions_dir=sessions_dir if sessions_dir.exists() else None,
**filters,
)
return {"ok": True, "removed": removed, "skipped_open": skipped_open}
finally:
db.close()
_ACTIVE_WINDOW_S = 300
def _csv(value: Optional[str]) -> List[str]:
"""Split a comma-separated query param into stripped, non-empty items."""
return [s.strip() for s in (value or "").split(",") if s.strip()]
def _is_active(row: dict, now: float) -> bool:
return (
row.get("ended_at") is None
and (now - row.get("last_active", row.get("started_at", 0))) < _ACTIVE_WINDOW_S
)
def _with_db(profile: Optional[str], fn: Callable, *, read_only: bool):
"""Open the profile's session DB, run ``fn(db)``, always close."""
db = _open_session_db_for_profile(profile, read_only=read_only)
try:
return fn(db)
finally:
db.close()
def _serving_profile(profile: Optional[str]) -> str:
"""The profile name rows are stamped with: the requested one, else the
serving process's own — so default-profile rows never circulate unowned."""
return _cron_profile_home(profile)[0] if profile else _cron_default_profile()
def _resolve_session_id(db, session_id: str) -> Optional[str]:
"""Resolve *session_id*, distinguishing "absent" from "unreadable".
On a corrupt ``state.db`` the exact-match lookup (primary-key index) just
misses, while the prefix fallback scans the b-tree and raises "database
disk image is malformed". Both used to end at 404; report corruption as
503 with the actual problem instead of an empty store.
"""
try:
return db.resolve_session_id(session_id)
except sqlite3.DatabaseError as exc:
if not is_malformed_db_error(exc):
raise
_log.error(
"state.db is corrupt while resolving session %s: %s", session_id, exc
)
raise HTTPException(
status_code=503,
detail=(
"Session store is corrupt (database disk image is malformed). "
"Sessions cannot be read until it is repaired — run "
"`hermes doctor` for diagnosis."
),
) from exc
@list_router.get("/api/sessions")
def get_sessions(
# ``le=100``: an unbounded limit lets one request drag every session row
# (plus correlated-subquery preview work) out of SQLite in a single hit.
limit: int = Query(20, ge=0, le=100),
offset: int = Query(0, ge=0),
min_messages: int = 0,
archived: str = "exclude",
order: str = "created",
source: str = None,
sources: str = None,
exclude_sources: str = None,
cwd_prefix: str = None,
full: bool = False,
profile: Optional[str] = None,
):
"""List sessions.
``archived``: ``exclude`` (default) / ``only`` / ``include``. ``order``:
``created`` (start time) or ``recent`` (latest activity across the
compression chain, so a long-running chat stays on page one after it
auto-compresses onto a fresh id). Rows omit ``system_prompt`` /
``model_config`` unless ``full=1``.
"""
if archived not in ("exclude", "only", "include"):
raise HTTPException(
status_code=400,
detail="archived must be one of: exclude, only, include",
)
if order not in ("created", "recent"):
raise HTTPException(
status_code=400,
detail="order must be one of: created, recent",
)
profile_name: Optional[str] = None
if profile:
profile_name, _ = _cron_profile_home(profile)
try:
# Auto-archive is the only write on this GET path: run it on its own
# maintenance connection, then open the listing connection read-only.
_maybe_auto_archive_for_profile(profile)
db = _open_session_db_for_profile(profile, read_only=True)
try:
min_message_count = max(0, min_messages)
archived_only = archived == "only"
include_archived = archived == "include"
# Source scoping: the desktop splits recents (exclude=cron) from
# the cron-jobs section (source=cron) into two independent lists.
source_list = _csv(sources)
exclude_list = _csv(exclude_sources)
sessions = db.list_sessions_rich(
source=source or None,
sources=source_list or None,
exclude_sources=exclude_list or None,
cwd_prefix=(cwd_prefix or None),
limit=limit,
offset=offset,
min_message_count=min_message_count,
include_archived=include_archived,
archived_only=archived_only,
order_by_last_active=order == "recent",
# Skip the system_prompt blob inside SQLite too (pairs with
# _strip_session_list_rows below).
compact_rows=not full,
include_pinned=True,
)
total = db.session_count(
source=source or None,
sources=source_list or None,
cwd_prefix=(cwd_prefix or None),
exclude_sources=exclude_list or None,
min_message_count=min_message_count,
include_archived=include_archived,
archived_only=archived_only,
exclude_children=True,
)
now = time.time()
row_profile = profile_name or _cron_default_profile()
for s in sessions:
s["is_active"] = _is_active(s, now)
s["profile"] = row_profile
s["is_default_profile"] = row_profile == "default"
# SQLite stores the flags as 0/1; expose real JSON booleans.
s["archived"] = bool(s.get("archived"))
s["pinned"] = bool(s.get("pinned"))
if not full:
_strip_session_list_rows(sessions)
return {"sessions": sessions, "total": total, "limit": limit, "offset": offset}
finally:
db.close()
except HTTPException:
raise
except sqlite3.OperationalError as exc:
_log.exception("GET /api/sessions failed")
# 503, not 500: the store is busy, not gone — the desktop keeps its
# sidebar instead of reading a 500 as an authoritative empty list.
# The bounded open-retry lives in SessionDB's read-only constructor.
transient = is_transient_sqlite_error(exc)
raise HTTPException(
status_code=503 if transient else 500,
detail=(
"Session store is busy (disk I/O or lock). Retry; the list was not cleared."
if transient
else "Internal server error"
),
) from exc
except Exception:
_log.exception("GET /api/sessions failed")
raise HTTPException(status_code=500, detail="Internal server error")
@search_router.get("/api/sessions/search")
async def search_sessions(
q: str = "",
limit: int = 20,
profile: Optional[str] = None,
source: str = None,
sources: str = None,
exclude_sources: str = None,
):
"""Search sessions by ID plus FTS5 message content.
ID matches first, then content matches. Results are deduped by
compression lineage, not raw ``session_id``: auto-compression rotates a
chat onto a fresh id and leaves the old segment in the FTS index, so one
logical chat owns many rows. Branches also use ``parent_session_id`` but
are real alternate conversations — they are NOT collapsed into the parent.
"""
if not q or not q.strip():
return {"results": []}
with http_failure("GET /api/sessions/search failed", 500, detail="Search failed"):
db = _open_session_db_for_profile(profile, read_only=True)
try:
safe_limit = max(1, min(int(limit or 20), 100))
source_filter = source or None
source_list = _csv(sources)
include_sources = [source_filter] if source_filter else (source_list or None)
exclude_list = _csv(exclude_sources)
now = time.time()
# Walk parent_session_id to the compression root, memoized per
# chain; stops at branch/delegate edges (those stay searchable).
root_cache: dict = {}
def compression_root(session_id: str) -> str:
if not session_id:
return session_id
if session_id in root_cache:
return root_cache[session_id]
chain = []
cur = session_id
visited = set()
root = session_id
while cur and cur not in visited:
visited.add(cur)
chain.append(cur)
if cur in root_cache:
root = root_cache[cur]
break
try:
s = db.get_session(cur)
except Exception:
s = None
if not s:
root = cur
break
parent = s.get("parent_session_id") if isinstance(s, dict) else None
if not parent:
root = cur
break
try:
parent_session = db.get_session(parent)
except Exception:
parent_session = None
if not parent_session:
root = cur
break
parent_ended_at = parent_session.get("ended_at")
started_at = s.get("started_at")
is_compression_edge = (
parent_session.get("end_reason") == "compression"
and parent_ended_at is not None
and started_at is not None
and started_at >= parent_ended_at
)
if not is_compression_edge:
root = cur
break
cur = parent
for node in chain:
root_cache[node] = root
return root
tip_cache: dict = {}
def lineage_tip(root_id: str) -> str:
if root_id in tip_cache:
return tip_cache[root_id]
tip = root_id
try:
resolved = db.get_compression_tip(root_id)
if resolved:
tip = resolved
except Exception:
pass
tip_cache[root_id] = tip
return tip
# One keyspace for id-hits and content-hits, keyed by lineage root;
# first hit wins, and ID matches run first.
seen: dict = {}
def add_lineage_result(raw_sid: str, payload: dict) -> None:
if not raw_sid:
return
root = compression_root(raw_sid)
if root in seen or len(seen) >= safe_limit:
return
payload = dict(payload)
sid = lineage_tip(root)
payload["session_id"] = sid
payload["lineage_root"] = root
try:
row = db.get_session_rich_row(sid)
except Exception:
row = None
if row:
payload.update(
{
"id": row.get("id") or sid,
"source": row.get("source"),
"model": row.get("model"),
"title": row.get("title"),
"started_at": row.get("started_at"),
"ended_at": row.get("ended_at"),
"last_active": row.get("last_active") or row.get("started_at"),
"is_active": (
row.get("ended_at") is None
and (now - (row.get("last_active") or row.get("started_at") or 0)) < 300
),
"message_count": row.get("message_count") or 0,
"tool_call_count": row.get("tool_call_count") or 0,
"input_tokens": row.get("input_tokens") or 0,
"output_tokens": row.get("output_tokens") or 0,
"preview": row.get("preview"),
"parent_session_id": row.get("parent_session_id"),
"archived": bool(row.get("archived")),
}
)
else:
payload["id"] = sid
seen[root] = payload
# Direct ID matches first (pasted ids never appear in message text).
for row in db.search_sessions_by_id(
q,
limit=safe_limit,
include_archived=True,
source=source_filter,
sources=source_list or None,
exclude_sources=exclude_list or None,
):
sid = row.get("id")
preview = (row.get("preview") or "").strip()
snippet = preview or f"Session ID: {sid}"
add_lineage_result(
sid,
{
"snippet": snippet,
"role": None,
"source": row.get("source"),
"model": row.get("model"),
"session_started": row.get("started_at"),
},
)
# Prefix wildcards so partial words match ("nimb" -> "nimb*");
# quoted phrases and existing wildcards are kept as-is.
prefix_query = " ".join(
tok if tok.startswith('"') or tok.endswith("*") else tok + "*"
for tok in re.findall(r'"[^"]*"|\S+', q.strip())
)
# Over-fetch so lineage dedup can still surface `limit` distinct
# conversations when several hits collapse onto one root.
fetch_limit = max(safe_limit * 5, 50)
matches = db.search_messages(
query=prefix_query,
source_filter=include_sources,
exclude_sources=exclude_list or None,
limit=fetch_limit,
fields=(
"session_id",
"role",
"snippet",
"source",
"model",
"session_started",
),
)
for m in matches:
if len(seen) >= safe_limit:
break
add_lineage_result(
m["session_id"],
{
"snippet": m.get("snippet", ""),
"role": m.get("role"),
"source": m.get("source"),
"model": m.get("model"),
"session_started": m.get("session_started"),
},
)
return {"results": list(seen.values())}
finally:
db.close()
@manage_router.post("/api/sessions/bulk-delete")
async def bulk_delete_sessions_endpoint(body: BulkDeleteSessions):
"""Delete every session in ``body.ids`` in one transaction (POST because
many clients refuse a DELETE body).
Contract matches :meth:`SessionDB.delete_sessions`: unknown ids are
skipped (``deleted`` reports what really happened — selection state can
race another tab's delete), children are orphaned not cascaded, active
and archived rows ARE deleted because the user hand-picked them, and
on-disk transcript cleanup is left to the next prune pass.
"""
# Hard cap so a runaway selection can't lock the writer for long; 500
# covers "select all on every page of a reasonable scrollback".
if len(body.ids) > 500:
raise HTTPException(
status_code=400,
detail="ids must contain at most 500 entries",
)
deleted = await asyncio.to_thread(
_with_db, body.profile, lambda db: db.delete_sessions(body.ids), read_only=False
)
return {"ok": True, "deleted": deleted}
@manage_router.post("/api/sessions/import")
async def import_sessions_endpoint(request: Request):
"""Import sessions exported from the dashboard or CLI (session rows only —
``/api/ops/import`` restores a whole backup archive)."""
try:
raw_body = await _read_session_import_body(request)
body = SessionImport.model_validate_json(raw_body)
except HTTPException:
raise
except ValueError as exc:
raise HTTPException(status_code=400, detail="Invalid session import payload") from exc
try:
result = await asyncio.to_thread(_import_sessions_for_profile, body.profile, body.sessions)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if not result.get("ok", False):
raise HTTPException(status_code=400, detail=result)
return result
@manage_router.get("/api/sessions/empty/count")
async def count_empty_sessions_endpoint(profile: Optional[str] = None):
"""Count of empty, ended, non-archived sessions (drives the "Delete empty
(N)" button, hidden when N is 0)."""
count = await asyncio.to_thread(
_with_db, profile, lambda db: db.count_empty_sessions(), read_only=True
)
return {"count": count}
@manage_router.delete("/api/sessions/empty")
async def delete_empty_sessions_endpoint(profile: Optional[str] = None):
"""Delete every empty, ended, non-archived session in one transaction.
Mirrors :meth:`SessionDB.delete_empty_sessions`: "empty" means NO
``messages`` rows at all (a rewound/compacted chat reads
``message_count == 0`` while its soft-archived rows are the only copy of
the transcript); active and archived sessions are skipped; children are
orphaned; on-disk cleanup is left to the next prune pass.
"""
deleted = await asyncio.to_thread(
_with_db, profile, lambda db: db.delete_empty_sessions(), read_only=False
)
return {"ok": True, "deleted": deleted}
@manage_router.get("/api/sessions/stats")
async def get_session_stats(profile: Optional[str] = None):
"""Session-store statistics (mirrors `hermes sessions stats`). Registered
before ``/api/sessions/{session_id}`` so ``stats`` isn't taken as an id."""
def _stats(db):
out = {
"total": db.session_count(include_archived=True),
"active_store": db.session_count(include_archived=False),
"archived": db.session_count(archived_only=True),
"messages": db.message_count(),
"by_source": {},
}
try:
out["by_source"] = db.session_count_by_source(
include_archived=True,
exclude_children=True,
)
except Exception:
pass
return out
return _with_db(profile, _stats, read_only=True)
@manage_router.get("/api/sessions/{session_id}")
async def get_session_detail(session_id: str, profile: Optional[str] = None):
def _detail(db):
sid = _resolve_session_id(db, session_id)
session = db.get_session(sid) if sid else None
if not session:
raise HTTPException(status_code=404, detail="Session not found")
# Always stamp the owner: stamping only on explicit ``?profile=`` left
# default-profile rows unowned, so multi-profile clients resolved them
# to whichever gateway happened to be active.
session["profile"] = _serving_profile(profile)
session["is_default_profile"] = session["profile"] == "default"
return session
return _with_db(profile, _detail, read_only=True)
@manage_router.get("/api/sessions/{session_id}/latest-descendant")
async def get_session_latest_descendant(
session_id: str,
profile: Optional[str] = None,
):
latest, path = await asyncio.to_thread(
_with_db, profile, lambda db: _session_latest_descendant(session_id, db), read_only=True
)
if not latest:
raise HTTPException(status_code=404, detail="Session not found")
return {
"requested_session_id": path[0] if path else session_id,
"session_id": latest,
"path": path,
"changed": bool(path and latest != path[0]),
}
@manage_router.get("/api/sessions/{session_id}/messages")
async def get_session_messages(
session_id: str,
profile: Optional[str] = None,
limit: Optional[int] = Query(None, ge=0),
offset: int = Query(0, ge=0),
order: Optional[str] = Query(None),
include_compacted: bool = Query(False),
):
if order not in (None, "oldest", "latest"):
raise HTTPException(
status_code=400,
detail="order must be one of: oldest, latest",
)
def _read(db):
sid = _resolve_session_id(db, session_id)
if not sid:
return None
sid = db.resolve_resume_session_id(sid)
# Always page: an omitted limit used to load whole transcripts (hundreds
# of thousands of rows for a runaway session). Explicit pagination
# anchors at the start; the default view is the latest page.
default_page = limit is None
latest_page = order == "latest" or (order is None and default_page)
_limit = 500 if default_page else min(limit, 500)
return sid, _limit, db.get_messages(
sid,
limit=_limit,
offset=offset,
latest=latest_page,
include_compacted=include_compacted,
)
result = await asyncio.to_thread(_with_db, profile, _read, read_only=True)
if result is None:
raise HTTPException(status_code=404, detail="Session not found")
sid, _limit, messages = result
from agent.compaction_display import project_compaction_message_for_display
from agent.context_compressor import is_compaction_summary_message
projected_messages = []
for message in messages:
if not is_compaction_summary_message(message):
projected_messages.append(message)
continue
display_view = project_compaction_message_for_display(message)
projected = message.copy()
if display_view is None:
if not projected.get("display_kind"):
projected["display_kind"] = "hidden"
else:
# Keep the physical content for inspection/export compatibility;
# Desktop consumes this display-only projection. A legacy hidden
# wrapper must not hide a successfully recovered live ask.
projected["display_content"] = display_view.get("content")
projected.pop("display_kind", None)
projected_messages.append(projected)
return {
"session_id": sid,
"messages": projected_messages,
"pagination": {
"limit": _limit,
"offset": offset,
"order": order or ("latest" if limit is None else "oldest"),
"returned": len(projected_messages),
},
}
@manage_router.delete("/api/sessions/{session_id}")
async def delete_session_endpoint(session_id: str, profile: Optional[str] = None):
# ``profile`` opens another local profile's state.db directly (remote
# profiles never reach here — the desktop routes those to the remote backend).
def _delete(db):
# An already-absent session is an idempotent success: the desktop
# optimistically removes the row and RESTORES it on any error, so a 404
# here resurrected ghost rows (transient empties from /goal +
# auto-compression churn racing the sidebar snapshot).
sid = _resolve_session_id(db, session_id)
if not sid:
return {"ok": True, "already_absent": True}
db.delete_session(sid)
return {"ok": True}
return await asyncio.to_thread(_with_db, profile, _delete, read_only=False)
@manage_router.post("/api/sessions/owner-backfill")
async def backfill_session_owner_profiles(body: SessionOwnerBackfill):
"""Stamp legacy ``profile_name = NULL`` rows with this store's own
serving-profile identity.
A multi-connection Desktop fails closed on unowned rows, leaving legacy
sessions unresumable. Each ``state.db`` belongs to exactly one profile,
so this is a single-match backfill (the same identity ``get_sessions``
stamps on outgoing rows), idempotent: non-NULL owners are never overwritten.
"""
stamp = _serving_profile(body.profile)
with http_failure("POST /api/sessions/owner-backfill failed", 500, detail="Internal server error"):
stamped = await asyncio.to_thread(
_with_db, body.profile, lambda db: db.backfill_null_session_profiles(stamp), read_only=False
)
if stamped:
_log.info(
"owner-backfill: stamped %d legacy NULL-profile session row(s) with profile %r",
stamped,
stamp,
)
return {"ok": True, "stamped": stamped, "profile": stamp}
@manage_router.patch("/api/sessions/{session_id}")
async def rename_session_endpoint(session_id: str, body: SessionRename):
"""Update a session: ``title`` (empty clears), ``archived``, ``hidden``,
``pinned`` (exempts from the auto-archive sweep), ``unread`` (True =
explicitly unread, False = read up to now). Any field may be omitted."""
flags = ("archived", "hidden", "pinned", "unread")
def _update(db):
sid = _resolve_session_id(db, session_id)
if not sid:
raise HTTPException(status_code=404, detail="Session not found")
if body.title is None and all(getattr(body, f) is None for f in flags):
raise HTTPException(
status_code=400,
detail="Nothing to update; provide 'title', 'archived', 'hidden', 'pinned', and/or 'unread'.",
)
if body.title is not None:
try:
db.set_session_title(sid, body.title or "")
except ValueError as e:
# Title too long, invalid characters, or already in use.
raise HTTPException(status_code=400, detail=str(e))
if body.archived is not None:
db.set_session_archived(sid, body.archived)
if body.hidden is not None:
db.set_session_hidden(sid, body.hidden)
if body.pinned is not None:
db.set_session_pinned(sid, body.pinned)
if body.unread is not None:
db.set_session_read(sid, read=not body.unread)
result = {"ok": True, "title": db.get_session_title(sid) or ""}
for f in flags:
if getattr(body, f) is not None:
result[f] = bool(getattr(body, f))
return result
return _with_db(body.profile, _update, read_only=False)
@manage_router.get("/api/sessions/{session_id}/export")
async def export_session_endpoint(session_id: str, profile: Optional[str] = None):
"""Stream a single session (metadata + messages) as JSON."""
def _prepare_export(db):
sid = _resolve_session_id(db, session_id)
return (sid, db.get_session(sid)) if sid else None
prepared = await asyncio.to_thread(_with_db, profile, _prepare_export, read_only=True)
if prepared is None or prepared[1] is None:
raise HTTPException(status_code=404, detail="Session not found")
sid, session = prepared
def _stream_export():
db = _open_session_db_for_profile(profile, read_only=True)
try:
metadata = json.dumps(
jsonable_encoder(session),
ensure_ascii=False,
separators=(",", ":"),
)
yield metadata[:-1] + ',"messages":['
# Keyset pagination (id > last_seen): O(n) total over the
# transcript, vs OFFSET's O(n²) on huge sessions.
last_id = None
first = True
while True:
messages = db.get_messages(
sid,
limit=500,
after_id=last_id if last_id is not None else 0,
)
for message in messages:
if not first:
yield ","
yield json.dumps(
jsonable_encoder(message),
ensure_ascii=False,
separators=(",", ":"),
)
first = False
if len(messages) < 500:
break
last_id = messages[-1].get("id")
if last_id is None:
break # defensive: cannot keyset without row ids
yield "]}"
finally:
db.close()
return StreamingResponse(
_stream_export(),
media_type="application/json",
)
@manage_router.post("/api/sessions/prune")
async def prune_sessions_endpoint(body: SessionPrune):
"""Delete ended sessions matching filters without blocking the event loop."""
return await asyncio.to_thread(_prune_sessions, body)