diff --git a/CHANGELOG.md b/CHANGELOG.md index c6c40c6..9ac08e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,33 @@ --- +## [v0.30] CLI Session Bridge (Community: @thadreber-web) +*April 4, 2026 | 426 tests* + +### Features +- **CLI session bridge.** The WebUI now reads sessions from the hermes-agent's + SQLite store (`state.db`). CLI sessions appear in the sidebar with a gold + "cli" indicator badge. Click to import into the WebUI store with full message + history — replies then work through the normal agent pipeline. +- **`/api/session/import_cli` endpoint.** Imports a CLI session into the WebUI + JSON store. Idempotent — returns existing session if already imported. + Derives title from first message, inherits active profile and workspace. +- **`/api/sessions` merges CLI sessions.** Sidebar shows both WebUI and CLI + sessions sorted by last activity. Deduplication ensures WebUI sessions take + priority when the same session_id exists in both stores. +- **CLI session fallback on `/api/session`.** If a session_id isn't found in + the WebUI store, falls back to reading from the CLI SQLite store. + +### Architecture +- `api/models.py`: `get_cli_sessions()`, `get_cli_session_messages()`, + `import_cli_session()`. All use parameterized SQL queries and `with` for + connection management. Graceful fallback on missing sqlite3 or state.db. +- `api/routes.py`: CLI fallback in GET `/api/session`, merged list in + GET `/api/sessions`, POST `/api/session/import_cli`. +- `static/style.css`: `.cli-session` indicator styles (gold border + badge). + +--- + ## [v0.29] Sprint 23: Agentic Transparency + Polish *April 4, 2026 | 424 tests* @@ -1015,4 +1042,4 @@ Three-panel layout: sessions sidebar, chat area, workspace panel. --- -*Last updated: v0.28.1, April 3, 2026 | Tests: 426* +*Last updated: v0.30, April 4, 2026 | Tests: 426* diff --git a/SPRINTS.md b/SPRINTS.md index 7f249fd..00b24a1 100644 --- a/SPRINTS.md +++ b/SPRINTS.md @@ -1,6 +1,6 @@ # Hermes Web UI -- Forward Sprint Plan -> Current state: v0.28.1 | 424 tests | Daily driver ready +> Current state: v0.30 | 426 tests | Daily driver ready > This document plans the path from here to two targets: > > Target A: 1:1 feature parity with the Hermes CLI (everything you can do from the @@ -898,6 +898,5 @@ genuinely differentiating for an open-source project --- *Last updated: April 4, 2026* -*Current version: v0.28.1 (Sprint 23 pending merge) | 424 tests* +*Current version: v0.30 | 426 tests* *Next sprint: Sprint 24 (Web Polish + Bug Fix Pass)* -*Horizon sprint: Sprint 25 (macOS Desktop Application)* diff --git a/api/models.py b/api/models.py index 5a51593..71e787e 100644 --- a/api/models.py +++ b/api/models.py @@ -10,7 +10,7 @@ from pathlib import Path import api.config as _cfg from api.config import ( SESSION_DIR, SESSION_INDEX_FILE, SESSIONS, SESSIONS_MAX, - LOCK, DEFAULT_WORKSPACE, DEFAULT_MODEL, PROJECTS_FILE + LOCK, DEFAULT_WORKSPACE, DEFAULT_MODEL, PROJECTS_FILE, HOME ) from api.workspace import get_last_workspace @@ -192,3 +192,130 @@ def load_projects(): def save_projects(projects): """Write project list to disk.""" PROJECTS_FILE.write_text(json.dumps(projects, ensure_ascii=False, indent=2), encoding='utf-8') + + +def import_cli_session(session_id, title, messages, model='unknown', profile=None): + """Create a new WebUI session populated with CLI messages. + Returns the Session object. + """ + s = Session( + session_id=session_id, + title=title, + workspace=get_last_workspace(), + model=model, + messages=messages, + profile=profile, + ) + s.save() + return s + + +# ── CLI session bridge ────────────────────────────────────────────────────── + +def get_cli_sessions(): + """Read CLI sessions from the agent's SQLite store and return them as + dicts in a format the WebUI sidebar can render alongside local sessions. + + Returns empty list if the SQLite DB is missing, the sqlite3 module is + unavailable, or any error occurs -- the bridge is purely additive and never + crashes the WebUI. + """ + import os + cli_sessions = [] + try: + import sqlite3 + except ImportError: + return cli_sessions + + hermes_home = Path(os.getenv('HERMES_HOME', str(HOME / '.hermes'))).expanduser() + db_path = hermes_home / 'state.db' + if not db_path.exists(): + return cli_sessions + + # Try to resolve the active CLI profile so imported sessions integrate + # with the WebUI profile filter (available since Sprint 22). + try: + from api.profiles import get_active_profile_name + _cli_profile = get_active_profile_name() + except ImportError: + _cli_profile = None # older agent -- fall back to no profile + + try: + with sqlite3.connect(str(db_path)) as conn: + conn.row_factory = sqlite3.Row + cur = conn.cursor() + cur.execute(""" + SELECT s.id, s.title, s.model, s.message_count, + s.started_at, s.source, s.profile, + MAX(m.timestamp) AS last_activity + FROM sessions s + LEFT JOIN messages m ON m.session_id = s.id + GROUP BY s.id + ORDER BY COALESCE(MAX(m.timestamp), s.started_at) DESC + LIMIT 200 + """) + for row in cur.fetchall(): + sid = row['id'] + raw_ts = row['last_activity'] or row['started_at'] + # Prefer the CLI session's own profile from the DB; fall back to + # the active CLI profile so sidebar filtering works either way. + profile = row.get('profile') or _cli_profile + + cli_sessions.append({ + 'session_id': sid, + 'title': row['title'] or 'CLI Session', + 'workspace': str(get_last_workspace()), + 'model': row['model'] or 'unknown', + 'message_count': row['message_count'] or 0, + 'created_at': row['started_at'], + 'updated_at': raw_ts, + 'pinned': False, + 'archived': False, + 'project_id': None, + 'profile': profile, + 'source_tag': 'cli', + 'is_cli_session': True, + }) + except Exception: + # DB schema changed, locked, or corrupted -- silently degrade + return [] + + return cli_sessions + + +def get_cli_session_messages(sid): + """Read messages for a single CLI session from the SQLite store. + Returns a list of {role, content, timestamp} dicts. + Returns empty list on any error. + """ + import os + try: + import sqlite3 + except ImportError: + return [] + + hermes_home = Path(os.getenv('HERMES_HOME', str(HOME / '.hermes'))).expanduser() + db_path = hermes_home / 'state.db' + if not db_path.exists(): + return [] + + try: + with sqlite3.connect(str(db_path)) as conn: + conn.row_factory = sqlite3.Row + cur = conn.cursor() + cur.execute(""" + SELECT role, content, timestamp + FROM messages + WHERE session_id = ? + ORDER BY timestamp ASC + """, (sid,)) + msgs = [] + for row in cur.fetchall(): + msgs.append({ + 'role': row['role'], + 'content': row['content'], + 'timestamp': row['timestamp'], + }) + except Exception: + return [] + return msgs diff --git a/api/routes.py b/api/routes.py index f032916..8b73115 100644 --- a/api/routes.py +++ b/api/routes.py @@ -23,7 +23,8 @@ from api.helpers import require, bad, safe_resolve, j, t, read_body, _security_h from api.models import ( Session, get_session, new_session, all_sessions, title_from, _write_session_index, SESSION_INDEX_FILE, - load_projects, save_projects, + load_projects, save_projects, import_cli_session, + get_cli_sessions, get_cli_session_messages, ) from api.workspace import ( load_workspaces, save_workspaces, get_last_workspace, set_last_workspace, @@ -151,14 +152,49 @@ def handle_get(handler, parsed): sid = parse_qs(parsed.query).get('session_id', [''])[0] if not sid: return j(handler, {'error': 'session_id is required'}, status=400) - s = get_session(sid) - return j(handler, {'session': s.compact() | { - 'messages': s.messages, - 'tool_calls': getattr(s, 'tool_calls', []), - }}) + try: + s = get_session(sid) + return j(handler, {'session': s.compact() | { + 'messages': s.messages, + 'tool_calls': getattr(s, 'tool_calls', []), + }}) + except KeyError: + # Not a WebUI session -- try CLI store + msgs = get_cli_session_messages(sid) + if msgs: + cli_meta = None + for cs in get_cli_sessions(): + if cs['session_id'] == sid: + cli_meta = cs + break + sess = { + 'session_id': sid, + 'title': (cli_meta or {}).get('title', 'CLI Session'), + 'workspace': (cli_meta or {}).get('workspace', ''), + 'model': (cli_meta or {}).get('model', 'unknown'), + 'message_count': len(msgs), + 'created_at': (cli_meta or {}).get('created_at', 0), + 'updated_at': (cli_meta or {}).get('updated_at', 0), + 'pinned': False, + 'archived': False, + 'project_id': None, + 'profile': (cli_meta or {}).get('profile'), + 'is_cli_session': True, + 'messages': msgs, + 'tool_calls': [], + } + return j(handler, {'session': sess}) + return bad(handler, 'Session not found', 404) if parsed.path == '/api/sessions': - return j(handler, {'sessions': all_sessions()}) + webui_sessions = all_sessions() + cli = get_cli_sessions() + # Deduplicate: WebUI sessions always win if same session_id + webui_ids = {s['session_id'] for s in webui_sessions} + deduped_cli = [s for s in cli if s['session_id'] not in webui_ids] + merged = webui_sessions + deduped_cli + merged.sort(key=lambda s: s.get('updated_at', 0) or 0, reverse=True) + return j(handler, {'sessions': merged, 'cli_count': len(deduped_cli)}) if parsed.path == '/api/projects': return j(handler, {'projects': load_projects()}) @@ -542,6 +578,10 @@ def handle_post(handler, parsed): if parsed.path == '/api/session/import': return _handle_session_import(handler, body) + # ── CLI session import (POST) ── + if parsed.path == '/api/session/import_cli': + return _handle_session_import_cli(handler, body) + # ── Auth endpoints (POST) ── if parsed.path == '/api/auth/login': from api.auth import verify_password, create_session, set_auth_cookie, is_auth_enabled @@ -1173,6 +1213,53 @@ def _handle_memory_write(handler, body): return j(handler, {'ok': True, 'section': section, 'path': str(target)}) +def _handle_session_import_cli(handler, body): + """Import a single CLI session into the WebUI store.""" + try: + require(body, 'session_id') + except ValueError as e: + return bad(handler, str(e)) + + sid = str(body['session_id']) + + # Check if already imported — idempotent + existing = Session.load(sid) + if existing: + return j(handler, {'session': existing.compact() | { + 'messages': existing.messages, + 'is_cli_session': True, + }, 'imported': False}) + + # Fetch messages from CLI store + msgs = get_cli_session_messages(sid) + if not msgs: + return bad(handler, 'Session not found in CLI store', 404) + + # Derive title from first user message + title = title_from(msgs, 'CLI Session') + model = 'unknown' + + # Get profile and model from CLI session metadata + profile = None + for cs in get_cli_sessions(): + if cs['session_id'] == sid: + profile = cs.get('profile') + model = cs.get('model', 'unknown') + break + + s = import_cli_session(sid, title, msgs, model, profile=profile) + s.is_cli_session = True + s._cli_origin = sid + s.save() + return j(handler, { + 'session': s.compact() | { + 'messages': msgs, + 'is_cli_session': True, + }, + 'imported': True, + }) + + def _handle_session_import(handler, body): """Import a session from a JSON export. Creates a new session with a new ID.""" if not body or not isinstance(body, dict): diff --git a/static/index.html b/static/index.html index be31f31..93e40e4 100644 --- a/static/index.html +++ b/static/index.html @@ -13,7 +13,7 @@