From 60b6abefd98f1aaec351c859a1dacfa37b6b2335 Mon Sep 17 00:00:00 2001 From: teknium1 Date: Sun, 8 Mar 2026 15:20:29 -0700 Subject: [PATCH 1/4] feat: session naming with unique titles, auto-lineage, rich listing, resume by name - Schema v4: unique title index, migration from v2/v3 - set/get/resolve session titles with uniqueness enforcement - Auto-lineage: context compression auto-numbers titles (Task -> Task #2 -> Task #3) - resolve_session_by_title: auto-latest finds most recent continuation - list_sessions_rich: preview (first 60 chars) + last_active timestamp - CLI: -c accepts optional name arg (hermes -c 'my project') - CLI: /title command with deferred mode (set before session exists) - CLI: sessions list shows Title, Preview, Last Active, ID - 27 new tests (1844 total passing) --- cli.py | 79 ++++++- hermes_cli/commands.py | 1 + hermes_cli/main.py | 150 ++++++++++--- hermes_state.py | 176 +++++++++++++++- run_agent.py | 9 + tests/hermes_cli/test_commands.py | 2 +- tests/test_hermes_state.py | 335 +++++++++++++++++++++++++++++- 7 files changed, 716 insertions(+), 36 deletions(-) diff --git a/cli.py b/cli.py index a326c93d..6fadd06a 100755 --- a/cli.py +++ b/cli.py @@ -1094,6 +1094,16 @@ class HermesCLI: self.conversation_history: List[Dict[str, Any]] = [] self.session_start = datetime.now() 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 if resume: @@ -1181,13 +1191,13 @@ class HermesCLI: if not self._ensure_runtime_credentials(): return False - # Initialize SQLite session store for CLI sessions - self._session_db = None - try: - from hermes_state import SessionDB - self._session_db = SessionDB() - except Exception as e: - logger.debug("SQLite session store not available: %s", e) + # Initialize SQLite session store for CLI sessions (if not already done in __init__) + if self._session_db is None: + try: + from hermes_state import SessionDB + self._session_db = SessionDB() + except Exception as e: + logger.debug("SQLite session store not available: %s", e) # If resuming, validate the session exists and load its history if self._resumed and self._session_db: @@ -1200,8 +1210,11 @@ class HermesCLI: if restored: self.conversation_history = restored 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( - 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"{len(restored)} total messages){_RST}" ) @@ -1243,6 +1256,15 @@ class HermesCLI: clarify_callback=self._clarify_callback, 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 except Exception as e: self.console.print(f"[bold red]Failed to initialize agent: {e}[/]") @@ -2091,6 +2113,47 @@ class HermesCLI: print(" ✨ (◕‿◕)✨ Fresh start! Screen cleared and conversation reset.\n") elif cmd_lower == "/history": self.show_history() + elif cmd_lower.startswith("/title"): + parts = cmd_original.split(maxsplit=1) + if len(parts) > 1: + new_title = parts[1].strip() + if new_title: + if self._session_db: + # Check if session exists in DB yet + session = self._session_db.get_session(self.session_id) + if session: + 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 + 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 ") + 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 ") + else: + _cprint(" Session database not available.") elif cmd_lower in ("/reset", "/new"): self.reset_conversation() elif cmd_lower.startswith("/model"): diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 61c5864f..20f01b17 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -34,6 +34,7 @@ COMMANDS = { "/platforms": "Show gateway/messaging platform status", "/verbose": "Cycle tool progress display: off → new → all → verbose", "/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", "/insights": "Show usage insights and analytics (last 30 days)", "/paste": "Check clipboard for an image and attach it", diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 20f33998..5ba09c35 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -120,16 +120,63 @@ def _resolve_last_cli_session() -> Optional[str]: 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): """Run interactive chat CLI.""" - # Resolve --continue into --resume with the latest CLI session - if getattr(args, "continue_last", False) and not getattr(args, "resume", None): - last_id = _resolve_last_cli_session() - if last_id: - args.resume = last_id + # Resolve --continue into --resume with the latest CLI session or by name + continue_val = getattr(args, "continue_last", None) + if continue_val and not getattr(args, "resume", None): + if isinstance(continue_val, str): + # -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: - print("No previous CLI session found to continue.") - sys.exit(1) + # -c with no argument — continue the most recent session + 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 if not _has_any_provider_configured(): @@ -1209,8 +1256,9 @@ def main(): Examples: hermes Start interactive chat hermes chat -q "Hello" Single query mode - hermes --continue Resume the most recent session - hermes --resume Resume a specific session + hermes -c Resume the most recent session + hermes -c "my project" Resume a session by name (latest in lineage) + hermes --resume Resume a specific session by ID hermes setup Run setup wizard hermes logout Clear stored authentication hermes model Select default model @@ -1221,6 +1269,7 @@ Examples: hermes -w Start in isolated git worktree hermes gateway install Install as system service hermes sessions list List past sessions + hermes sessions rename ID T Rename/title a session hermes update Update to latest version For more help on a command: @@ -1235,16 +1284,18 @@ For more help on a command: ) parser.add_argument( "--resume", "-r", - metavar="SESSION_ID", + metavar="SESSION", 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( "--continue", "-c", dest="continue_last", - action="store_true", - default=False, - help="Resume the most recent CLI session" + nargs="?", + const=True, + default=None, + metavar="SESSION_NAME", + help="Resume a session by name, or the most recent if no name given" ) parser.add_argument( "--worktree", "-w", @@ -1294,9 +1345,11 @@ For more help on a command: chat_parser.add_argument( "--continue", "-c", dest="continue_last", - action="store_true", - default=False, - help="Resume the most recent CLI session" + nargs="?", + const=True, + default=None, + metavar="SESSION_NAME", + help="Resume a session by name, or the most recent if no name given" ) chat_parser.add_argument( "--worktree", "-w", @@ -1696,6 +1749,10 @@ For more help on a command: 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): import json as _json try: @@ -1708,18 +1765,51 @@ For more help on a command: action = args.sessions_action 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: print("No sessions found.") return - print(f"{'ID':<30} {'Source':<12} {'Model':<30} {'Messages':>8} {'Started'}") - print("─" * 100) 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: - started = datetime.fromtimestamp(s["started_at"]).strftime("%Y-%m-%d %H:%M") if s["started_at"] else "?" - model = (s.get("model") or "?")[:28] - ended = " (ended)" if s.get("ended_at") else "" - print(f"{s['id']:<30} {s['source']:<12} {model:<30} {s['message_count']:>8} {started}{ended}") + last_active = _relative_time(s.get("last_active")) + preview = s.get("preview", "")[:38] if has_titles else s.get("preview", "")[:48] + if has_titles: + 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": 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) 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": total = db.session_count() msgs = db.message_count() @@ -1877,7 +1977,7 @@ For more help on a command: args.toolsets = None args.verbose = False args.resume = None - args.continue_last = False + args.continue_last = None if not hasattr(args, "worktree"): args.worktree = False cmd_chat(args) diff --git a/hermes_state.py b/hermes_state.py index 1d1f951c..df266f07 100644 --- a/hermes_state.py +++ b/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" -SCHEMA_VERSION = 2 +SCHEMA_VERSION = 4 SCHEMA_SQL = """ CREATE TABLE IF NOT EXISTS schema_version ( @@ -46,6 +46,7 @@ CREATE TABLE IF NOT EXISTS sessions ( tool_call_count INTEGER DEFAULT 0, input_tokens INTEGER DEFAULT 0, output_tokens INTEGER DEFAULT 0, + title TEXT, FOREIGN KEY (parent_session_id) REFERENCES sessions(id) ); @@ -133,7 +134,33 @@ class SessionDB: except sqlite3.OperationalError: pass # Column already exists 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) try: @@ -219,6 +246,153 @@ class SessionDB: row = cursor.fetchone() return dict(row) if row else None + 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. + """ + 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. + cursor = self._conn.execute( + "SELECT id, title, started_at FROM sessions " + "WHERE title LIKE ? ORDER BY started_at DESC", + (f"{title} #%",), + ) + 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 + cursor = self._conn.execute( + "SELECT title FROM sessions WHERE title = ? OR title LIKE ?", + (base, f"{base} #%"), + ) + 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). + """ + if source: + cursor = self._conn.execute( + "SELECT * FROM sessions WHERE source = ? ORDER BY started_at DESC LIMIT ? OFFSET ?", + (source, limit, offset), + ) + else: + cursor = self._conn.execute( + "SELECT * FROM sessions ORDER BY started_at DESC LIMIT ? OFFSET ?", + (limit, offset), + ) + sessions = [dict(row) for row in cursor.fetchall()] + + for s in sessions: + # Get first user message preview + preview_cursor = self._conn.execute( + "SELECT content FROM messages WHERE session_id = ? AND role = 'user' " + "ORDER BY timestamp, id LIMIT 1", + (s["id"],), + ) + preview_row = preview_cursor.fetchone() + if preview_row and preview_row["content"]: + text = preview_row["content"].replace("\n", " ").strip() + s["preview"] = text[:60] + ("..." if len(text) > 60 else "") + else: + s["preview"] = "" + + # Get last message timestamp + last_cursor = self._conn.execute( + "SELECT MAX(timestamp) as last_ts FROM messages WHERE session_id = ?", + (s["id"],), + ) + last_row = last_cursor.fetchone() + s["last_active"] = last_row["last_ts"] if last_row and last_row["last_ts"] else s["started_at"] + + return sessions + # ========================================================================= # Message storage # ========================================================================= diff --git a/run_agent.py b/run_agent.py index 75e3dfc9..0537dd97 100644 --- a/run_agent.py +++ b/run_agent.py @@ -2484,6 +2484,8 @@ class AIAgent: if self._session_db: 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") old_session_id = self.session_id 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, 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) except Exception as e: logger.debug("Session DB compression split failed: %s", e) diff --git a/tests/hermes_cli/test_commands.py b/tests/hermes_cli/test_commands.py index adbf677b..3b01eb7b 100644 --- a/tests/hermes_cli/test_commands.py +++ b/tests/hermes_cli/test_commands.py @@ -11,7 +11,7 @@ EXPECTED_COMMANDS = { "/help", "/tools", "/toolsets", "/model", "/provider", "/prompt", "/personality", "/clear", "/history", "/new", "/reset", "/retry", "/undo", "/save", "/config", "/cron", "/skills", "/platforms", - "/verbose", "/compress", "/usage", "/insights", "/paste", + "/verbose", "/compress", "/title", "/usage", "/insights", "/paste", "/reload-mcp", "/quit", } diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index 734db494..fef1f49c 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -351,6 +351,77 @@ class TestPruneSessions: # 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(self, db): + db.create_session(session_id="s1", source="cli") + db.set_session_title("s1", "") + + session = db.get_session("s1") + assert session["title"] == "" + + 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 TestSchemaInit: def test_wal_mode(self, db): cursor = db._conn.execute("PRAGMA journal_mode") @@ -373,4 +444,266 @@ class TestSchemaInit: def test_schema_version(self, db): cursor = db._conn.execute("SELECT version FROM schema_version") 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 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" From 4fdd6c0dac1ab4b48f9664d9c18f1c9fb9dd8672 Mon Sep 17 00:00:00 2001 From: teknium1 Date: Sun, 8 Mar 2026 15:48:09 -0700 Subject: [PATCH 2/4] fix: harden session title system + add /title to gateway - Empty string titles normalized to None (prevents uncaught IntegrityError when two sessions both get empty-string titles via the unique index) - Escape SQL LIKE wildcards (%, _) in resolve_session_by_title and get_next_title_in_lineage to prevent false matches on titles like 'test_project' matching 'testXproject #2' - Optimize list_sessions_rich from N+2 queries to a single query with correlated subqueries (preview + last_active computed in SQL) - Add /title slash command to gateway (Telegram, Discord, Slack, WhatsApp) with set and show modes, uniqueness conflict handling - Add /title to gateway /help text and _known_commands - 12 new tests: empty string normalization, multi-empty-title safety, SQL wildcard edge cases, gateway /title set/show/conflict/cross-platform --- gateway/run.py | 34 +++++- hermes_state.py | 80 ++++++++------ tests/gateway/test_title_command.py | 165 ++++++++++++++++++++++++++++ tests/test_hermes_state.py | 48 +++++++- 4 files changed, 289 insertions(+), 38 deletions(-) create mode 100644 tests/gateway/test_title_command.py diff --git a/gateway/run.py b/gateway/run.py index 379c4ef1..d09d09c1 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -710,7 +710,8 @@ class GatewayRunner: # Emit command:* hook for any recognized slash command _known_commands = {"new", "reset", "help", "status", "stop", "model", "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: await self.hooks.emit(f"command:{command}", { "platform": source.platform.value if source.platform else "", @@ -763,6 +764,9 @@ class GatewayRunner: if command == "update": 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 if command: @@ -1301,6 +1305,7 @@ class GatewayRunner: "`/undo` — Remove the last exchange", "`/sethome` — Set this chat as the home channel", "`/compress` — Compress conversation context", + "`/title [name]` — Set or show the session title", "`/usage` — Show token usage for this session", "`/insights [days]` — Show usage insights and analytics", "`/reload-mcp` — Reload MCP servers from config", @@ -1691,6 +1696,33 @@ class GatewayRunner: logger.warning("Manual compress failed: %s", 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: + # Set the title + try: + if self._session_db.set_session_title(session_id, title_arg): + return f"✏️ Session title set: **{title_arg}**" + 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: """Handle /usage command -- show token usage for the session's last agent run.""" source = event.source diff --git a/hermes_state.py b/hermes_state.py index df266f07..12b47ab4 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -251,7 +251,12 @@ class SessionDB: Returns True if session was found and title was set. Raises ValueError if title is already in use by another session. + Empty strings are normalized to None (clearing the title). """ + # Normalize empty string to None so it doesn't conflict with the + # unique index (only non-NULL values are constrained) + if not title: + title = None if title: # Check uniqueness (allow the same session to keep its own title) cursor = self._conn.execute( @@ -298,10 +303,12 @@ class SessionDB: 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 ? ORDER BY started_at DESC", - (f"{title} #%",), + "WHERE title LIKE ? ESCAPE '\\' ORDER BY started_at DESC", + (f"{escaped} #%",), ) numbered = cursor.fetchall() @@ -327,9 +334,11 @@ class SessionDB: 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 ?", - (base, f"{base} #%"), + "SELECT title FROM sessions WHERE title = ? OR title LIKE ? ESCAPE '\\'", + (base, f"{escaped} #%"), ) existing = [row["title"] for row in cursor.fetchall()] @@ -356,40 +365,41 @@ class SessionDB: 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). - """ - if source: - cursor = self._conn.execute( - "SELECT * FROM sessions WHERE source = ? ORDER BY started_at DESC LIMIT ? OFFSET ?", - (source, limit, offset), - ) - else: - cursor = self._conn.execute( - "SELECT * FROM sessions ORDER BY started_at DESC LIMIT ? OFFSET ?", - (limit, offset), - ) - sessions = [dict(row) for row in cursor.fetchall()] - for s in sessions: - # Get first user message preview - preview_cursor = self._conn.execute( - "SELECT content FROM messages WHERE session_id = ? AND role = 'user' " - "ORDER BY timestamp, id LIMIT 1", - (s["id"],), - ) - preview_row = preview_cursor.fetchone() - if preview_row and preview_row["content"]: - text = preview_row["content"].replace("\n", " ").strip() - s["preview"] = text[:60] + ("..." if len(text) > 60 else "") + 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"] = "" - - # Get last message timestamp - last_cursor = self._conn.execute( - "SELECT MAX(timestamp) as last_ts FROM messages WHERE session_id = ?", - (s["id"],), - ) - last_row = last_cursor.fetchone() - s["last_active"] = last_row["last_ts"] if last_row and last_row["last_ts"] else s["started_at"] + sessions.append(s) return sessions diff --git a/tests/gateway/test_title_command.py b/tests/gateway/test_title_command.py new file mode 100644 index 00000000..0429fe1c --- /dev/null +++ b/tests/gateway/test_title_command.py @@ -0,0 +1,165 @@ +"""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_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 diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index fef1f49c..02970a9a 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -405,12 +405,25 @@ class TestSessionTitle: session = db.get_session("s1") assert session["title"] == title - def test_title_empty_string(self, db): + 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"] == "" + 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") @@ -630,6 +643,37 @@ class TestTitleLineage: 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.""" From 34b4fe495e7bd169492daba380e34310adc40cf7 Mon Sep 17 00:00:00 2001 From: teknium1 Date: Sun, 8 Mar 2026 15:54:51 -0700 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20add=20title=20validation=20=E2=80=94?= =?UTF-8?q?=20sanitize,=20length=20limit,=20control=20char=20stripping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add SessionDB.sanitize_title() static method: - Strips ASCII control chars (null, bell, ESC, etc.) except whitespace - Strips problematic Unicode controls (zero-width, RTL override, BOM) - Collapses whitespace runs, strips edges - Normalizes empty/whitespace-only to None - Enforces 100 char max length (raises ValueError) - set_session_title() now calls sanitize_title() internally, so all call sites (CLI, gateway, auto-lineage) are protected - CLI /title handler sanitizes early to show correct feedback - Gateway /title handler sanitizes early to show correct feedback - 24 new tests: sanitize_title (17 cases covering control chars, zero-width, RTL, BOM, emoji, CJK, length, integration), gateway validation (too long, control chars, only-control-chars) --- cli.py | 20 ++++--- gateway/run.py | 11 +++- hermes_state.py | 59 +++++++++++++++++--- tests/gateway/test_title_command.py | 42 +++++++++++++++ tests/test_hermes_state.py | 83 +++++++++++++++++++++++++++++ 5 files changed, 201 insertions(+), 14 deletions(-) diff --git a/cli.py b/cli.py index 6fadd06a..4d8d181a 100755 --- a/cli.py +++ b/cli.py @@ -2116,12 +2116,20 @@ class HermesCLI: elif cmd_lower.startswith("/title"): parts = cmd_original.split(maxsplit=1) if len(parts) > 1: - new_title = parts[1].strip() - if new_title: + raw_title = parts[1].strip() + if raw_title: if self._session_db: - # Check if session exists in DB yet - session = self._session_db.get_session(self.session_id) - if session: + # 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}") @@ -2131,7 +2139,7 @@ class HermesCLI: _cprint(f" {e}") else: # Session not created yet — defer the title - # Check uniqueness proactively + # 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']}") diff --git a/gateway/run.py b/gateway/run.py index d09d09c1..f1b832e0 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1707,10 +1707,17 @@ class GatewayRunner: 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, title_arg): - return f"✏️ Session title set: **{title_arg}**" + 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: diff --git a/hermes_state.py b/hermes_state.py index 12b47ab4..67b4484e 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -246,17 +246,64 @@ class SessionDB: row = cursor.fetchone() 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. - Empty strings are normalized to None (clearing the title). + 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). """ - # Normalize empty string to None so it doesn't conflict with the - # unique index (only non-NULL values are constrained) - if not title: - title = None + title = self.sanitize_title(title) if title: # Check uniqueness (allow the same session to keep its own title) cursor = self._conn.execute( diff --git a/tests/gateway/test_title_command.py b/tests/gateway/test_title_command.py index 0429fe1c..7f7c782a 100644 --- a/tests/gateway/test_title_command.py +++ b/tests/gateway/test_title_command.py @@ -122,6 +122,48 @@ class TestHandleTitleCommand: 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.""" diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index 02970a9a..fcbaf219 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -435,6 +435,89 @@ class TestSessionTitle: 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: def test_wal_mode(self, db): cursor = db._conn.execute("PRAGMA journal_mode") From 2b8856865339d23c575d1a72c9308f9123c727a5 Mon Sep 17 00:00:00 2001 From: teknium1 Date: Sun, 8 Mar 2026 16:09:31 -0700 Subject: [PATCH 4/4] docs: add session naming documentation across all doc files - website/docs/user-guide/sessions.md: New 'Session Naming' section with /title usage, title rules, auto-lineage, gateway support. Updated 'Resume by Name' section, 'Rename a Session' subsection, updated sessions list output format, updated DB schema description. - website/docs/reference/cli-commands.md: Added -c "name" and --resume by title to Core Commands, sessions rename to Sessions table, /title to slash commands. - website/docs/user-guide/cli.md: Added -c "name" and --resume by title to resume options. - AGENTS.md: Added -c, --resume, sessions list/rename to CLI commands table. Added hermes_state.py to project structure. - CONTRIBUTING.md: Updated hermes_state.py and session persistence descriptions to mention titles. - hermes_cli/main.py: Fixed sessions help string to include 'rename'. --- AGENTS.md | 6 ++ CONTRIBUTING.md | 4 +- hermes_cli/main.py | 2 +- website/docs/reference/cli-commands.md | 7 +- website/docs/user-guide/cli.md | 4 +- website/docs/user-guide/sessions.md | 95 ++++++++++++++++++++++++-- 6 files changed, 106 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index cc66a5c7..a7318fd3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,6 +58,7 @@ hermes-agent/ ├── skills/ # Bundled skill sources ├── optional-skills/ # Official optional skills (not activated by default) ├── 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) ├── model_tools.py # Tool orchestration (thin layer over tools/registry.py) ├── toolsets.py # Tool groupings @@ -226,6 +227,9 @@ The unified `hermes` command provides all functionality: |---------|-------------| | `hermes` | Interactive chat (default) | | `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 ` | Resume a specific session by ID or title | | `hermes -w` / `hermes --worktree` | Start in isolated git worktree (for parallel agents) | | `hermes setup` | Configure API keys and settings | | `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 setup` | Configure messaging platforms interactively | | `hermes gateway install` | Install gateway as system service | +| `hermes sessions list` | List past sessions (title, preview, last active) | +| `hermes sessions rename ` | Rename/title a session | | `hermes cron list` | View scheduled jobs | | `hermes cron status` | Check if cron scheduler is running | | `hermes version` | Show version info | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9679d79d..6ed6c833 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -118,7 +118,7 @@ hermes-agent/ ├── cli.py # HermesCLI class — interactive TUI, prompt_toolkit integration ├── model_tools.py # Tool orchestration (thin layer over tools/registry.py) ├── 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 │ ├── 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. - **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. - **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. diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 5ba09c35..49f271f7 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -1724,7 +1724,7 @@ For more help on a command: # ========================================================================= sessions_parser = subparsers.add_parser( "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" ) sessions_subparsers = sessions_parser.add_subparsers(dest="sessions_action") diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index 55fd8504..7f03f50a 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -17,7 +17,8 @@ These are commands you run from your shell. | `hermes` | Start interactive chat (default) | | `hermes chat -q "Hello"` | Single query mode (non-interactive) | | `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 --provider <name>` | Force a provider (`nous`, `openrouter`, `zai`, `kimi-coding`, `minimax`, `minimax-cn`) | | `hermes chat --toolsets "web,terminal"` / `-t` | Use specific toolsets | @@ -103,7 +104,8 @@ These are commands you run from your shell. | 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 delete <id>` | Delete a specific session | | `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 | | `/save` | Save the current conversation | | `/compress` | Manually compress conversation context | +| `/title [name]` | Set or show the current session's title | | `/usage` | Show token usage for this session | | `/insights [--days N]` | Show usage insights and analytics (last 30 days) | diff --git a/website/docs/user-guide/cli.md b/website/docs/user-guide/cli.md index d80b178b..314fc326 100644 --- a/website/docs/user-guide/cli.md +++ b/website/docs/user-guide/cli.md @@ -229,13 +229,15 @@ Resume options: ```bash hermes --continue # Resume the most recent CLI session 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 "refactoring auth" # Resume by title 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. -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 diff --git a/website/docs/user-guide/sessions.md b/website/docs/user-guide/sessions.md index 92f6e121..e99a725d 100644 --- a/website/docs/user-guide/sessions.md +++ b/website/docs/user-guide/sessions.md @@ -17,6 +17,7 @@ Every conversation — whether from the CLI, Telegram, Discord, WhatsApp, or Sla The SQLite database stores: - Session ID, source platform, user ID +- **Session title** (unique, human-readable name) - Model name and configuration - System prompt snapshot - 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. +### 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 ```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 -r 20250305_091523_a1b2c3d4 +# Resume by title +hermes --resume "refactoring auth" + # Or with the chat subcommand 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`. :::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 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 ``` -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 -20250304_143022_e5f6g7h8 telegram anthropic/claude-opus-4.6 12 2025-03-04 14:30 (ended) +refactoring auth Help me refactor the auth module please 2h ago 20250305_091523_a +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 @@ -122,6 +193,18 @@ hermes sessions delete 20250305_091523_a1b2c3d4 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 ```bash @@ -233,7 +316,7 @@ The SQLite database uses WAL mode for concurrent readers and a single writer, wh 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_fts** — FTS5 virtual table for full-text search across message content