Merge pull request #720 from NousResearch/feat/session-naming
feat: Session naming with unique titles, auto-lineage & rich listing
This commit is contained in:
commit
c5e8166c8b
14 changed files with 1261 additions and 49 deletions
|
|
@ -58,6 +58,7 @@ hermes-agent/
|
||||||
├── skills/ # Bundled skill sources
|
├── skills/ # Bundled skill sources
|
||||||
├── optional-skills/ # Official optional skills (not activated by default)
|
├── optional-skills/ # Official optional skills (not activated by default)
|
||||||
├── cli.py # Interactive CLI orchestrator (HermesCLI class)
|
├── cli.py # Interactive CLI orchestrator (HermesCLI class)
|
||||||
|
├── hermes_state.py # SessionDB — SQLite session store (schema, titles, FTS5 search)
|
||||||
├── run_agent.py # AIAgent class (core conversation loop)
|
├── run_agent.py # AIAgent class (core conversation loop)
|
||||||
├── model_tools.py # Tool orchestration (thin layer over tools/registry.py)
|
├── model_tools.py # Tool orchestration (thin layer over tools/registry.py)
|
||||||
├── toolsets.py # Tool groupings
|
├── toolsets.py # Tool groupings
|
||||||
|
|
@ -226,6 +227,9 @@ The unified `hermes` command provides all functionality:
|
||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| `hermes` | Interactive chat (default) |
|
| `hermes` | Interactive chat (default) |
|
||||||
| `hermes chat -q "..."` | Single query mode |
|
| `hermes chat -q "..."` | Single query mode |
|
||||||
|
| `hermes -c` / `hermes --continue` | Resume the most recent session |
|
||||||
|
| `hermes -c "my project"` | Resume a session by name (latest in lineage) |
|
||||||
|
| `hermes --resume <session_id>` | Resume a specific session by ID or title |
|
||||||
| `hermes -w` / `hermes --worktree` | Start in isolated git worktree (for parallel agents) |
|
| `hermes -w` / `hermes --worktree` | Start in isolated git worktree (for parallel agents) |
|
||||||
| `hermes setup` | Configure API keys and settings |
|
| `hermes setup` | Configure API keys and settings |
|
||||||
| `hermes config` | View current configuration |
|
| `hermes config` | View current configuration |
|
||||||
|
|
@ -240,6 +244,8 @@ The unified `hermes` command provides all functionality:
|
||||||
| `hermes gateway` | Start gateway (messaging + cron scheduler) |
|
| `hermes gateway` | Start gateway (messaging + cron scheduler) |
|
||||||
| `hermes gateway setup` | Configure messaging platforms interactively |
|
| `hermes gateway setup` | Configure messaging platforms interactively |
|
||||||
| `hermes gateway install` | Install gateway as system service |
|
| `hermes gateway install` | Install gateway as system service |
|
||||||
|
| `hermes sessions list` | List past sessions (title, preview, last active) |
|
||||||
|
| `hermes sessions rename <id> <title>` | Rename/title a session |
|
||||||
| `hermes cron list` | View scheduled jobs |
|
| `hermes cron list` | View scheduled jobs |
|
||||||
| `hermes cron status` | Check if cron scheduler is running |
|
| `hermes cron status` | Check if cron scheduler is running |
|
||||||
| `hermes version` | Show version info |
|
| `hermes version` | Show version info |
|
||||||
|
|
|
||||||
|
|
@ -118,7 +118,7 @@ hermes-agent/
|
||||||
├── cli.py # HermesCLI class — interactive TUI, prompt_toolkit integration
|
├── cli.py # HermesCLI class — interactive TUI, prompt_toolkit integration
|
||||||
├── model_tools.py # Tool orchestration (thin layer over tools/registry.py)
|
├── model_tools.py # Tool orchestration (thin layer over tools/registry.py)
|
||||||
├── toolsets.py # Tool groupings and presets (hermes-cli, hermes-telegram, etc.)
|
├── toolsets.py # Tool groupings and presets (hermes-cli, hermes-telegram, etc.)
|
||||||
├── hermes_state.py # SQLite session database with FTS5 full-text search
|
├── hermes_state.py # SQLite session database with FTS5 full-text search, session titles
|
||||||
├── batch_runner.py # Parallel batch processing for trajectory generation
|
├── batch_runner.py # Parallel batch processing for trajectory generation
|
||||||
│
|
│
|
||||||
├── agent/ # Agent internals (extracted modules)
|
├── agent/ # Agent internals (extracted modules)
|
||||||
|
|
@ -218,7 +218,7 @@ User message → AIAgent._run_agent_loop()
|
||||||
|
|
||||||
- **Self-registering tools**: Each tool file calls `registry.register()` at import time. `model_tools.py` triggers discovery by importing all tool modules.
|
- **Self-registering tools**: Each tool file calls `registry.register()` at import time. `model_tools.py` triggers discovery by importing all tool modules.
|
||||||
- **Toolset grouping**: Tools are grouped into toolsets (`web`, `terminal`, `file`, `browser`, etc.) that can be enabled/disabled per platform.
|
- **Toolset grouping**: Tools are grouped into toolsets (`web`, `terminal`, `file`, `browser`, etc.) that can be enabled/disabled per platform.
|
||||||
- **Session persistence**: All conversations are stored in SQLite (`hermes_state.py`) with full-text search. JSON logs go to `~/.hermes/sessions/`.
|
- **Session persistence**: All conversations are stored in SQLite (`hermes_state.py`) with full-text search and unique session titles. JSON logs go to `~/.hermes/sessions/`.
|
||||||
- **Ephemeral injection**: System prompts and prefill messages are injected at API call time, never persisted to the database or logs.
|
- **Ephemeral injection**: System prompts and prefill messages are injected at API call time, never persisted to the database or logs.
|
||||||
- **Provider abstraction**: The agent works with any OpenAI-compatible API. Provider resolution happens at init time (Nous Portal OAuth, OpenRouter API key, or custom endpoint).
|
- **Provider abstraction**: The agent works with any OpenAI-compatible API. Provider resolution happens at init time (Nous Portal OAuth, OpenRouter API key, or custom endpoint).
|
||||||
- **Provider routing**: When using OpenRouter, `provider_routing` in config.yaml controls provider selection (sort by throughput/latency/price, allow/ignore specific providers, data retention policies). These are injected as `extra_body.provider` in API requests.
|
- **Provider routing**: When using OpenRouter, `provider_routing` in config.yaml controls provider selection (sort by throughput/latency/price, allow/ignore specific providers, data retention policies). These are injected as `extra_body.provider` in API requests.
|
||||||
|
|
|
||||||
87
cli.py
87
cli.py
|
|
@ -1094,6 +1094,16 @@ class HermesCLI:
|
||||||
self.conversation_history: List[Dict[str, Any]] = []
|
self.conversation_history: List[Dict[str, Any]] = []
|
||||||
self.session_start = datetime.now()
|
self.session_start = datetime.now()
|
||||||
self._resumed = False
|
self._resumed = False
|
||||||
|
# Initialize SQLite session store early so /title works before first message
|
||||||
|
self._session_db = None
|
||||||
|
try:
|
||||||
|
from hermes_state import SessionDB
|
||||||
|
self._session_db = SessionDB()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Deferred title: stored in memory until the session is created in the DB
|
||||||
|
self._pending_title: Optional[str] = None
|
||||||
|
|
||||||
# Session ID: reuse existing one when resuming, otherwise generate fresh
|
# Session ID: reuse existing one when resuming, otherwise generate fresh
|
||||||
if resume:
|
if resume:
|
||||||
|
|
@ -1181,13 +1191,13 @@ class HermesCLI:
|
||||||
if not self._ensure_runtime_credentials():
|
if not self._ensure_runtime_credentials():
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Initialize SQLite session store for CLI sessions
|
# Initialize SQLite session store for CLI sessions (if not already done in __init__)
|
||||||
self._session_db = None
|
if self._session_db is None:
|
||||||
try:
|
try:
|
||||||
from hermes_state import SessionDB
|
from hermes_state import SessionDB
|
||||||
self._session_db = SessionDB()
|
self._session_db = SessionDB()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("SQLite session store not available: %s", e)
|
logger.debug("SQLite session store not available: %s", e)
|
||||||
|
|
||||||
# If resuming, validate the session exists and load its history
|
# If resuming, validate the session exists and load its history
|
||||||
if self._resumed and self._session_db:
|
if self._resumed and self._session_db:
|
||||||
|
|
@ -1200,8 +1210,11 @@ class HermesCLI:
|
||||||
if restored:
|
if restored:
|
||||||
self.conversation_history = restored
|
self.conversation_history = restored
|
||||||
msg_count = len([m for m in restored if m.get("role") == "user"])
|
msg_count = len([m for m in restored if m.get("role") == "user"])
|
||||||
|
title_part = ""
|
||||||
|
if session_meta.get("title"):
|
||||||
|
title_part = f" \"{session_meta['title']}\""
|
||||||
_cprint(
|
_cprint(
|
||||||
f"{_GOLD}↻ Resumed session {_BOLD}{self.session_id}{_RST}{_GOLD} "
|
f"{_GOLD}↻ Resumed session {_BOLD}{self.session_id}{_RST}{_GOLD}{title_part} "
|
||||||
f"({msg_count} user message{'s' if msg_count != 1 else ''}, "
|
f"({msg_count} user message{'s' if msg_count != 1 else ''}, "
|
||||||
f"{len(restored)} total messages){_RST}"
|
f"{len(restored)} total messages){_RST}"
|
||||||
)
|
)
|
||||||
|
|
@ -1243,6 +1256,15 @@ class HermesCLI:
|
||||||
clarify_callback=self._clarify_callback,
|
clarify_callback=self._clarify_callback,
|
||||||
honcho_session_key=self.session_id,
|
honcho_session_key=self.session_id,
|
||||||
)
|
)
|
||||||
|
# Apply any pending title now that the session exists in the DB
|
||||||
|
if self._pending_title and self._session_db:
|
||||||
|
try:
|
||||||
|
self._session_db.set_session_title(self.session_id, self._pending_title)
|
||||||
|
_cprint(f" Session title applied: {self._pending_title}")
|
||||||
|
self._pending_title = None
|
||||||
|
except (ValueError, Exception) as e:
|
||||||
|
_cprint(f" Could not apply pending title: {e}")
|
||||||
|
self._pending_title = None
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.console.print(f"[bold red]Failed to initialize agent: {e}[/]")
|
self.console.print(f"[bold red]Failed to initialize agent: {e}[/]")
|
||||||
|
|
@ -2091,6 +2113,55 @@ class HermesCLI:
|
||||||
print(" ✨ (◕‿◕)✨ Fresh start! Screen cleared and conversation reset.\n")
|
print(" ✨ (◕‿◕)✨ Fresh start! Screen cleared and conversation reset.\n")
|
||||||
elif cmd_lower == "/history":
|
elif cmd_lower == "/history":
|
||||||
self.show_history()
|
self.show_history()
|
||||||
|
elif cmd_lower.startswith("/title"):
|
||||||
|
parts = cmd_original.split(maxsplit=1)
|
||||||
|
if len(parts) > 1:
|
||||||
|
raw_title = parts[1].strip()
|
||||||
|
if raw_title:
|
||||||
|
if self._session_db:
|
||||||
|
# Sanitize the title early so feedback matches what gets stored
|
||||||
|
try:
|
||||||
|
from hermes_state import SessionDB
|
||||||
|
new_title = SessionDB.sanitize_title(raw_title)
|
||||||
|
except ValueError as e:
|
||||||
|
_cprint(f" {e}")
|
||||||
|
new_title = None
|
||||||
|
if not new_title:
|
||||||
|
_cprint(" Title is empty after cleanup. Please use printable characters.")
|
||||||
|
elif self._session_db.get_session(self.session_id):
|
||||||
|
# Session exists in DB — set title directly
|
||||||
|
try:
|
||||||
|
if self._session_db.set_session_title(self.session_id, new_title):
|
||||||
|
_cprint(f" Session title set: {new_title}")
|
||||||
|
else:
|
||||||
|
_cprint(" Session not found in database.")
|
||||||
|
except ValueError as e:
|
||||||
|
_cprint(f" {e}")
|
||||||
|
else:
|
||||||
|
# Session not created yet — defer the title
|
||||||
|
# Check uniqueness proactively with the sanitized title
|
||||||
|
existing = self._session_db.get_session_by_title(new_title)
|
||||||
|
if existing:
|
||||||
|
_cprint(f" Title '{new_title}' is already in use by session {existing['id']}")
|
||||||
|
else:
|
||||||
|
self._pending_title = new_title
|
||||||
|
_cprint(f" Session title queued: {new_title} (will be saved on first message)")
|
||||||
|
else:
|
||||||
|
_cprint(" Session database not available.")
|
||||||
|
else:
|
||||||
|
_cprint(" Usage: /title <your session title>")
|
||||||
|
else:
|
||||||
|
# Show current title if no argument given
|
||||||
|
if self._session_db:
|
||||||
|
session = self._session_db.get_session(self.session_id)
|
||||||
|
if session and session.get("title"):
|
||||||
|
_cprint(f" Session title: {session['title']}")
|
||||||
|
elif self._pending_title:
|
||||||
|
_cprint(f" Session title (pending): {self._pending_title}")
|
||||||
|
else:
|
||||||
|
_cprint(f" No title set. Usage: /title <your session title>")
|
||||||
|
else:
|
||||||
|
_cprint(" Session database not available.")
|
||||||
elif cmd_lower in ("/reset", "/new"):
|
elif cmd_lower in ("/reset", "/new"):
|
||||||
self.reset_conversation()
|
self.reset_conversation()
|
||||||
elif cmd_lower.startswith("/model"):
|
elif cmd_lower.startswith("/model"):
|
||||||
|
|
|
||||||
|
|
@ -710,7 +710,8 @@ class GatewayRunner:
|
||||||
# Emit command:* hook for any recognized slash command
|
# Emit command:* hook for any recognized slash command
|
||||||
_known_commands = {"new", "reset", "help", "status", "stop", "model",
|
_known_commands = {"new", "reset", "help", "status", "stop", "model",
|
||||||
"personality", "retry", "undo", "sethome", "set-home",
|
"personality", "retry", "undo", "sethome", "set-home",
|
||||||
"compress", "usage", "insights", "reload-mcp", "update"}
|
"compress", "usage", "insights", "reload-mcp", "update",
|
||||||
|
"title"}
|
||||||
if command and command in _known_commands:
|
if command and command in _known_commands:
|
||||||
await self.hooks.emit(f"command:{command}", {
|
await self.hooks.emit(f"command:{command}", {
|
||||||
"platform": source.platform.value if source.platform else "",
|
"platform": source.platform.value if source.platform else "",
|
||||||
|
|
@ -764,6 +765,9 @@ class GatewayRunner:
|
||||||
if command == "update":
|
if command == "update":
|
||||||
return await self._handle_update_command(event)
|
return await self._handle_update_command(event)
|
||||||
|
|
||||||
|
if command == "title":
|
||||||
|
return await self._handle_title_command(event)
|
||||||
|
|
||||||
# Skill slash commands: /skill-name loads the skill and sends to agent
|
# Skill slash commands: /skill-name loads the skill and sends to agent
|
||||||
if command:
|
if command:
|
||||||
try:
|
try:
|
||||||
|
|
@ -1301,6 +1305,7 @@ class GatewayRunner:
|
||||||
"`/undo` — Remove the last exchange",
|
"`/undo` — Remove the last exchange",
|
||||||
"`/sethome` — Set this chat as the home channel",
|
"`/sethome` — Set this chat as the home channel",
|
||||||
"`/compress` — Compress conversation context",
|
"`/compress` — Compress conversation context",
|
||||||
|
"`/title [name]` — Set or show the session title",
|
||||||
"`/usage` — Show token usage for this session",
|
"`/usage` — Show token usage for this session",
|
||||||
"`/insights [days]` — Show usage insights and analytics",
|
"`/insights [days]` — Show usage insights and analytics",
|
||||||
"`/reload-mcp` — Reload MCP servers from config",
|
"`/reload-mcp` — Reload MCP servers from config",
|
||||||
|
|
@ -1691,6 +1696,40 @@ class GatewayRunner:
|
||||||
logger.warning("Manual compress failed: %s", e)
|
logger.warning("Manual compress failed: %s", e)
|
||||||
return f"Compression failed: {e}"
|
return f"Compression failed: {e}"
|
||||||
|
|
||||||
|
async def _handle_title_command(self, event: MessageEvent) -> str:
|
||||||
|
"""Handle /title command — set or show the current session's title."""
|
||||||
|
source = event.source
|
||||||
|
session_entry = self.session_store.get_or_create_session(source)
|
||||||
|
session_id = session_entry.session_id
|
||||||
|
|
||||||
|
if not self._session_db:
|
||||||
|
return "Session database not available."
|
||||||
|
|
||||||
|
title_arg = event.get_command_args().strip()
|
||||||
|
if title_arg:
|
||||||
|
# Sanitize the title before setting
|
||||||
|
try:
|
||||||
|
sanitized = self._session_db.sanitize_title(title_arg)
|
||||||
|
except ValueError as e:
|
||||||
|
return f"⚠️ {e}"
|
||||||
|
if not sanitized:
|
||||||
|
return "⚠️ Title is empty after cleanup. Please use printable characters."
|
||||||
|
# Set the title
|
||||||
|
try:
|
||||||
|
if self._session_db.set_session_title(session_id, sanitized):
|
||||||
|
return f"✏️ Session title set: **{sanitized}**"
|
||||||
|
else:
|
||||||
|
return "Session not found in database."
|
||||||
|
except ValueError as e:
|
||||||
|
return f"⚠️ {e}"
|
||||||
|
else:
|
||||||
|
# Show the current title
|
||||||
|
title = self._session_db.get_session_title(session_id)
|
||||||
|
if title:
|
||||||
|
return f"📌 Session title: **{title}**"
|
||||||
|
else:
|
||||||
|
return "No title set. Usage: `/title My Session Name`"
|
||||||
|
|
||||||
async def _handle_usage_command(self, event: MessageEvent) -> str:
|
async def _handle_usage_command(self, event: MessageEvent) -> str:
|
||||||
"""Handle /usage command -- show token usage for the session's last agent run."""
|
"""Handle /usage command -- show token usage for the session's last agent run."""
|
||||||
source = event.source
|
source = event.source
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ COMMANDS = {
|
||||||
"/platforms": "Show gateway/messaging platform status",
|
"/platforms": "Show gateway/messaging platform status",
|
||||||
"/verbose": "Cycle tool progress display: off → new → all → verbose",
|
"/verbose": "Cycle tool progress display: off → new → all → verbose",
|
||||||
"/compress": "Manually compress conversation context (flush memories + summarize)",
|
"/compress": "Manually compress conversation context (flush memories + summarize)",
|
||||||
|
"/title": "Set a title for the current session (usage: /title My Session Name)",
|
||||||
"/usage": "Show token usage for the current session",
|
"/usage": "Show token usage for the current session",
|
||||||
"/insights": "Show usage insights and analytics (last 30 days)",
|
"/insights": "Show usage insights and analytics (last 30 days)",
|
||||||
"/paste": "Check clipboard for an image and attach it",
|
"/paste": "Check clipboard for an image and attach it",
|
||||||
|
|
|
||||||
|
|
@ -120,16 +120,63 @@ def _resolve_last_cli_session() -> Optional[str]:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_session_by_name_or_id(name_or_id: str) -> Optional[str]:
|
||||||
|
"""Resolve a session name (title) or ID to a session ID.
|
||||||
|
|
||||||
|
- If it looks like a session ID (contains underscore + hex), try direct lookup first.
|
||||||
|
- Otherwise, treat it as a title and use resolve_session_by_title (auto-latest).
|
||||||
|
- Falls back to the other method if the first doesn't match.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from hermes_state import SessionDB
|
||||||
|
db = SessionDB()
|
||||||
|
|
||||||
|
# Try as exact session ID first
|
||||||
|
session = db.get_session(name_or_id)
|
||||||
|
if session:
|
||||||
|
db.close()
|
||||||
|
return session["id"]
|
||||||
|
|
||||||
|
# Try as title (with auto-latest for lineage)
|
||||||
|
session_id = db.resolve_session_by_title(name_or_id)
|
||||||
|
db.close()
|
||||||
|
return session_id
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def cmd_chat(args):
|
def cmd_chat(args):
|
||||||
"""Run interactive chat CLI."""
|
"""Run interactive chat CLI."""
|
||||||
# Resolve --continue into --resume with the latest CLI session
|
# Resolve --continue into --resume with the latest CLI session or by name
|
||||||
if getattr(args, "continue_last", False) and not getattr(args, "resume", None):
|
continue_val = getattr(args, "continue_last", None)
|
||||||
last_id = _resolve_last_cli_session()
|
if continue_val and not getattr(args, "resume", None):
|
||||||
if last_id:
|
if isinstance(continue_val, str):
|
||||||
args.resume = last_id
|
# -c "session name" — resolve by title or ID
|
||||||
|
resolved = _resolve_session_by_name_or_id(continue_val)
|
||||||
|
if resolved:
|
||||||
|
args.resume = resolved
|
||||||
|
else:
|
||||||
|
print(f"No session found matching '{continue_val}'.")
|
||||||
|
print("Use 'hermes sessions list' to see available sessions.")
|
||||||
|
sys.exit(1)
|
||||||
else:
|
else:
|
||||||
print("No previous CLI session found to continue.")
|
# -c with no argument — continue the most recent session
|
||||||
sys.exit(1)
|
last_id = _resolve_last_cli_session()
|
||||||
|
if last_id:
|
||||||
|
args.resume = last_id
|
||||||
|
else:
|
||||||
|
print("No previous CLI session found to continue.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Resolve --resume by title if it's not a direct session ID
|
||||||
|
resume_val = getattr(args, "resume", None)
|
||||||
|
if resume_val:
|
||||||
|
resolved = _resolve_session_by_name_or_id(resume_val)
|
||||||
|
if resolved:
|
||||||
|
args.resume = resolved
|
||||||
|
# If resolution fails, keep the original value — _init_agent will
|
||||||
|
# report "Session not found" with the original input
|
||||||
|
|
||||||
# First-run guard: check if any provider is configured before launching
|
# First-run guard: check if any provider is configured before launching
|
||||||
if not _has_any_provider_configured():
|
if not _has_any_provider_configured():
|
||||||
|
|
@ -1209,8 +1256,9 @@ def main():
|
||||||
Examples:
|
Examples:
|
||||||
hermes Start interactive chat
|
hermes Start interactive chat
|
||||||
hermes chat -q "Hello" Single query mode
|
hermes chat -q "Hello" Single query mode
|
||||||
hermes --continue Resume the most recent session
|
hermes -c Resume the most recent session
|
||||||
hermes --resume <session_id> Resume a specific session
|
hermes -c "my project" Resume a session by name (latest in lineage)
|
||||||
|
hermes --resume <session_id> Resume a specific session by ID
|
||||||
hermes setup Run setup wizard
|
hermes setup Run setup wizard
|
||||||
hermes logout Clear stored authentication
|
hermes logout Clear stored authentication
|
||||||
hermes model Select default model
|
hermes model Select default model
|
||||||
|
|
@ -1221,6 +1269,7 @@ Examples:
|
||||||
hermes -w Start in isolated git worktree
|
hermes -w Start in isolated git worktree
|
||||||
hermes gateway install Install as system service
|
hermes gateway install Install as system service
|
||||||
hermes sessions list List past sessions
|
hermes sessions list List past sessions
|
||||||
|
hermes sessions rename ID T Rename/title a session
|
||||||
hermes update Update to latest version
|
hermes update Update to latest version
|
||||||
|
|
||||||
For more help on a command:
|
For more help on a command:
|
||||||
|
|
@ -1235,16 +1284,18 @@ For more help on a command:
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--resume", "-r",
|
"--resume", "-r",
|
||||||
metavar="SESSION_ID",
|
metavar="SESSION",
|
||||||
default=None,
|
default=None,
|
||||||
help="Resume a previous session by ID (shortcut for: hermes chat --resume ID)"
|
help="Resume a previous session by ID or title"
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--continue", "-c",
|
"--continue", "-c",
|
||||||
dest="continue_last",
|
dest="continue_last",
|
||||||
action="store_true",
|
nargs="?",
|
||||||
default=False,
|
const=True,
|
||||||
help="Resume the most recent CLI session"
|
default=None,
|
||||||
|
metavar="SESSION_NAME",
|
||||||
|
help="Resume a session by name, or the most recent if no name given"
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--worktree", "-w",
|
"--worktree", "-w",
|
||||||
|
|
@ -1294,9 +1345,11 @@ For more help on a command:
|
||||||
chat_parser.add_argument(
|
chat_parser.add_argument(
|
||||||
"--continue", "-c",
|
"--continue", "-c",
|
||||||
dest="continue_last",
|
dest="continue_last",
|
||||||
action="store_true",
|
nargs="?",
|
||||||
default=False,
|
const=True,
|
||||||
help="Resume the most recent CLI session"
|
default=None,
|
||||||
|
metavar="SESSION_NAME",
|
||||||
|
help="Resume a session by name, or the most recent if no name given"
|
||||||
)
|
)
|
||||||
chat_parser.add_argument(
|
chat_parser.add_argument(
|
||||||
"--worktree", "-w",
|
"--worktree", "-w",
|
||||||
|
|
@ -1671,7 +1724,7 @@ For more help on a command:
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
sessions_parser = subparsers.add_parser(
|
sessions_parser = subparsers.add_parser(
|
||||||
"sessions",
|
"sessions",
|
||||||
help="Manage session history (list, export, prune, delete)",
|
help="Manage session history (list, rename, export, prune, delete)",
|
||||||
description="View and manage the SQLite session store"
|
description="View and manage the SQLite session store"
|
||||||
)
|
)
|
||||||
sessions_subparsers = sessions_parser.add_subparsers(dest="sessions_action")
|
sessions_subparsers = sessions_parser.add_subparsers(dest="sessions_action")
|
||||||
|
|
@ -1696,6 +1749,10 @@ For more help on a command:
|
||||||
|
|
||||||
sessions_stats = sessions_subparsers.add_parser("stats", help="Show session store statistics")
|
sessions_stats = sessions_subparsers.add_parser("stats", help="Show session store statistics")
|
||||||
|
|
||||||
|
sessions_rename = sessions_subparsers.add_parser("rename", help="Set or change a session's title")
|
||||||
|
sessions_rename.add_argument("session_id", help="Session ID to rename")
|
||||||
|
sessions_rename.add_argument("title", nargs="+", help="New title for the session")
|
||||||
|
|
||||||
def cmd_sessions(args):
|
def cmd_sessions(args):
|
||||||
import json as _json
|
import json as _json
|
||||||
try:
|
try:
|
||||||
|
|
@ -1708,18 +1765,51 @@ For more help on a command:
|
||||||
action = args.sessions_action
|
action = args.sessions_action
|
||||||
|
|
||||||
if action == "list":
|
if action == "list":
|
||||||
sessions = db.search_sessions(source=args.source, limit=args.limit)
|
sessions = db.list_sessions_rich(source=args.source, limit=args.limit)
|
||||||
if not sessions:
|
if not sessions:
|
||||||
print("No sessions found.")
|
print("No sessions found.")
|
||||||
return
|
return
|
||||||
print(f"{'ID':<30} {'Source':<12} {'Model':<30} {'Messages':>8} {'Started'}")
|
|
||||||
print("─" * 100)
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
import time as _time
|
||||||
|
|
||||||
|
def _relative_time(ts):
|
||||||
|
"""Format a timestamp as relative time (e.g., '2h ago', 'yesterday')."""
|
||||||
|
if not ts:
|
||||||
|
return "?"
|
||||||
|
delta = _time.time() - ts
|
||||||
|
if delta < 60:
|
||||||
|
return "just now"
|
||||||
|
elif delta < 3600:
|
||||||
|
mins = int(delta / 60)
|
||||||
|
return f"{mins}m ago"
|
||||||
|
elif delta < 86400:
|
||||||
|
hours = int(delta / 3600)
|
||||||
|
return f"{hours}h ago"
|
||||||
|
elif delta < 172800:
|
||||||
|
return "yesterday"
|
||||||
|
elif delta < 604800:
|
||||||
|
days = int(delta / 86400)
|
||||||
|
return f"{days}d ago"
|
||||||
|
else:
|
||||||
|
return datetime.fromtimestamp(ts).strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
has_titles = any(s.get("title") for s in sessions)
|
||||||
|
if has_titles:
|
||||||
|
print(f"{'Title':<22} {'Preview':<40} {'Last Active':<13} {'ID'}")
|
||||||
|
print("─" * 100)
|
||||||
|
else:
|
||||||
|
print(f"{'Preview':<50} {'Last Active':<13} {'Src':<6} {'ID'}")
|
||||||
|
print("─" * 90)
|
||||||
for s in sessions:
|
for s in sessions:
|
||||||
started = datetime.fromtimestamp(s["started_at"]).strftime("%Y-%m-%d %H:%M") if s["started_at"] else "?"
|
last_active = _relative_time(s.get("last_active"))
|
||||||
model = (s.get("model") or "?")[:28]
|
preview = s.get("preview", "")[:38] if has_titles else s.get("preview", "")[:48]
|
||||||
ended = " (ended)" if s.get("ended_at") else ""
|
if has_titles:
|
||||||
print(f"{s['id']:<30} {s['source']:<12} {model:<30} {s['message_count']:>8} {started}{ended}")
|
title = (s.get("title") or "—")[:20]
|
||||||
|
sid = s["id"][:20]
|
||||||
|
print(f"{title:<22} {preview:<40} {last_active:<13} {sid}")
|
||||||
|
else:
|
||||||
|
sid = s["id"][:20]
|
||||||
|
print(f"{preview:<50} {last_active:<13} {s['source']:<6} {sid}")
|
||||||
|
|
||||||
elif action == "export":
|
elif action == "export":
|
||||||
if args.session_id:
|
if args.session_id:
|
||||||
|
|
@ -1759,6 +1849,16 @@ For more help on a command:
|
||||||
count = db.prune_sessions(older_than_days=days, source=args.source)
|
count = db.prune_sessions(older_than_days=days, source=args.source)
|
||||||
print(f"Pruned {count} session(s).")
|
print(f"Pruned {count} session(s).")
|
||||||
|
|
||||||
|
elif action == "rename":
|
||||||
|
title = " ".join(args.title)
|
||||||
|
try:
|
||||||
|
if db.set_session_title(args.session_id, title):
|
||||||
|
print(f"Session '{args.session_id}' renamed to: {title}")
|
||||||
|
else:
|
||||||
|
print(f"Session '{args.session_id}' not found.")
|
||||||
|
except ValueError as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
|
||||||
elif action == "stats":
|
elif action == "stats":
|
||||||
total = db.session_count()
|
total = db.session_count()
|
||||||
msgs = db.message_count()
|
msgs = db.message_count()
|
||||||
|
|
@ -1877,7 +1977,7 @@ For more help on a command:
|
||||||
args.toolsets = None
|
args.toolsets = None
|
||||||
args.verbose = False
|
args.verbose = False
|
||||||
args.resume = None
|
args.resume = None
|
||||||
args.continue_last = False
|
args.continue_last = None
|
||||||
if not hasattr(args, "worktree"):
|
if not hasattr(args, "worktree"):
|
||||||
args.worktree = False
|
args.worktree = False
|
||||||
cmd_chat(args)
|
cmd_chat(args)
|
||||||
|
|
|
||||||
233
hermes_state.py
233
hermes_state.py
|
|
@ -24,7 +24,7 @@ from typing import Dict, Any, List, Optional
|
||||||
|
|
||||||
DEFAULT_DB_PATH = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes")) / "state.db"
|
DEFAULT_DB_PATH = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes")) / "state.db"
|
||||||
|
|
||||||
SCHEMA_VERSION = 2
|
SCHEMA_VERSION = 4
|
||||||
|
|
||||||
SCHEMA_SQL = """
|
SCHEMA_SQL = """
|
||||||
CREATE TABLE IF NOT EXISTS schema_version (
|
CREATE TABLE IF NOT EXISTS schema_version (
|
||||||
|
|
@ -46,6 +46,7 @@ CREATE TABLE IF NOT EXISTS sessions (
|
||||||
tool_call_count INTEGER DEFAULT 0,
|
tool_call_count INTEGER DEFAULT 0,
|
||||||
input_tokens INTEGER DEFAULT 0,
|
input_tokens INTEGER DEFAULT 0,
|
||||||
output_tokens INTEGER DEFAULT 0,
|
output_tokens INTEGER DEFAULT 0,
|
||||||
|
title TEXT,
|
||||||
FOREIGN KEY (parent_session_id) REFERENCES sessions(id)
|
FOREIGN KEY (parent_session_id) REFERENCES sessions(id)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -133,7 +134,33 @@ class SessionDB:
|
||||||
except sqlite3.OperationalError:
|
except sqlite3.OperationalError:
|
||||||
pass # Column already exists
|
pass # Column already exists
|
||||||
cursor.execute("UPDATE schema_version SET version = 2")
|
cursor.execute("UPDATE schema_version SET version = 2")
|
||||||
|
if current_version < 3:
|
||||||
|
# v3: add title column to sessions
|
||||||
|
try:
|
||||||
|
cursor.execute("ALTER TABLE sessions ADD COLUMN title TEXT")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass # Column already exists
|
||||||
|
cursor.execute("UPDATE schema_version SET version = 3")
|
||||||
|
if current_version < 4:
|
||||||
|
# v4: add unique index on title (NULLs allowed, only non-NULL must be unique)
|
||||||
|
try:
|
||||||
|
cursor.execute(
|
||||||
|
"CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_title_unique "
|
||||||
|
"ON sessions(title) WHERE title IS NOT NULL"
|
||||||
|
)
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass # Index already exists
|
||||||
|
cursor.execute("UPDATE schema_version SET version = 4")
|
||||||
|
|
||||||
|
# Unique title index — always ensure it exists (safe to run after migrations
|
||||||
|
# since the title column is guaranteed to exist at this point)
|
||||||
|
try:
|
||||||
|
cursor.execute(
|
||||||
|
"CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_title_unique "
|
||||||
|
"ON sessions(title) WHERE title IS NOT NULL"
|
||||||
|
)
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass # Index already exists
|
||||||
|
|
||||||
# FTS5 setup (separate because CREATE VIRTUAL TABLE can't be in executescript with IF NOT EXISTS reliably)
|
# FTS5 setup (separate because CREATE VIRTUAL TABLE can't be in executescript with IF NOT EXISTS reliably)
|
||||||
try:
|
try:
|
||||||
|
|
@ -219,6 +246,210 @@ class SessionDB:
|
||||||
row = cursor.fetchone()
|
row = cursor.fetchone()
|
||||||
return dict(row) if row else None
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
# Maximum length for session titles
|
||||||
|
MAX_TITLE_LENGTH = 100
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def sanitize_title(title: Optional[str]) -> Optional[str]:
|
||||||
|
"""Validate and sanitize a session title.
|
||||||
|
|
||||||
|
- Strips leading/trailing whitespace
|
||||||
|
- Removes ASCII control characters (0x00-0x1F, 0x7F) and problematic
|
||||||
|
Unicode control chars (zero-width, RTL/LTR overrides, etc.)
|
||||||
|
- Collapses internal whitespace runs to single spaces
|
||||||
|
- Normalizes empty/whitespace-only strings to None
|
||||||
|
- Enforces MAX_TITLE_LENGTH
|
||||||
|
|
||||||
|
Returns the cleaned title string or None.
|
||||||
|
Raises ValueError if the title exceeds MAX_TITLE_LENGTH after cleaning.
|
||||||
|
"""
|
||||||
|
if not title:
|
||||||
|
return None
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
# Remove ASCII control characters (0x00-0x1F, 0x7F) but keep
|
||||||
|
# whitespace chars (\t=0x09, \n=0x0A, \r=0x0D) so they can be
|
||||||
|
# normalized to spaces by the whitespace collapsing step below
|
||||||
|
cleaned = re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]', '', title)
|
||||||
|
|
||||||
|
# Remove problematic Unicode control characters:
|
||||||
|
# - Zero-width chars (U+200B-U+200F, U+FEFF)
|
||||||
|
# - Directional overrides (U+202A-U+202E, U+2066-U+2069)
|
||||||
|
# - Object replacement (U+FFFC), interlinear annotation (U+FFF9-U+FFFB)
|
||||||
|
cleaned = re.sub(
|
||||||
|
r'[\u200b-\u200f\u2028-\u202e\u2060-\u2069\ufeff\ufffc\ufff9-\ufffb]',
|
||||||
|
'', cleaned,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Collapse internal whitespace runs and strip
|
||||||
|
cleaned = re.sub(r'\s+', ' ', cleaned).strip()
|
||||||
|
|
||||||
|
if not cleaned:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if len(cleaned) > SessionDB.MAX_TITLE_LENGTH:
|
||||||
|
raise ValueError(
|
||||||
|
f"Title too long ({len(cleaned)} chars, max {SessionDB.MAX_TITLE_LENGTH})"
|
||||||
|
)
|
||||||
|
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
def set_session_title(self, session_id: str, title: str) -> bool:
|
||||||
|
"""Set or update a session's title.
|
||||||
|
|
||||||
|
Returns True if session was found and title was set.
|
||||||
|
Raises ValueError if title is already in use by another session,
|
||||||
|
or if the title fails validation (too long, invalid characters).
|
||||||
|
Empty/whitespace-only strings are normalized to None (clearing the title).
|
||||||
|
"""
|
||||||
|
title = self.sanitize_title(title)
|
||||||
|
if title:
|
||||||
|
# Check uniqueness (allow the same session to keep its own title)
|
||||||
|
cursor = self._conn.execute(
|
||||||
|
"SELECT id FROM sessions WHERE title = ? AND id != ?",
|
||||||
|
(title, session_id),
|
||||||
|
)
|
||||||
|
conflict = cursor.fetchone()
|
||||||
|
if conflict:
|
||||||
|
raise ValueError(
|
||||||
|
f"Title '{title}' is already in use by session {conflict['id']}"
|
||||||
|
)
|
||||||
|
cursor = self._conn.execute(
|
||||||
|
"UPDATE sessions SET title = ? WHERE id = ?",
|
||||||
|
(title, session_id),
|
||||||
|
)
|
||||||
|
self._conn.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
def get_session_title(self, session_id: str) -> Optional[str]:
|
||||||
|
"""Get the title for a session, or None."""
|
||||||
|
cursor = self._conn.execute(
|
||||||
|
"SELECT title FROM sessions WHERE id = ?", (session_id,)
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
return row["title"] if row else None
|
||||||
|
|
||||||
|
def get_session_by_title(self, title: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Look up a session by exact title. Returns session dict or None."""
|
||||||
|
cursor = self._conn.execute(
|
||||||
|
"SELECT * FROM sessions WHERE title = ?", (title,)
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
def resolve_session_by_title(self, title: str) -> Optional[str]:
|
||||||
|
"""Resolve a title to a session ID, preferring the latest in a lineage.
|
||||||
|
|
||||||
|
If the exact title exists, returns that session's ID.
|
||||||
|
If not, searches for "title #N" variants and returns the latest one.
|
||||||
|
If the exact title exists AND numbered variants exist, returns the
|
||||||
|
latest numbered variant (the most recent continuation).
|
||||||
|
"""
|
||||||
|
# First try exact match
|
||||||
|
exact = self.get_session_by_title(title)
|
||||||
|
|
||||||
|
# Also search for numbered variants: "title #2", "title #3", etc.
|
||||||
|
# Escape SQL LIKE wildcards (%, _) in the title to prevent false matches
|
||||||
|
escaped = title.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||||
|
cursor = self._conn.execute(
|
||||||
|
"SELECT id, title, started_at FROM sessions "
|
||||||
|
"WHERE title LIKE ? ESCAPE '\\' ORDER BY started_at DESC",
|
||||||
|
(f"{escaped} #%",),
|
||||||
|
)
|
||||||
|
numbered = cursor.fetchall()
|
||||||
|
|
||||||
|
if numbered:
|
||||||
|
# Return the most recent numbered variant
|
||||||
|
return numbered[0]["id"]
|
||||||
|
elif exact:
|
||||||
|
return exact["id"]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_next_title_in_lineage(self, base_title: str) -> str:
|
||||||
|
"""Generate the next title in a lineage (e.g., "my session" → "my session #2").
|
||||||
|
|
||||||
|
Strips any existing " #N" suffix to find the base name, then finds
|
||||||
|
the highest existing number and increments.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
# Strip existing #N suffix to find the true base
|
||||||
|
match = re.match(r'^(.*?) #(\d+)$', base_title)
|
||||||
|
if match:
|
||||||
|
base = match.group(1)
|
||||||
|
else:
|
||||||
|
base = base_title
|
||||||
|
|
||||||
|
# Find all existing numbered variants
|
||||||
|
# Escape SQL LIKE wildcards (%, _) in the base to prevent false matches
|
||||||
|
escaped = base.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||||
|
cursor = self._conn.execute(
|
||||||
|
"SELECT title FROM sessions WHERE title = ? OR title LIKE ? ESCAPE '\\'",
|
||||||
|
(base, f"{escaped} #%"),
|
||||||
|
)
|
||||||
|
existing = [row["title"] for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
if not existing:
|
||||||
|
return base # No conflict, use the base name as-is
|
||||||
|
|
||||||
|
# Find the highest number
|
||||||
|
max_num = 1 # The unnumbered original counts as #1
|
||||||
|
for t in existing:
|
||||||
|
m = re.match(r'^.* #(\d+)$', t)
|
||||||
|
if m:
|
||||||
|
max_num = max(max_num, int(m.group(1)))
|
||||||
|
|
||||||
|
return f"{base} #{max_num + 1}"
|
||||||
|
|
||||||
|
def list_sessions_rich(
|
||||||
|
self,
|
||||||
|
source: str = None,
|
||||||
|
limit: int = 20,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""List sessions with preview (first user message) and last active timestamp.
|
||||||
|
|
||||||
|
Returns dicts with keys: id, source, model, title, started_at, ended_at,
|
||||||
|
message_count, preview (first 60 chars of first user message),
|
||||||
|
last_active (timestamp of last message).
|
||||||
|
|
||||||
|
Uses a single query with correlated subqueries instead of N+2 queries.
|
||||||
|
"""
|
||||||
|
source_clause = "WHERE s.source = ?" if source else ""
|
||||||
|
query = f"""
|
||||||
|
SELECT s.*,
|
||||||
|
COALESCE(
|
||||||
|
(SELECT SUBSTR(REPLACE(REPLACE(m.content, X'0A', ' '), X'0D', ' '), 1, 63)
|
||||||
|
FROM messages m
|
||||||
|
WHERE m.session_id = s.id AND m.role = 'user' AND m.content IS NOT NULL
|
||||||
|
ORDER BY m.timestamp, m.id LIMIT 1),
|
||||||
|
''
|
||||||
|
) AS _preview_raw,
|
||||||
|
COALESCE(
|
||||||
|
(SELECT MAX(m2.timestamp) FROM messages m2 WHERE m2.session_id = s.id),
|
||||||
|
s.started_at
|
||||||
|
) AS last_active
|
||||||
|
FROM sessions s
|
||||||
|
{source_clause}
|
||||||
|
ORDER BY s.started_at DESC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
"""
|
||||||
|
params = (source, limit, offset) if source else (limit, offset)
|
||||||
|
cursor = self._conn.execute(query, params)
|
||||||
|
sessions = []
|
||||||
|
for row in cursor.fetchall():
|
||||||
|
s = dict(row)
|
||||||
|
# Build the preview from the raw substring
|
||||||
|
raw = s.pop("_preview_raw", "").strip()
|
||||||
|
if raw:
|
||||||
|
text = raw[:60]
|
||||||
|
s["preview"] = text + ("..." if len(raw) > 60 else "")
|
||||||
|
else:
|
||||||
|
s["preview"] = ""
|
||||||
|
sessions.append(s)
|
||||||
|
|
||||||
|
return sessions
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Message storage
|
# Message storage
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
|
||||||
|
|
@ -2484,6 +2484,8 @@ class AIAgent:
|
||||||
|
|
||||||
if self._session_db:
|
if self._session_db:
|
||||||
try:
|
try:
|
||||||
|
# Propagate title to the new session with auto-numbering
|
||||||
|
old_title = self._session_db.get_session_title(self.session_id)
|
||||||
self._session_db.end_session(self.session_id, "compression")
|
self._session_db.end_session(self.session_id, "compression")
|
||||||
old_session_id = self.session_id
|
old_session_id = self.session_id
|
||||||
self.session_id = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}"
|
self.session_id = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}"
|
||||||
|
|
@ -2493,6 +2495,13 @@ class AIAgent:
|
||||||
model=self.model,
|
model=self.model,
|
||||||
parent_session_id=old_session_id,
|
parent_session_id=old_session_id,
|
||||||
)
|
)
|
||||||
|
# Auto-number the title for the continuation session
|
||||||
|
if old_title:
|
||||||
|
try:
|
||||||
|
new_title = self._session_db.get_next_title_in_lineage(old_title)
|
||||||
|
self._session_db.set_session_title(self.session_id, new_title)
|
||||||
|
except (ValueError, Exception) as e:
|
||||||
|
logger.debug("Could not propagate title on compression: %s", e)
|
||||||
self._session_db.update_system_prompt(self.session_id, new_system_prompt)
|
self._session_db.update_system_prompt(self.session_id, new_system_prompt)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("Session DB compression split failed: %s", e)
|
logger.debug("Session DB compression split failed: %s", e)
|
||||||
|
|
|
||||||
207
tests/gateway/test_title_command.py
Normal file
207
tests/gateway/test_title_command.py
Normal file
|
|
@ -0,0 +1,207 @@
|
||||||
|
"""Tests for /title gateway slash command.
|
||||||
|
|
||||||
|
Tests the _handle_title_command handler (set/show session titles)
|
||||||
|
across all gateway messenger platforms.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from gateway.config import Platform
|
||||||
|
from gateway.platforms.base import MessageEvent
|
||||||
|
from gateway.session import SessionSource
|
||||||
|
|
||||||
|
|
||||||
|
def _make_event(text="/title", platform=Platform.TELEGRAM,
|
||||||
|
user_id="12345", chat_id="67890"):
|
||||||
|
"""Build a MessageEvent for testing."""
|
||||||
|
source = SessionSource(
|
||||||
|
platform=platform,
|
||||||
|
user_id=user_id,
|
||||||
|
chat_id=chat_id,
|
||||||
|
user_name="testuser",
|
||||||
|
)
|
||||||
|
return MessageEvent(text=text, source=source)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_runner(session_db=None):
|
||||||
|
"""Create a bare GatewayRunner with a mock session_store and optional session_db."""
|
||||||
|
from gateway.run import GatewayRunner
|
||||||
|
runner = object.__new__(GatewayRunner)
|
||||||
|
runner.adapters = {}
|
||||||
|
runner._session_db = session_db
|
||||||
|
|
||||||
|
# Mock session_store that returns a session entry with a known session_id
|
||||||
|
mock_session_entry = MagicMock()
|
||||||
|
mock_session_entry.session_id = "test_session_123"
|
||||||
|
mock_session_entry.session_key = "telegram:12345:67890"
|
||||||
|
mock_store = MagicMock()
|
||||||
|
mock_store.get_or_create_session.return_value = mock_session_entry
|
||||||
|
runner.session_store = mock_store
|
||||||
|
|
||||||
|
return runner
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _handle_title_command
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestHandleTitleCommand:
|
||||||
|
"""Tests for GatewayRunner._handle_title_command."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_set_title(self, tmp_path):
|
||||||
|
"""Setting a title returns confirmation."""
|
||||||
|
from hermes_state import SessionDB
|
||||||
|
db = SessionDB(db_path=tmp_path / "state.db")
|
||||||
|
db.create_session("test_session_123", "telegram")
|
||||||
|
|
||||||
|
runner = _make_runner(session_db=db)
|
||||||
|
event = _make_event(text="/title My Research Project")
|
||||||
|
result = await runner._handle_title_command(event)
|
||||||
|
assert "My Research Project" in result
|
||||||
|
assert "✏️" in result
|
||||||
|
|
||||||
|
# Verify in DB
|
||||||
|
assert db.get_session_title("test_session_123") == "My Research Project"
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_show_title_when_set(self, tmp_path):
|
||||||
|
"""Showing title when one is set returns the title."""
|
||||||
|
from hermes_state import SessionDB
|
||||||
|
db = SessionDB(db_path=tmp_path / "state.db")
|
||||||
|
db.create_session("test_session_123", "telegram")
|
||||||
|
db.set_session_title("test_session_123", "Existing Title")
|
||||||
|
|
||||||
|
runner = _make_runner(session_db=db)
|
||||||
|
event = _make_event(text="/title")
|
||||||
|
result = await runner._handle_title_command(event)
|
||||||
|
assert "Existing Title" in result
|
||||||
|
assert "📌" in result
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_show_title_when_not_set(self, tmp_path):
|
||||||
|
"""Showing title when none is set returns usage hint."""
|
||||||
|
from hermes_state import SessionDB
|
||||||
|
db = SessionDB(db_path=tmp_path / "state.db")
|
||||||
|
db.create_session("test_session_123", "telegram")
|
||||||
|
|
||||||
|
runner = _make_runner(session_db=db)
|
||||||
|
event = _make_event(text="/title")
|
||||||
|
result = await runner._handle_title_command(event)
|
||||||
|
assert "No title set" in result
|
||||||
|
assert "/title" in result
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_title_conflict(self, tmp_path):
|
||||||
|
"""Setting a title already used by another session returns error."""
|
||||||
|
from hermes_state import SessionDB
|
||||||
|
db = SessionDB(db_path=tmp_path / "state.db")
|
||||||
|
db.create_session("other_session", "telegram")
|
||||||
|
db.set_session_title("other_session", "Taken Title")
|
||||||
|
db.create_session("test_session_123", "telegram")
|
||||||
|
|
||||||
|
runner = _make_runner(session_db=db)
|
||||||
|
event = _make_event(text="/title Taken Title")
|
||||||
|
result = await runner._handle_title_command(event)
|
||||||
|
assert "already in use" in result
|
||||||
|
assert "⚠️" in result
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_no_session_db(self):
|
||||||
|
"""Returns error when session database is not available."""
|
||||||
|
runner = _make_runner(session_db=None)
|
||||||
|
event = _make_event(text="/title My Title")
|
||||||
|
result = await runner._handle_title_command(event)
|
||||||
|
assert "not available" in result
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_title_too_long(self, tmp_path):
|
||||||
|
"""Setting a title that exceeds max length returns error."""
|
||||||
|
from hermes_state import SessionDB
|
||||||
|
db = SessionDB(db_path=tmp_path / "state.db")
|
||||||
|
db.create_session("test_session_123", "telegram")
|
||||||
|
|
||||||
|
runner = _make_runner(session_db=db)
|
||||||
|
long_title = "A" * 150
|
||||||
|
event = _make_event(text=f"/title {long_title}")
|
||||||
|
result = await runner._handle_title_command(event)
|
||||||
|
assert "too long" in result
|
||||||
|
assert "⚠️" in result
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_title_control_chars_sanitized(self, tmp_path):
|
||||||
|
"""Control characters are stripped and sanitized title is stored."""
|
||||||
|
from hermes_state import SessionDB
|
||||||
|
db = SessionDB(db_path=tmp_path / "state.db")
|
||||||
|
db.create_session("test_session_123", "telegram")
|
||||||
|
|
||||||
|
runner = _make_runner(session_db=db)
|
||||||
|
event = _make_event(text="/title hello\x00world")
|
||||||
|
result = await runner._handle_title_command(event)
|
||||||
|
assert "helloworld" in result
|
||||||
|
assert db.get_session_title("test_session_123") == "helloworld"
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_title_only_control_chars(self, tmp_path):
|
||||||
|
"""Title with only control chars returns empty error."""
|
||||||
|
from hermes_state import SessionDB
|
||||||
|
db = SessionDB(db_path=tmp_path / "state.db")
|
||||||
|
db.create_session("test_session_123", "telegram")
|
||||||
|
|
||||||
|
runner = _make_runner(session_db=db)
|
||||||
|
event = _make_event(text="/title \x00\x01\x02")
|
||||||
|
result = await runner._handle_title_command(event)
|
||||||
|
assert "empty after cleanup" in result
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_works_across_platforms(self, tmp_path):
|
||||||
|
"""The /title command works for Discord, Slack, and WhatsApp too."""
|
||||||
|
from hermes_state import SessionDB
|
||||||
|
for platform in [Platform.DISCORD, Platform.TELEGRAM]:
|
||||||
|
db = SessionDB(db_path=tmp_path / f"state_{platform.value}.db")
|
||||||
|
db.create_session("test_session_123", platform.value)
|
||||||
|
|
||||||
|
runner = _make_runner(session_db=db)
|
||||||
|
event = _make_event(text="/title Cross-Platform Test", platform=platform)
|
||||||
|
result = await runner._handle_title_command(event)
|
||||||
|
assert "Cross-Platform Test" in result
|
||||||
|
assert db.get_session_title("test_session_123") == "Cross-Platform Test"
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# /title in help and known_commands
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestTitleInHelp:
|
||||||
|
"""Verify /title appears in help text and known commands."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_title_in_help_output(self):
|
||||||
|
"""The /help output includes /title."""
|
||||||
|
runner = _make_runner()
|
||||||
|
event = _make_event(text="/help")
|
||||||
|
# Need hooks for help command
|
||||||
|
from gateway.hooks import HookRegistry
|
||||||
|
runner.hooks = HookRegistry()
|
||||||
|
result = await runner._handle_help_command(event)
|
||||||
|
assert "/title" in result
|
||||||
|
|
||||||
|
def test_title_is_known_command(self):
|
||||||
|
"""The /title command is in the _known_commands set."""
|
||||||
|
from gateway.run import GatewayRunner
|
||||||
|
import inspect
|
||||||
|
source = inspect.getsource(GatewayRunner._handle_message)
|
||||||
|
assert '"title"' in source
|
||||||
|
|
@ -11,7 +11,7 @@ EXPECTED_COMMANDS = {
|
||||||
"/help", "/tools", "/toolsets", "/model", "/provider", "/prompt",
|
"/help", "/tools", "/toolsets", "/model", "/provider", "/prompt",
|
||||||
"/personality", "/clear", "/history", "/new", "/reset", "/retry",
|
"/personality", "/clear", "/history", "/new", "/reset", "/retry",
|
||||||
"/undo", "/save", "/config", "/cron", "/skills", "/platforms",
|
"/undo", "/save", "/config", "/cron", "/skills", "/platforms",
|
||||||
"/verbose", "/compress", "/usage", "/insights", "/paste",
|
"/verbose", "/compress", "/title", "/usage", "/insights", "/paste",
|
||||||
"/reload-mcp", "/quit",
|
"/reload-mcp", "/quit",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -351,6 +351,173 @@ class TestPruneSessions:
|
||||||
# Schema and WAL mode
|
# Schema and WAL mode
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Session title
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
class TestSessionTitle:
|
||||||
|
def test_set_and_get_title(self, db):
|
||||||
|
db.create_session(session_id="s1", source="cli")
|
||||||
|
assert db.set_session_title("s1", "My Session") is True
|
||||||
|
|
||||||
|
session = db.get_session("s1")
|
||||||
|
assert session["title"] == "My Session"
|
||||||
|
|
||||||
|
def test_set_title_nonexistent_session(self, db):
|
||||||
|
assert db.set_session_title("nonexistent", "Title") is False
|
||||||
|
|
||||||
|
def test_title_initially_none(self, db):
|
||||||
|
db.create_session(session_id="s1", source="cli")
|
||||||
|
session = db.get_session("s1")
|
||||||
|
assert session["title"] is None
|
||||||
|
|
||||||
|
def test_update_title(self, db):
|
||||||
|
db.create_session(session_id="s1", source="cli")
|
||||||
|
db.set_session_title("s1", "First Title")
|
||||||
|
db.set_session_title("s1", "Updated Title")
|
||||||
|
|
||||||
|
session = db.get_session("s1")
|
||||||
|
assert session["title"] == "Updated Title"
|
||||||
|
|
||||||
|
def test_title_in_search_sessions(self, db):
|
||||||
|
db.create_session(session_id="s1", source="cli")
|
||||||
|
db.set_session_title("s1", "Debugging Auth")
|
||||||
|
db.create_session(session_id="s2", source="cli")
|
||||||
|
|
||||||
|
sessions = db.search_sessions()
|
||||||
|
titled = [s for s in sessions if s.get("title") == "Debugging Auth"]
|
||||||
|
assert len(titled) == 1
|
||||||
|
assert titled[0]["id"] == "s1"
|
||||||
|
|
||||||
|
def test_title_in_export(self, db):
|
||||||
|
db.create_session(session_id="s1", source="cli")
|
||||||
|
db.set_session_title("s1", "Export Test")
|
||||||
|
db.append_message("s1", role="user", content="Hello")
|
||||||
|
|
||||||
|
export = db.export_session("s1")
|
||||||
|
assert export["title"] == "Export Test"
|
||||||
|
|
||||||
|
def test_title_with_special_characters(self, db):
|
||||||
|
db.create_session(session_id="s1", source="cli")
|
||||||
|
title = "PR #438 — fixing the 'auth' middleware"
|
||||||
|
db.set_session_title("s1", title)
|
||||||
|
|
||||||
|
session = db.get_session("s1")
|
||||||
|
assert session["title"] == title
|
||||||
|
|
||||||
|
def test_title_empty_string_normalized_to_none(self, db):
|
||||||
|
"""Empty strings are normalized to None (clearing the title)."""
|
||||||
|
db.create_session(session_id="s1", source="cli")
|
||||||
|
db.set_session_title("s1", "My Title")
|
||||||
|
# Setting to empty string should clear the title (normalize to None)
|
||||||
|
db.set_session_title("s1", "")
|
||||||
|
|
||||||
|
session = db.get_session("s1")
|
||||||
|
assert session["title"] is None
|
||||||
|
|
||||||
|
def test_multiple_empty_titles_no_conflict(self, db):
|
||||||
|
"""Multiple sessions can have empty-string (normalized to NULL) titles."""
|
||||||
|
db.create_session(session_id="s1", source="cli")
|
||||||
|
db.create_session(session_id="s2", source="cli")
|
||||||
|
db.set_session_title("s1", "")
|
||||||
|
db.set_session_title("s2", "")
|
||||||
|
# Both should be None, no uniqueness conflict
|
||||||
|
assert db.get_session("s1")["title"] is None
|
||||||
|
assert db.get_session("s2")["title"] is None
|
||||||
|
|
||||||
|
def test_title_survives_end_session(self, db):
|
||||||
|
db.create_session(session_id="s1", source="cli")
|
||||||
|
db.set_session_title("s1", "Before End")
|
||||||
|
db.end_session("s1", end_reason="user_exit")
|
||||||
|
|
||||||
|
session = db.get_session("s1")
|
||||||
|
assert session["title"] == "Before End"
|
||||||
|
assert session["ended_at"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
class TestSanitizeTitle:
|
||||||
|
"""Tests for SessionDB.sanitize_title() validation and cleaning."""
|
||||||
|
|
||||||
|
def test_normal_title_unchanged(self):
|
||||||
|
assert SessionDB.sanitize_title("My Project") == "My Project"
|
||||||
|
|
||||||
|
def test_strips_whitespace(self):
|
||||||
|
assert SessionDB.sanitize_title(" hello world ") == "hello world"
|
||||||
|
|
||||||
|
def test_collapses_internal_whitespace(self):
|
||||||
|
assert SessionDB.sanitize_title("hello world") == "hello world"
|
||||||
|
|
||||||
|
def test_tabs_and_newlines_collapsed(self):
|
||||||
|
assert SessionDB.sanitize_title("hello\t\nworld") == "hello world"
|
||||||
|
|
||||||
|
def test_none_returns_none(self):
|
||||||
|
assert SessionDB.sanitize_title(None) is None
|
||||||
|
|
||||||
|
def test_empty_string_returns_none(self):
|
||||||
|
assert SessionDB.sanitize_title("") is None
|
||||||
|
|
||||||
|
def test_whitespace_only_returns_none(self):
|
||||||
|
assert SessionDB.sanitize_title(" \t\n ") is None
|
||||||
|
|
||||||
|
def test_control_chars_stripped(self):
|
||||||
|
# Null byte, bell, backspace, etc.
|
||||||
|
assert SessionDB.sanitize_title("hello\x00world") == "helloworld"
|
||||||
|
assert SessionDB.sanitize_title("\x07\x08test\x1b") == "test"
|
||||||
|
|
||||||
|
def test_del_char_stripped(self):
|
||||||
|
assert SessionDB.sanitize_title("hello\x7fworld") == "helloworld"
|
||||||
|
|
||||||
|
def test_zero_width_chars_stripped(self):
|
||||||
|
# Zero-width space (U+200B), zero-width joiner (U+200D)
|
||||||
|
assert SessionDB.sanitize_title("hello\u200bworld") == "helloworld"
|
||||||
|
assert SessionDB.sanitize_title("hello\u200dworld") == "helloworld"
|
||||||
|
|
||||||
|
def test_rtl_override_stripped(self):
|
||||||
|
# Right-to-left override (U+202E) — used in filename spoofing attacks
|
||||||
|
assert SessionDB.sanitize_title("hello\u202eworld") == "helloworld"
|
||||||
|
|
||||||
|
def test_bom_stripped(self):
|
||||||
|
# Byte order mark (U+FEFF)
|
||||||
|
assert SessionDB.sanitize_title("\ufeffhello") == "hello"
|
||||||
|
|
||||||
|
def test_only_control_chars_returns_none(self):
|
||||||
|
assert SessionDB.sanitize_title("\x00\x01\x02\u200b\ufeff") is None
|
||||||
|
|
||||||
|
def test_max_length_allowed(self):
|
||||||
|
title = "A" * 100
|
||||||
|
assert SessionDB.sanitize_title(title) == title
|
||||||
|
|
||||||
|
def test_exceeds_max_length_raises(self):
|
||||||
|
title = "A" * 101
|
||||||
|
with pytest.raises(ValueError, match="too long"):
|
||||||
|
SessionDB.sanitize_title(title)
|
||||||
|
|
||||||
|
def test_unicode_emoji_allowed(self):
|
||||||
|
assert SessionDB.sanitize_title("🚀 My Project 🎉") == "🚀 My Project 🎉"
|
||||||
|
|
||||||
|
def test_cjk_characters_allowed(self):
|
||||||
|
assert SessionDB.sanitize_title("我的项目") == "我的项目"
|
||||||
|
|
||||||
|
def test_accented_characters_allowed(self):
|
||||||
|
assert SessionDB.sanitize_title("Résumé éditing") == "Résumé éditing"
|
||||||
|
|
||||||
|
def test_special_punctuation_allowed(self):
|
||||||
|
title = "PR #438 — fixing the 'auth' middleware"
|
||||||
|
assert SessionDB.sanitize_title(title) == title
|
||||||
|
|
||||||
|
def test_sanitize_applied_in_set_session_title(self, db):
|
||||||
|
"""set_session_title applies sanitize_title internally."""
|
||||||
|
db.create_session("s1", "cli")
|
||||||
|
db.set_session_title("s1", " hello\x00 world ")
|
||||||
|
assert db.get_session("s1")["title"] == "hello world"
|
||||||
|
|
||||||
|
def test_too_long_title_rejected_by_set(self, db):
|
||||||
|
"""set_session_title raises ValueError for overly long titles."""
|
||||||
|
db.create_session("s1", "cli")
|
||||||
|
with pytest.raises(ValueError, match="too long"):
|
||||||
|
db.set_session_title("s1", "X" * 150)
|
||||||
|
|
||||||
|
|
||||||
class TestSchemaInit:
|
class TestSchemaInit:
|
||||||
def test_wal_mode(self, db):
|
def test_wal_mode(self, db):
|
||||||
cursor = db._conn.execute("PRAGMA journal_mode")
|
cursor = db._conn.execute("PRAGMA journal_mode")
|
||||||
|
|
@ -373,4 +540,297 @@ class TestSchemaInit:
|
||||||
def test_schema_version(self, db):
|
def test_schema_version(self, db):
|
||||||
cursor = db._conn.execute("SELECT version FROM schema_version")
|
cursor = db._conn.execute("SELECT version FROM schema_version")
|
||||||
version = cursor.fetchone()[0]
|
version = cursor.fetchone()[0]
|
||||||
assert version == 2
|
assert version == 4
|
||||||
|
|
||||||
|
def test_title_column_exists(self, db):
|
||||||
|
"""Verify the title column was created in the sessions table."""
|
||||||
|
cursor = db._conn.execute("PRAGMA table_info(sessions)")
|
||||||
|
columns = {row[1] for row in cursor.fetchall()}
|
||||||
|
assert "title" in columns
|
||||||
|
|
||||||
|
def test_migration_from_v2(self, tmp_path):
|
||||||
|
"""Simulate a v2 database and verify migration adds title column."""
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
db_path = tmp_path / "migrate_test.db"
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
# Create v2 schema (without title column)
|
||||||
|
conn.executescript("""
|
||||||
|
CREATE TABLE schema_version (version INTEGER NOT NULL);
|
||||||
|
INSERT INTO schema_version (version) VALUES (2);
|
||||||
|
|
||||||
|
CREATE TABLE sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
user_id TEXT,
|
||||||
|
model TEXT,
|
||||||
|
model_config TEXT,
|
||||||
|
system_prompt TEXT,
|
||||||
|
parent_session_id TEXT,
|
||||||
|
started_at REAL NOT NULL,
|
||||||
|
ended_at REAL,
|
||||||
|
end_reason TEXT,
|
||||||
|
message_count INTEGER DEFAULT 0,
|
||||||
|
tool_call_count INTEGER DEFAULT 0,
|
||||||
|
input_tokens INTEGER DEFAULT 0,
|
||||||
|
output_tokens INTEGER DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE messages (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL,
|
||||||
|
content TEXT,
|
||||||
|
tool_call_id TEXT,
|
||||||
|
tool_calls TEXT,
|
||||||
|
tool_name TEXT,
|
||||||
|
timestamp REAL NOT NULL,
|
||||||
|
token_count INTEGER,
|
||||||
|
finish_reason TEXT
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO sessions (id, source, started_at) VALUES (?, ?, ?)",
|
||||||
|
("existing", "cli", 1000.0),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# Open with SessionDB — should migrate to v4
|
||||||
|
migrated_db = SessionDB(db_path=db_path)
|
||||||
|
|
||||||
|
# Verify migration
|
||||||
|
cursor = migrated_db._conn.execute("SELECT version FROM schema_version")
|
||||||
|
assert cursor.fetchone()[0] == 4
|
||||||
|
|
||||||
|
# Verify title column exists and is NULL for existing sessions
|
||||||
|
session = migrated_db.get_session("existing")
|
||||||
|
assert session is not None
|
||||||
|
assert session["title"] is None
|
||||||
|
|
||||||
|
# Verify we can set title on migrated session
|
||||||
|
assert migrated_db.set_session_title("existing", "Migrated Title") is True
|
||||||
|
session = migrated_db.get_session("existing")
|
||||||
|
assert session["title"] == "Migrated Title"
|
||||||
|
|
||||||
|
migrated_db.close()
|
||||||
|
|
||||||
|
|
||||||
|
class TestTitleUniqueness:
|
||||||
|
"""Tests for unique title enforcement and title-based lookups."""
|
||||||
|
|
||||||
|
def test_duplicate_title_raises(self, db):
|
||||||
|
"""Setting a title already used by another session raises ValueError."""
|
||||||
|
db.create_session("s1", "cli")
|
||||||
|
db.create_session("s2", "cli")
|
||||||
|
db.set_session_title("s1", "my project")
|
||||||
|
with pytest.raises(ValueError, match="already in use"):
|
||||||
|
db.set_session_title("s2", "my project")
|
||||||
|
|
||||||
|
def test_same_session_can_keep_title(self, db):
|
||||||
|
"""A session can re-set its own title without error."""
|
||||||
|
db.create_session("s1", "cli")
|
||||||
|
db.set_session_title("s1", "my project")
|
||||||
|
# Should not raise — it's the same session
|
||||||
|
assert db.set_session_title("s1", "my project") is True
|
||||||
|
|
||||||
|
def test_null_titles_not_unique(self, db):
|
||||||
|
"""Multiple sessions can have NULL titles (no constraint violation)."""
|
||||||
|
db.create_session("s1", "cli")
|
||||||
|
db.create_session("s2", "cli")
|
||||||
|
# Both have NULL titles — no error
|
||||||
|
assert db.get_session("s1")["title"] is None
|
||||||
|
assert db.get_session("s2")["title"] is None
|
||||||
|
|
||||||
|
def test_get_session_by_title(self, db):
|
||||||
|
db.create_session("s1", "cli")
|
||||||
|
db.set_session_title("s1", "refactoring auth")
|
||||||
|
result = db.get_session_by_title("refactoring auth")
|
||||||
|
assert result is not None
|
||||||
|
assert result["id"] == "s1"
|
||||||
|
|
||||||
|
def test_get_session_by_title_not_found(self, db):
|
||||||
|
assert db.get_session_by_title("nonexistent") is None
|
||||||
|
|
||||||
|
def test_get_session_title(self, db):
|
||||||
|
db.create_session("s1", "cli")
|
||||||
|
assert db.get_session_title("s1") is None
|
||||||
|
db.set_session_title("s1", "my title")
|
||||||
|
assert db.get_session_title("s1") == "my title"
|
||||||
|
|
||||||
|
def test_get_session_title_nonexistent(self, db):
|
||||||
|
assert db.get_session_title("nonexistent") is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestTitleLineage:
|
||||||
|
"""Tests for title lineage resolution and auto-numbering."""
|
||||||
|
|
||||||
|
def test_resolve_exact_title(self, db):
|
||||||
|
db.create_session("s1", "cli")
|
||||||
|
db.set_session_title("s1", "my project")
|
||||||
|
assert db.resolve_session_by_title("my project") == "s1"
|
||||||
|
|
||||||
|
def test_resolve_returns_latest_numbered(self, db):
|
||||||
|
"""When numbered variants exist, return the most recent one."""
|
||||||
|
import time
|
||||||
|
db.create_session("s1", "cli")
|
||||||
|
db.set_session_title("s1", "my project")
|
||||||
|
time.sleep(0.01)
|
||||||
|
db.create_session("s2", "cli")
|
||||||
|
db.set_session_title("s2", "my project #2")
|
||||||
|
time.sleep(0.01)
|
||||||
|
db.create_session("s3", "cli")
|
||||||
|
db.set_session_title("s3", "my project #3")
|
||||||
|
# Resolving "my project" should return s3 (latest numbered variant)
|
||||||
|
assert db.resolve_session_by_title("my project") == "s3"
|
||||||
|
|
||||||
|
def test_resolve_exact_numbered(self, db):
|
||||||
|
"""Resolving an exact numbered title returns that specific session."""
|
||||||
|
db.create_session("s1", "cli")
|
||||||
|
db.set_session_title("s1", "my project")
|
||||||
|
db.create_session("s2", "cli")
|
||||||
|
db.set_session_title("s2", "my project #2")
|
||||||
|
# Resolving "my project #2" exactly should return s2
|
||||||
|
assert db.resolve_session_by_title("my project #2") == "s2"
|
||||||
|
|
||||||
|
def test_resolve_nonexistent_title(self, db):
|
||||||
|
assert db.resolve_session_by_title("nonexistent") is None
|
||||||
|
|
||||||
|
def test_next_title_no_existing(self, db):
|
||||||
|
"""With no existing sessions, base title is returned as-is."""
|
||||||
|
assert db.get_next_title_in_lineage("my project") == "my project"
|
||||||
|
|
||||||
|
def test_next_title_first_continuation(self, db):
|
||||||
|
"""First continuation after the original gets #2."""
|
||||||
|
db.create_session("s1", "cli")
|
||||||
|
db.set_session_title("s1", "my project")
|
||||||
|
assert db.get_next_title_in_lineage("my project") == "my project #2"
|
||||||
|
|
||||||
|
def test_next_title_increments(self, db):
|
||||||
|
"""Each continuation increments the number."""
|
||||||
|
db.create_session("s1", "cli")
|
||||||
|
db.set_session_title("s1", "my project")
|
||||||
|
db.create_session("s2", "cli")
|
||||||
|
db.set_session_title("s2", "my project #2")
|
||||||
|
db.create_session("s3", "cli")
|
||||||
|
db.set_session_title("s3", "my project #3")
|
||||||
|
assert db.get_next_title_in_lineage("my project") == "my project #4"
|
||||||
|
|
||||||
|
def test_next_title_strips_existing_number(self, db):
|
||||||
|
"""Passing a numbered title strips the number and finds the base."""
|
||||||
|
db.create_session("s1", "cli")
|
||||||
|
db.set_session_title("s1", "my project")
|
||||||
|
db.create_session("s2", "cli")
|
||||||
|
db.set_session_title("s2", "my project #2")
|
||||||
|
# Even when called with "my project #2", it should return #3
|
||||||
|
assert db.get_next_title_in_lineage("my project #2") == "my project #3"
|
||||||
|
|
||||||
|
|
||||||
|
class TestTitleSqlWildcards:
|
||||||
|
"""Titles containing SQL LIKE wildcards (%, _) must not cause false matches."""
|
||||||
|
|
||||||
|
def test_resolve_title_with_underscore(self, db):
|
||||||
|
"""A title like 'test_project' should not match 'testXproject #2'."""
|
||||||
|
db.create_session("s1", "cli")
|
||||||
|
db.set_session_title("s1", "test_project")
|
||||||
|
db.create_session("s2", "cli")
|
||||||
|
db.set_session_title("s2", "testXproject #2")
|
||||||
|
# Resolving "test_project" should return s1 (exact), not s2
|
||||||
|
assert db.resolve_session_by_title("test_project") == "s1"
|
||||||
|
|
||||||
|
def test_resolve_title_with_percent(self, db):
|
||||||
|
"""A title with '%' should not wildcard-match unrelated sessions."""
|
||||||
|
db.create_session("s1", "cli")
|
||||||
|
db.set_session_title("s1", "100% done")
|
||||||
|
db.create_session("s2", "cli")
|
||||||
|
db.set_session_title("s2", "100X done #2")
|
||||||
|
# Should resolve to s1 (exact), not s2
|
||||||
|
assert db.resolve_session_by_title("100% done") == "s1"
|
||||||
|
|
||||||
|
def test_next_lineage_with_underscore(self, db):
|
||||||
|
"""get_next_title_in_lineage with underscores doesn't match wrong sessions."""
|
||||||
|
db.create_session("s1", "cli")
|
||||||
|
db.set_session_title("s1", "test_project")
|
||||||
|
db.create_session("s2", "cli")
|
||||||
|
db.set_session_title("s2", "testXproject #2")
|
||||||
|
# Only "test_project" exists, so next should be "test_project #2"
|
||||||
|
assert db.get_next_title_in_lineage("test_project") == "test_project #2"
|
||||||
|
|
||||||
|
|
||||||
|
class TestListSessionsRich:
|
||||||
|
"""Tests for enhanced session listing with preview and last_active."""
|
||||||
|
|
||||||
|
def test_preview_from_first_user_message(self, db):
|
||||||
|
db.create_session("s1", "cli")
|
||||||
|
db.append_message("s1", "system", "You are a helpful assistant.")
|
||||||
|
db.append_message("s1", "user", "Help me refactor the auth module please")
|
||||||
|
db.append_message("s1", "assistant", "Sure, let me look at it.")
|
||||||
|
sessions = db.list_sessions_rich()
|
||||||
|
assert len(sessions) == 1
|
||||||
|
assert "Help me refactor the auth module" in sessions[0]["preview"]
|
||||||
|
|
||||||
|
def test_preview_truncated_at_60(self, db):
|
||||||
|
db.create_session("s1", "cli")
|
||||||
|
long_msg = "A" * 100
|
||||||
|
db.append_message("s1", "user", long_msg)
|
||||||
|
sessions = db.list_sessions_rich()
|
||||||
|
assert len(sessions[0]["preview"]) == 63 # 60 chars + "..."
|
||||||
|
assert sessions[0]["preview"].endswith("...")
|
||||||
|
|
||||||
|
def test_preview_empty_when_no_user_messages(self, db):
|
||||||
|
db.create_session("s1", "cli")
|
||||||
|
db.append_message("s1", "system", "System prompt")
|
||||||
|
sessions = db.list_sessions_rich()
|
||||||
|
assert sessions[0]["preview"] == ""
|
||||||
|
|
||||||
|
def test_last_active_from_latest_message(self, db):
|
||||||
|
import time
|
||||||
|
db.create_session("s1", "cli")
|
||||||
|
db.append_message("s1", "user", "Hello")
|
||||||
|
time.sleep(0.01)
|
||||||
|
db.append_message("s1", "assistant", "Hi there!")
|
||||||
|
sessions = db.list_sessions_rich()
|
||||||
|
# last_active should be close to now (the assistant message)
|
||||||
|
assert sessions[0]["last_active"] > sessions[0]["started_at"]
|
||||||
|
|
||||||
|
def test_last_active_fallback_to_started_at(self, db):
|
||||||
|
db.create_session("s1", "cli")
|
||||||
|
sessions = db.list_sessions_rich()
|
||||||
|
# No messages, so last_active falls back to started_at
|
||||||
|
assert sessions[0]["last_active"] == sessions[0]["started_at"]
|
||||||
|
|
||||||
|
def test_rich_list_includes_title(self, db):
|
||||||
|
db.create_session("s1", "cli")
|
||||||
|
db.set_session_title("s1", "refactoring auth")
|
||||||
|
sessions = db.list_sessions_rich()
|
||||||
|
assert sessions[0]["title"] == "refactoring auth"
|
||||||
|
|
||||||
|
def test_rich_list_source_filter(self, db):
|
||||||
|
db.create_session("s1", "cli")
|
||||||
|
db.create_session("s2", "telegram")
|
||||||
|
sessions = db.list_sessions_rich(source="cli")
|
||||||
|
assert len(sessions) == 1
|
||||||
|
assert sessions[0]["id"] == "s1"
|
||||||
|
|
||||||
|
def test_preview_newlines_collapsed(self, db):
|
||||||
|
db.create_session("s1", "cli")
|
||||||
|
db.append_message("s1", "user", "Line one\nLine two\nLine three")
|
||||||
|
sessions = db.list_sessions_rich()
|
||||||
|
assert "\n" not in sessions[0]["preview"]
|
||||||
|
assert "Line one Line two" in sessions[0]["preview"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveSessionByNameOrId:
|
||||||
|
"""Tests for the main.py helper that resolves names or IDs."""
|
||||||
|
|
||||||
|
def test_resolve_by_id(self, db):
|
||||||
|
db.create_session("test-id-123", "cli")
|
||||||
|
session = db.get_session("test-id-123")
|
||||||
|
assert session is not None
|
||||||
|
assert session["id"] == "test-id-123"
|
||||||
|
|
||||||
|
def test_resolve_by_title_falls_back(self, db):
|
||||||
|
db.create_session("s1", "cli")
|
||||||
|
db.set_session_title("s1", "my project")
|
||||||
|
result = db.resolve_session_by_title("my project")
|
||||||
|
assert result == "s1"
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,8 @@ These are commands you run from your shell.
|
||||||
| `hermes` | Start interactive chat (default) |
|
| `hermes` | Start interactive chat (default) |
|
||||||
| `hermes chat -q "Hello"` | Single query mode (non-interactive) |
|
| `hermes chat -q "Hello"` | Single query mode (non-interactive) |
|
||||||
| `hermes chat --continue` / `-c` | Resume the most recent session |
|
| `hermes chat --continue` / `-c` | Resume the most recent session |
|
||||||
| `hermes chat --resume <id>` / `-r <id>` | Resume a specific session |
|
| `hermes chat -c "my project"` | Resume a session by name (latest in lineage) |
|
||||||
|
| `hermes chat --resume <id>` / `-r <id>` | Resume a specific session by ID or title |
|
||||||
| `hermes chat --model <name>` | Use a specific model |
|
| `hermes chat --model <name>` | Use a specific model |
|
||||||
| `hermes chat --provider <name>` | Force a provider (`nous`, `openrouter`, `zai`, `kimi-coding`, `minimax`, `minimax-cn`) |
|
| `hermes chat --provider <name>` | Force a provider (`nous`, `openrouter`, `zai`, `kimi-coding`, `minimax`, `minimax-cn`) |
|
||||||
| `hermes chat --toolsets "web,terminal"` / `-t` | Use specific toolsets |
|
| `hermes chat --toolsets "web,terminal"` / `-t` | Use specific toolsets |
|
||||||
|
|
@ -103,7 +104,8 @@ These are commands you run from your shell.
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| `hermes sessions list` | Browse past sessions |
|
| `hermes sessions list` | Browse past sessions (shows title, preview, last active) |
|
||||||
|
| `hermes sessions rename <id> <title>` | Set or change a session's title |
|
||||||
| `hermes sessions export <id>` | Export a session |
|
| `hermes sessions export <id>` | Export a session |
|
||||||
| `hermes sessions delete <id>` | Delete a specific session |
|
| `hermes sessions delete <id>` | Delete a specific session |
|
||||||
| `hermes sessions prune` | Remove old sessions |
|
| `hermes sessions prune` | Remove old sessions |
|
||||||
|
|
@ -154,6 +156,7 @@ Type `/` in the interactive CLI to see an autocomplete dropdown.
|
||||||
| `/undo` | Remove the last user/assistant exchange |
|
| `/undo` | Remove the last user/assistant exchange |
|
||||||
| `/save` | Save the current conversation |
|
| `/save` | Save the current conversation |
|
||||||
| `/compress` | Manually compress conversation context |
|
| `/compress` | Manually compress conversation context |
|
||||||
|
| `/title [name]` | Set or show the current session's title |
|
||||||
| `/usage` | Show token usage for this session |
|
| `/usage` | Show token usage for this session |
|
||||||
| `/insights [--days N]` | Show usage insights and analytics (last 30 days) |
|
| `/insights [--days N]` | Show usage insights and analytics (last 30 days) |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -229,13 +229,15 @@ Resume options:
|
||||||
```bash
|
```bash
|
||||||
hermes --continue # Resume the most recent CLI session
|
hermes --continue # Resume the most recent CLI session
|
||||||
hermes -c # Short form
|
hermes -c # Short form
|
||||||
|
hermes -c "my project" # Resume a named session (latest in lineage)
|
||||||
hermes --resume 20260225_143052_a1b2c3 # Resume a specific session by ID
|
hermes --resume 20260225_143052_a1b2c3 # Resume a specific session by ID
|
||||||
|
hermes --resume "refactoring auth" # Resume by title
|
||||||
hermes -r 20260225_143052_a1b2c3 # Short form
|
hermes -r 20260225_143052_a1b2c3 # Short form
|
||||||
```
|
```
|
||||||
|
|
||||||
Resuming restores the full conversation history from SQLite. The agent sees all previous messages, tool calls, and responses — just as if you never left.
|
Resuming restores the full conversation history from SQLite. The agent sees all previous messages, tool calls, and responses — just as if you never left.
|
||||||
|
|
||||||
Use `hermes sessions list` to browse past sessions.
|
Use `/title My Session Name` inside a chat to name the current session, or `hermes sessions rename <id> <title>` from the command line. Use `hermes sessions list` to browse past sessions.
|
||||||
|
|
||||||
### Session Logging
|
### Session Logging
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ Every conversation — whether from the CLI, Telegram, Discord, WhatsApp, or Sla
|
||||||
|
|
||||||
The SQLite database stores:
|
The SQLite database stores:
|
||||||
- Session ID, source platform, user ID
|
- Session ID, source platform, user ID
|
||||||
|
- **Session title** (unique, human-readable name)
|
||||||
- Model name and configuration
|
- Model name and configuration
|
||||||
- System prompt snapshot
|
- System prompt snapshot
|
||||||
- Full message history (role, content, tool calls, tool results)
|
- Full message history (role, content, tool calls, tool results)
|
||||||
|
|
@ -54,6 +55,19 @@ hermes chat -c
|
||||||
|
|
||||||
This looks up the most recent `cli` session from the SQLite database and loads its full conversation history.
|
This looks up the most recent `cli` session from the SQLite database and loads its full conversation history.
|
||||||
|
|
||||||
|
### Resume by Name
|
||||||
|
|
||||||
|
If you've given a session a title (see [Session Naming](#session-naming) below), you can resume it by name:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Resume a named session
|
||||||
|
hermes -c "my project"
|
||||||
|
|
||||||
|
# If there are lineage variants (my project, my project #2, my project #3),
|
||||||
|
# this automatically resumes the most recent one
|
||||||
|
hermes -c "my project" # → resumes "my project #3"
|
||||||
|
```
|
||||||
|
|
||||||
### Resume Specific Session
|
### Resume Specific Session
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -61,6 +75,9 @@ This looks up the most recent `cli` session from the SQLite database and loads i
|
||||||
hermes --resume 20250305_091523_a1b2c3d4
|
hermes --resume 20250305_091523_a1b2c3d4
|
||||||
hermes -r 20250305_091523_a1b2c3d4
|
hermes -r 20250305_091523_a1b2c3d4
|
||||||
|
|
||||||
|
# Resume by title
|
||||||
|
hermes --resume "refactoring auth"
|
||||||
|
|
||||||
# Or with the chat subcommand
|
# Or with the chat subcommand
|
||||||
hermes chat --resume 20250305_091523_a1b2c3d4
|
hermes chat --resume 20250305_091523_a1b2c3d4
|
||||||
```
|
```
|
||||||
|
|
@ -68,9 +85,53 @@ hermes chat --resume 20250305_091523_a1b2c3d4
|
||||||
Session IDs are shown when you exit a CLI session, and can be found with `hermes sessions list`.
|
Session IDs are shown when you exit a CLI session, and can be found with `hermes sessions list`.
|
||||||
|
|
||||||
:::tip
|
:::tip
|
||||||
Session IDs follow the format `YYYYMMDD_HHMMSS_<8-char-hex>`, e.g. `20250305_091523_a1b2c3d4`. You only need to provide enough of the ID to be unique.
|
Session IDs follow the format `YYYYMMDD_HHMMSS_<8-char-hex>`, e.g. `20250305_091523_a1b2c3d4`. You can resume by ID or by title — both work with `-c` and `-r`.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
## Session Naming
|
||||||
|
|
||||||
|
Give sessions human-readable titles so you can find and resume them easily.
|
||||||
|
|
||||||
|
### Setting a Title
|
||||||
|
|
||||||
|
Use the `/title` slash command inside any chat session (CLI or gateway):
|
||||||
|
|
||||||
|
```
|
||||||
|
/title my research project
|
||||||
|
```
|
||||||
|
|
||||||
|
The title is applied immediately. If the session hasn't been created in the database yet (e.g., you run `/title` before sending your first message), it's queued and applied once the session starts.
|
||||||
|
|
||||||
|
You can also rename existing sessions from the command line:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hermes sessions rename 20250305_091523_a1b2c3d4 "refactoring auth module"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Title Rules
|
||||||
|
|
||||||
|
- **Unique** — no two sessions can share the same title
|
||||||
|
- **Max 100 characters** — keeps listing output clean
|
||||||
|
- **Sanitized** — control characters, zero-width chars, and RTL overrides are stripped automatically
|
||||||
|
- **Normal Unicode is fine** — emoji, CJK, accented characters all work
|
||||||
|
|
||||||
|
### Auto-Lineage on Compression
|
||||||
|
|
||||||
|
When a session's context is compressed (manually via `/compress` or automatically), Hermes creates a new continuation session. If the original had a title, the new session automatically gets a numbered title:
|
||||||
|
|
||||||
|
```
|
||||||
|
"my project" → "my project #2" → "my project #3"
|
||||||
|
```
|
||||||
|
|
||||||
|
When you resume by name (`hermes -c "my project"`), it automatically picks the most recent session in the lineage.
|
||||||
|
|
||||||
|
### /title in Messaging Platforms
|
||||||
|
|
||||||
|
The `/title` command works in all gateway platforms (Telegram, Discord, Slack, WhatsApp):
|
||||||
|
|
||||||
|
- `/title My Research` — set the session title
|
||||||
|
- `/title` — show the current title
|
||||||
|
|
||||||
## Session Management Commands
|
## Session Management Commands
|
||||||
|
|
||||||
Hermes provides a full set of session management commands via `hermes sessions`:
|
Hermes provides a full set of session management commands via `hermes sessions`:
|
||||||
|
|
@ -88,13 +149,23 @@ hermes sessions list --source telegram
|
||||||
hermes sessions list --limit 50
|
hermes sessions list --limit 50
|
||||||
```
|
```
|
||||||
|
|
||||||
Output format:
|
When sessions have titles, the output shows titles, previews, and relative timestamps:
|
||||||
|
|
||||||
```
|
```
|
||||||
ID Source Model Messages Started
|
Title Preview Last Active ID
|
||||||
────────────────────────────────────────────────────────────────────────────────────────────────
|
────────────────────────────────────────────────────────────────────────────────────────────────
|
||||||
20250305_091523_a1b2c3d4 cli anthropic/claude-opus-4.6 24 2025-03-05 09:15
|
refactoring auth Help me refactor the auth module please 2h ago 20250305_091523_a
|
||||||
20250304_143022_e5f6g7h8 telegram anthropic/claude-opus-4.6 12 2025-03-04 14:30 (ended)
|
my project #3 Can you check the test failures? yesterday 20250304_143022_e
|
||||||
|
— What's the weather in Las Vegas? 3d ago 20250303_101500_f
|
||||||
|
```
|
||||||
|
|
||||||
|
When no sessions have titles, a simpler format is used:
|
||||||
|
|
||||||
|
```
|
||||||
|
Preview Last Active Src ID
|
||||||
|
──────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
Help me refactor the auth module please 2h ago cli 20250305_091523_a
|
||||||
|
What's the weather in Las Vegas? 3d ago tele 20250303_101500_f
|
||||||
```
|
```
|
||||||
|
|
||||||
### Export Sessions
|
### Export Sessions
|
||||||
|
|
@ -122,6 +193,18 @@ hermes sessions delete 20250305_091523_a1b2c3d4
|
||||||
hermes sessions delete 20250305_091523_a1b2c3d4 --yes
|
hermes sessions delete 20250305_091523_a1b2c3d4 --yes
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Rename a Session
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Set or change a session's title
|
||||||
|
hermes sessions rename 20250305_091523_a1b2c3d4 "debugging auth flow"
|
||||||
|
|
||||||
|
# Multi-word titles don't need quotes in the CLI
|
||||||
|
hermes sessions rename 20250305_091523_a1b2c3d4 debugging auth flow
|
||||||
|
```
|
||||||
|
|
||||||
|
If the title is already in use by another session, an error is shown.
|
||||||
|
|
||||||
### Prune Old Sessions
|
### Prune Old Sessions
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -233,7 +316,7 @@ The SQLite database uses WAL mode for concurrent readers and a single writer, wh
|
||||||
|
|
||||||
Key tables in `state.db`:
|
Key tables in `state.db`:
|
||||||
|
|
||||||
- **sessions** — session metadata (id, source, user_id, model, timestamps, token counts)
|
- **sessions** — session metadata (id, source, user_id, model, title, timestamps, token counts). Titles have a unique index (NULL titles allowed, only non-NULL must be unique).
|
||||||
- **messages** — full message history (role, content, tool_calls, tool_name, token_count)
|
- **messages** — full message history (role, content, tool_calls, tool_name, token_count)
|
||||||
- **messages_fts** — FTS5 virtual table for full-text search across message content
|
- **messages_fts** — FTS5 virtual table for full-text search across message content
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue