hermes-agent/hermes_cli/web_routers/profiles.py

683 lines
28 KiB
Python

"""Profiles dashboard routes (extracted verbatim from web_server.py).
Two routers because the original registration points are far apart and route
order matters: ``sessions_router`` (/api/profiles/sessions*) was registered
long before the generic ``/api/profiles/{name}`` routes on ``router`` — if the
literal-path routes were appended after ``{name}`` in one router, Starlette
would still match literals first here, but we preserve the original global
registration order exactly rather than rely on that.
Handler bodies are byte-identical; web_server-owned helpers are reached via the
late-binding seam in :mod:`hermes_cli.web_deps` so tests that
``monkeypatch.setattr(web_server, "_helper", ...)`` keep working.
"""
import asyncio # noqa: F401 — used by handlers
import logging
import subprocess # noqa: F401
import sys # noqa: F401
import time # noqa: F401
from pathlib import Path # noqa: F401
from typing import Any, Dict, List, Optional, Tuple # noqa: F401
from fastapi import APIRouter, HTTPException # noqa: F401
from hermes_cli.web_deps import late
from hermes_cli.web_models import (
ProfileCreate,
ProfileActiveUpdate,
ProfileRename,
ProfileSoulUpdate,
ProfileDescriptionUpdate,
ProfileModelUpdate,
ProfileDescribeAuto,
)
# Same logger the handlers used before extraction (identical logger object).
_log = logging.getLogger("hermes_cli.web_server")
sessions_router = APIRouter()
router = APIRouter()
# Late-bound web_server helpers (resolved at call time; cycle-safe,
# monkeypatch-transparent).
_cron_profile_home = late("_cron_profile_home")
_disable_unselected_skills = late("_disable_unselected_skills")
_fallback_profile_dicts = late("_fallback_profile_dicts")
_hub_action_name = late("_hub_action_name")
_profile_setup_command = late("_profile_setup_command")
_profile_to_dict = late("_profile_to_dict")
_resolve_profile_dir = late("_resolve_profile_dir")
_spawn_hermes_action = late("_spawn_hermes_action")
_strip_session_list_rows = late("_strip_session_list_rows")
_write_profile_mcp_servers = late("_write_profile_mcp_servers")
_write_profile_model = late("_write_profile_model")
@sessions_router.get("/api/profiles/sessions")
def get_profiles_sessions(
limit: int = 20,
offset: int = 0,
min_messages: int = 0,
archived: str = "exclude",
order: str = "recent",
profile: str = "all",
source: str = None,
sources: str = None,
exclude_sources: str = None,
full: bool = False,
):
"""Unified, read-only session list aggregated across ALL profiles.
Intentionally process-light: this opens each profile's ``state.db`` directly
from disk — it does NOT spawn a dashboard backend per profile. Each returned
session is tagged with its owning ``profile`` so the desktop renders one
browsable list and only spins up a profile's backend when the user actually
interacts (sends a message). A user with a single (default) profile gets the
same rows as ``/api/sessions``, just tagged ``profile="default"``.
Rows omit ``system_prompt``/``model_config`` unless ``full=1`` — same
list projection as ``/api/sessions``.
"""
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")
from hermes_state import SessionDB
from hermes_cli import profiles as profiles_mod
targets: List[Tuple[str, Path]] = []
if profile and profile != "all":
name, home = _cron_profile_home(profile)
targets.append((name, home))
else:
try:
infos = profiles_mod.list_profiles()
targets = [(info.name, info.path) for info in infos]
except Exception:
_log.exception("GET /api/profiles/sessions: list_profiles failed")
targets = []
if not targets:
targets.append(("default", profiles_mod.get_profile_dir("default")))
min_message_count = max(0, min_messages)
archived_only = archived == "only"
include_archived = archived == "include"
# Source scoping (see /api/sessions): recents pass exclude_sources=cron,
# the cron-jobs section passes source=cron — two independent lists so
# newest cron sessions can't starve the recents page.
source_filter = source or None
source_list = [s.strip() for s in (sources or "").split(",") if s.strip()]
exclude_list = [s.strip() for s in (exclude_sources or "").split(",") if s.strip()]
# Over-fetch per profile so the merged+sorted window is correct for the
# requested page. Capped so a huge profile can't blow up the response.
per_profile = min(max(limit + offset, limit), 500)
merged: List[Dict[str, Any]] = []
total = 0
profile_totals: Dict[str, int] = {}
errors: List[Dict[str, str]] = []
now = time.time()
for name, home in targets:
db_path = Path(home) / "state.db"
if not db_path.exists():
continue
try:
# Read-only: this loop runs on every sidebar refresh, so it must
# never DDL/write-lock another profile's live DB (see SessionDB
# read_only docstring).
db = SessionDB(db_path=db_path, read_only=True)
except Exception as exc:
errors.append({"profile": name, "error": str(exc)})
continue
try:
rows = db.list_sessions_rich(
source=source_filter,
sources=source_list or None,
exclude_sources=exclude_list or None,
limit=per_profile,
offset=0,
min_message_count=min_message_count,
include_archived=include_archived,
archived_only=archived_only,
order_by_last_active=order == "recent",
# Same SQL-level blob skip as /api/sessions (see above).
compact_rows=not full,
include_pinned=True,
)
profile_total = db.session_count(
source=source_filter,
sources=source_list 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,
)
total += profile_total
profile_totals[name] = profile_total
for s in rows:
s["profile"] = name
s["is_default_profile"] = name == "default"
s["is_active"] = (
s.get("ended_at") is None
and (now - s.get("last_active", s.get("started_at", 0))) < 300
)
s["archived"] = bool(s.get("archived"))
s["pinned"] = bool(s.get("pinned"))
merged.append(s)
except Exception as exc:
errors.append({"profile": name, "error": str(exc)})
finally:
db.close()
sort_key = "last_active" if order == "recent" else "started_at"
merged.sort(key=lambda s: s.get(sort_key) or s.get("started_at") or 0, reverse=True)
# Pinned rows are back-filled past each profile's LIMIT on purpose; keep
# them in the merged window instead of re-dropping them on recency.
window = merged[offset:offset + limit]
if len(merged) > offset + limit:
seen = {id(s) for s in window}
window.extend(s for s in merged[offset + limit:] if s.get("pinned") and id(s) not in seen)
if not full:
_strip_session_list_rows(window)
return {
"sessions": window,
"total": total,
"profile_totals": profile_totals,
"limit": limit,
"offset": offset,
"errors": errors,
}
@sessions_router.get("/api/profiles/sessions/sidebar")
def get_profiles_sessions_sidebar(
recents_profile: str = "all",
recents_limit: int = 20,
recents_exclude: str = None,
cron_limit: int = 50,
messaging_limit: int = 100,
messaging_exclude: str = None,
):
"""Batched sidebar session slices — one profile-DB open per refresh.
The desktop sidebar needs three source-scoped windows per refresh: recents
(local chats, scoped to the active profile), cron sessions (all profiles),
and messaging-platform sessions (all profiles). Served as three separate
``/api/profiles/sessions`` calls they reopened every profile's ``state.db``
three times and re-counted each refresh. This opens each DB once and runs
the three filtered queries together, returning the three windows in one
payload. Read-only and process-light, same row projection and 300s active
heuristic as ``/api/profiles/sessions``.
The caller passes the source taxonomy (``recents_exclude`` /
``messaging_exclude`` CSV, ``source=cron`` is implicit) so this stays
taxonomy-agnostic like the per-slice endpoint. All three slices use
``min_messages=1`` / ``archived=exclude`` / recency order, matching the
desktop's per-slice calls.
"""
from hermes_state import SessionDB
from hermes_cli import profiles as profiles_mod
# cron + messaging are cross-profile; recents is scoped to recents_profile.
# Scan every profile once regardless (each DB opened a single time).
try:
infos = profiles_mod.list_profiles()
targets: List[Tuple[str, Path]] = [(info.name, info.path) for info in infos]
except Exception:
_log.exception("GET /api/profiles/sessions/sidebar: list_profiles failed")
targets = []
if not targets:
targets.append(("default", profiles_mod.get_profile_dir("default")))
recents_scope = (recents_profile or "all").strip() or "all"
recents_exclude_list = [s for s in (recents_exclude or "").split(",") if s.strip()]
messaging_exclude_list = [s for s in (messaging_exclude or "").split(",") if s.strip()]
recents_cap = min(max(recents_limit, 1), 500)
cron_cap = min(max(cron_limit, 1), 500)
messaging_cap = min(max(messaging_limit, 1), 500)
recents_rows: List[Dict[str, Any]] = []
cron_rows: List[Dict[str, Any]] = []
messaging_rows: List[Dict[str, Any]] = []
recents_truncated: Dict[str, bool] = {}
errors: List[Dict[str, str]] = []
now = time.time()
def _tag(rows: List[Dict[str, Any]], name: str) -> List[Dict[str, Any]]:
for s in rows:
s["profile"] = name
s["is_default_profile"] = name == "default"
s["is_active"] = (
s.get("ended_at") is None
and (now - s.get("last_active", s.get("started_at", 0))) < 300
)
s["archived"] = bool(s.get("archived"))
# SQLite stores the pin as 0/1; the sidebar needs a real boolean to
# render the Pinned section from server state.
s["pinned"] = bool(s.get("pinned"))
return rows
def _slice(db, *, source=None, exclude=None, cap):
return db.list_sessions_rich(
source=source,
exclude_sources=exclude or None,
limit=cap,
offset=0,
min_message_count=1,
include_archived=False,
archived_only=False,
order_by_last_active=True,
compact_rows=True,
# A pinned conversation must reach the sidebar even when it has
# aged past the window — otherwise its Pinned row renders empty.
include_pinned=True,
)
for name, home in targets:
db_path = Path(home) / "state.db"
if not db_path.exists():
continue
try:
db = SessionDB(db_path=db_path, read_only=True)
except Exception as exc:
errors.append({"profile": name, "error": str(exc)})
continue
try:
if recents_scope == "all" or name == recents_scope:
profile_rows = _slice(db, exclude=recents_exclude_list, cap=recents_cap)
# A full window means more rows remain on disk. That is all the
# sidebar's "load more" needs, and unlike an exact COUNT(*) per
# profile per refresh it costs nothing beyond the rows already
# read. Discount pinned back-fills — they arrive past the LIMIT
# and would otherwise fake a full page on a short list.
unpinned_count = sum(1 for s in profile_rows if not s.get("pinned"))
recents_truncated[name] = unpinned_count >= recents_cap
recents_rows.extend(_tag(profile_rows, name))
cron_rows.extend(_tag(_slice(db, source="cron", cap=cron_cap), name))
messaging_rows.extend(
_tag(_slice(db, exclude=messaging_exclude_list, cap=messaging_cap), name)
)
except Exception as exc:
errors.append({"profile": name, "error": str(exc)})
finally:
db.close()
def _window(rows: List[Dict[str, Any]], cap: int) -> List[Dict[str, Any]]:
rows.sort(key=lambda s: s.get("last_active") or s.get("started_at") or 0, reverse=True)
# Pinned rows survive the cap. The per-profile queries deliberately
# back-fill them past the LIMIT, so truncating the merged window on
# recency alone would throw away exactly what the back-fill fetched.
win = rows[:cap]
if len(rows) > cap:
seen = {id(s) for s in win}
win.extend(s for s in rows[cap:] if s.get("pinned") and id(s) not in seen)
_strip_session_list_rows(win)
return win
return {
"recents": {
"sessions": _window(recents_rows, recents_cap),
"profiles_truncated": recents_truncated,
},
"cron": {"sessions": _window(cron_rows, cron_cap)},
"messaging": {
"sessions": _window(messaging_rows, messaging_cap),
"total": len(messaging_rows),
},
"errors": errors,
}
@router.get("/api/profiles")
async def list_profiles_endpoint():
from hermes_cli import profiles as profiles_mod
try:
loop = asyncio.get_running_loop()
profiles = await loop.run_in_executor(None, profiles_mod.list_profiles)
return {"profiles": [_profile_to_dict(p) for p in profiles]}
except Exception:
_log.exception("GET /api/profiles failed; falling back to profile directory scan")
return {"profiles": _fallback_profile_dicts(profiles_mod)}
@router.post("/api/profiles")
async def create_profile_endpoint(body: ProfileCreate):
from hermes_cli import profiles as profiles_mod
explicit_source = (body.clone_from or "").strip()
if explicit_source:
# Duplicating a specific profile: clone its config/skills/SOUL (or full
# state when clone_all) from the named source rather than "default".
clone = True
clone_from = explicit_source
clone_config = not body.clone_all
elif body.clone_all:
# Preserve the dashboard's historical clone-all behavior: a full-copy
# request with no explicit dropdown source copies from default.
clone = True
clone_from = "default"
clone_config = False
else:
clone = body.clone_from_default
clone_from = "default" if clone else None
clone_config = clone
try:
path = profiles_mod.create_profile(
name=body.name,
clone_from=clone_from,
clone_all=body.clone_all,
clone_config=clone_config,
no_skills=body.no_skills,
description=body.description,
)
# Match the CLI's profile-create flow: fresh named profiles get the
# bundled skills installed. When cloning from default, create_profile()
# has already copied the source profile's skills, including any
# user-installed skills. When no_skills=True, create_profile() wrote
# the opt-out marker and seed_profile_skills() will no-op.
if not clone:
profiles_mod.seed_profile_skills(path, quiet=True)
# Match the CLI's profile-create flow: named profiles should get a
# wrapper in ~/.local/bin when the alias is safe to create.
collision = profiles_mod.check_alias_collision(body.name)
if not collision:
profiles_mod.create_wrapper_script(body.name)
except (ValueError, FileExistsError, FileNotFoundError) as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
_log.exception("POST /api/profiles failed")
raise HTTPException(status_code=500, detail=str(e))
# Optional explicit model assignment for the new profile. Best-effort:
# the profile already exists, so a model-write hiccup must not 500 the
# whole create — the user can set the model later from the Models page
# or `<profile> setup`.
provider = (body.provider or "").strip()
model = (body.model or "").strip()
model_set = False
if provider and model:
try:
_write_profile_model(path, provider, model)
model_set = True
except Exception:
_log.exception("Setting model for new profile %s failed", body.name)
# Optional MCP servers. Best-effort, same rationale as model assignment.
mcp_written = 0
if body.mcp_servers:
try:
mcp_written = _write_profile_mcp_servers(path, body.mcp_servers)
except Exception:
_log.exception("Writing MCP servers for new profile %s failed", body.name)
# Optional "keep" skill selection — replace semantics. When the builder
# sends an explicit keep list, disable every seeded skill not in it.
# Best-effort. Skipped when keep_skills is empty (legacy: keep the bundle).
skills_disabled = 0
if body.keep_skills:
try:
skills_disabled = _disable_unselected_skills(path, body.keep_skills)
except Exception:
_log.exception("Applying skill selection for new profile %s failed", body.name)
# Optional skills-hub installs. Spawned async, scoped to the new profile
# via `-p <name>` (a fresh subprocess re-binds skills_hub.SKILLS_DIR to the
# profile's HERMES_HOME at import). Returns PIDs for the UI to poll.
hub_installs: List[Dict[str, Any]] = []
for identifier in body.hub_skills:
ident = (identifier or "").strip()
if not ident:
continue
try:
proc = _spawn_hermes_action(
["-p", body.name, "skills", "install", ident, "--yes"],
_hub_action_name("install", ident),
)
hub_installs.append({"identifier": ident, "pid": proc.pid})
except Exception:
_log.exception(
"Spawning hub-skill install %s for new profile %s failed",
ident,
body.name,
)
hub_installs.append({"identifier": ident, "pid": None})
return {
"ok": True,
"name": body.name,
"path": str(path),
"model_set": model_set,
"mcp_written": mcp_written,
"skills_disabled": skills_disabled,
"hub_installs": hub_installs,
}
@router.get("/api/profiles/active")
async def get_active_profile_endpoint():
"""Return the sticky active profile and the profile this dashboard
process is currently running as.
``active`` is the sticky default written by ``hermes profile use`` —
the profile new CLI invocations pick up. ``current`` is the profile
the running dashboard/gateway is scoped to (derived from HERMES_HOME).
"""
from hermes_cli import profiles as profiles_mod
try:
active = profiles_mod.get_active_profile() or "default"
except Exception:
active = "default"
try:
current = profiles_mod.get_active_profile_name() or "default"
except Exception:
current = "default"
return {"active": active, "current": current}
@router.post("/api/profiles/active")
async def set_active_profile_endpoint(body: ProfileActiveUpdate):
"""Set the sticky active profile (mirrors ``hermes profile use``).
Note: this does not retarget the already-running dashboard process —
it changes which profile subsequent CLI commands and gateways use.
"""
from hermes_cli import profiles as profiles_mod
try:
profiles_mod.set_active_profile(body.name)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
_log.exception("POST /api/profiles/active failed")
raise HTTPException(status_code=500, detail=str(e))
return {"ok": True, "active": profiles_mod.normalize_profile_name(body.name)}
@router.get("/api/profiles/{name}/setup-command")
async def get_profile_setup_command(name: str):
return {"command": _profile_setup_command(name)}
@router.post("/api/profiles/{name}/open-terminal")
async def open_profile_terminal_endpoint(name: str):
try:
command = _profile_setup_command(name)
if sys.platform.startswith("win"):
subprocess.Popen(["cmd.exe", "/c", "start", "", command])
elif sys.platform == "darwin":
escaped = command.replace("\\", "\\\\").replace('"', '\\"')
applescript = (
'tell application "Terminal"\n'
"activate\n"
f'do script "{escaped}"\n'
"end tell"
)
subprocess.Popen(["osascript", "-e", applescript])
else:
terminal_commands = [
("x-terminal-emulator", ["x-terminal-emulator", "-e", "sh", "-lc", command]),
("gnome-terminal", ["gnome-terminal", "--", "sh", "-lc", command]),
("konsole", ["konsole", "-e", "sh", "-lc", command]),
("xfce4-terminal", ["xfce4-terminal", "-e", f"sh -lc '{command}'"]),
("mate-terminal", ["mate-terminal", "-e", f"sh -lc '{command}'"]),
("lxterminal", ["lxterminal", "-e", f"sh -lc '{command}'"]),
("tilix", ["tilix", "-e", "sh", "-lc", command]),
("alacritty", ["alacritty", "-e", "sh", "-lc", command]),
("kitty", ["kitty", "sh", "-lc", command]),
("xterm", ["xterm", "-e", "sh", "-lc", command]),
]
for executable, popen_args in terminal_commands:
if subprocess.call(
["which", executable],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
) == 0:
subprocess.Popen(popen_args)
break
else:
raise HTTPException(
status_code=400,
detail="No supported terminal emulator found",
)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except HTTPException:
raise
except Exception as e:
_log.exception("POST /api/profiles/%s/open-terminal failed", name)
raise HTTPException(status_code=500, detail=str(e))
return {"ok": True, "command": command}
@router.patch("/api/profiles/{name}")
async def rename_profile_endpoint(name: str, body: ProfileRename):
from hermes_cli import profiles as profiles_mod
try:
path = profiles_mod.rename_profile(name, body.new_name)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except (ValueError, FileExistsError) as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
_log.exception("PATCH /api/profiles/%s failed", name)
raise HTTPException(status_code=500, detail=str(e))
return {"ok": True, "name": body.new_name, "path": str(path)}
@router.delete("/api/profiles/{name}")
async def delete_profile_endpoint(name: str):
"""Delete a profile. The dashboard collects the user's confirmation in
its own dialog before this request, so we always pass ``yes=True`` to
skip the CLI's interactive prompt."""
from hermes_cli import profiles as profiles_mod
try:
path = profiles_mod.delete_profile(name, yes=True)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
_log.exception("DELETE /api/profiles/%s failed", name)
raise HTTPException(status_code=500, detail=str(e))
return {"ok": True, "path": str(path)}
@router.get("/api/profiles/{name}/soul")
async def get_profile_soul(name: str):
soul_path = _resolve_profile_dir(name) / "SOUL.md"
if soul_path.exists():
try:
return {"content": soul_path.read_text(encoding="utf-8"), "exists": True}
except OSError as e:
raise HTTPException(status_code=500, detail=f"Could not read SOUL.md: {e}")
return {"content": "", "exists": False}
@router.put("/api/profiles/{name}/soul")
async def update_profile_soul(name: str, body: ProfileSoulUpdate):
soul_path = _resolve_profile_dir(name) / "SOUL.md"
try:
soul_path.write_text(body.content, encoding="utf-8")
except OSError as e:
_log.exception("PUT /api/profiles/%s/soul failed", name)
raise HTTPException(status_code=500, detail=f"Could not write SOUL.md: {e}")
return {"ok": True}
@router.put("/api/profiles/{name}/description")
async def update_profile_description_endpoint(name: str, body: ProfileDescriptionUpdate):
"""Set or clear a profile's role description (kanban routing signal).
Empty string clears the description. Non-empty stores it as a
user-authored description (``description_auto: false``) so the
auto-describer won't overwrite it on a sweep.
"""
from hermes_cli import profiles as profiles_mod
profile_dir = _resolve_profile_dir(name)
text = (body.description or "").strip()
try:
profiles_mod.write_profile_meta(
profile_dir,
description=text,
description_auto=False,
)
except Exception as e:
_log.exception("PUT /api/profiles/%s/description failed", name)
raise HTTPException(status_code=500, detail=str(e))
return {"ok": True, "description": text, "description_auto": False}
@router.put("/api/profiles/{name}/model")
async def update_profile_model_endpoint(name: str, body: ProfileModelUpdate):
"""Set the main model (``model.default`` + ``model.provider``) for a
specific profile's config.yaml, without touching the dashboard's own
active profile. Mirrors ``POST /api/model/set`` (main scope) but scoped
to the named profile via the HERMES_HOME override.
"""
profile_dir = _resolve_profile_dir(name)
provider = (body.provider or "").strip()
model = (body.model or "").strip()
if not provider or not model:
raise HTTPException(status_code=400, detail="provider and model are required")
try:
_write_profile_model(profile_dir, provider, model)
except Exception as e:
_log.exception("PUT /api/profiles/%s/model failed", name)
raise HTTPException(status_code=500, detail=str(e))
return {"ok": True, "provider": provider, "model": model}
@router.post("/api/profiles/{name}/describe-auto")
async def describe_profile_auto_endpoint(name: str, body: ProfileDescribeAuto):
"""Auto-generate a profile's description via the auxiliary LLM
(``auxiliary.profile_describer``). Mirrors ``hermes profile describe
<name> --auto``.
A failed generation (no aux client, LLM error, …) is returned as
``ok: false`` with a reason rather than an HTTP error so the UI can
surface it inline and let the operator fix config and retry.
"""
_resolve_profile_dir(name)
try:
from hermes_cli import profile_describer
outcome = profile_describer.describe_profile(name, overwrite=bool(body.overwrite))
except Exception as e:
_log.exception("POST /api/profiles/%s/describe-auto failed", name)
raise HTTPException(status_code=500, detail=str(e))
return {
"ok": bool(outcome.ok),
"reason": outcome.reason,
"description": outcome.description,
# Only a successful generation is an auto-authored description. A failed
# sweep leaves any existing description untouched, so don't claim it's
# auto-generated.
"description_auto": bool(outcome.ok),
}