← Files Portable ResumeARCHIVED FILE

skills/.portable-resume/runtime/portable_resume/adapters/hermes.py

19.8 KB · Oct 5, 2026 · 18:33 UTC

↓ Download file

"""Read Hermes Agent ``state.db`` SQLite as inert context.

Pinned format: ``hermes-state-sqlite-v1`` (``schema_version`` = 23).
Default store: ``$HERMES_HOME/state.db`` or ``~/.hermes/state.db``.

Authority: SQLite sessions + messages. FTS virtual tables are never queried.
Never invokes Hermes CLI/gateway, Skill hub, taps, messaging platforms, or
migrations. Does not import ``hermes_state`` / SessionDB.
"""

from __future__ import annotations

import os
import re
import sqlite3
import stat
from dataclasses import replace
from datetime import datetime, timezone
from typing import Any

from ..bounds import DEFAULT_BOUNDS, ReadBudget
from ..diagnostics import DiagnosticError
from ..model import Query, Session, SessionSummary, Turn
from ..paths import canonical_root, canonicalize_cwd, is_within, same_cwd
from ..sanitize import sanitize_turn_record
from ..snapshot import private_sqlite_connection, query_only_live_sqlite
from .base import CapabilityReport, ResolvedRef
from .common import within_age

FORMAT_ID = "hermes-state-sqlite-v1"
SCHEMA_VERSION = 23
DB_NAME = "state.db"
_SESSION_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,200}$")
_REQUIRED_TABLES = frozenset({"schema_version", "sessions", "messages"})
_SESSION_COLUMNS = frozenset(
    {
        "id",
        "source",
        "parent_session_id",
        "started_at",
        "ended_at",
        "title",
        "cwd",
        "archived",
        "system_prompt",
    }
)
_MESSAGE_COLUMNS = frozenset(
    {
        "id",
        "session_id",
        "role",
        "content",
        "tool_call_id",
        "tool_calls",
        "tool_name",
        "timestamp",
        "reasoning",
        "active",
    }
)
# Messaging/platform labels allowed as high-level source tags (never IDs).
_SAFE_SOURCES = frozenset(
    {
        "cli",
        "telegram",
        "discord",
        "slack",
        "whatsapp",
        "signal",
        "web",
        "api",
        "gateway",
        "unknown",
    }
)
_PUBLIC_ROLES = frozenset({"user", "assistant", "tool", "tool_result", "function"})


def _root_candidate(query: Query) -> str:
    if query.source_root:
        return query.source_root
    home = os.environ.get("HERMES_HOME")
    if home and home.strip():
        path = home.strip()
        if not os.path.isabs(path):
            raise DiagnosticError("E_UNSAFE_PATH", source="hermes", provider=FORMAT_ID)
        return path
    return os.path.expanduser("~/.hermes")


def _existing_root(query: Query) -> str | None:
    candidate = _root_candidate(query)
    try:
        # Exact state.db path
        if os.path.isfile(candidate) and os.path.basename(candidate) == DB_NAME:
            parent = os.path.dirname(os.path.abspath(candidate))
            return canonical_root(parent)
        if not os.path.isdir(candidate):
            return None
        return canonical_root(candidate)
    except DiagnosticError:
        if query.source_root:
            raise
        return None


def _regular_file(path: str, root: str) -> bool:
    try:
        mode = os.lstat(path).st_mode
    except OSError:
        return False
    if stat.S_ISLNK(mode) or not stat.S_ISREG(mode):
        return False
    try:
        return is_within(path, root)
    except DiagnosticError:
        return False


def _database_path(query: Query, root: str) -> str | None:
    if query.source_root:
        candidate = os.path.abspath(query.source_root)
        if os.path.isfile(candidate) and os.path.basename(candidate) == DB_NAME:
            return candidate if _regular_file(candidate, root) else None
    path = os.path.join(root, DB_NAME)
    return path if _regular_file(path, root) else None


def _stamp_sql(value: object) -> str | None:
    if value is None:
        return None
    if isinstance(value, (int, float)) and not isinstance(value, bool):
        number = float(value)
        # Hermes uses Unix epoch floats; ms would be > 1e12.
        if number > 10_000_000_000:
            number /= 1000.0
        try:
            return (
                datetime.fromtimestamp(number, timezone.utc)
                .isoformat(timespec="microseconds")
                .replace("+00:00", "Z")
            )
        except (OverflowError, OSError, ValueError):
            return None
    return None


def _cwd_from(value: object) -> str | None:
    if not isinstance(value, str) or not value.strip():
        return None
    try:
        return canonicalize_cwd(value)
    except DiagnosticError:
        return None


def _open_connection(database: str, root: str, budget: ReadBudget | None = None):
    limits = budget.limits if budget is not None else DEFAULT_BOUNDS
    try:
        size = os.path.getsize(database)
    except OSError as error:
        raise DiagnosticError.source_busy(provider=FORMAT_ID) from error
    if size > limits.sqlite_snapshot_bytes:
        return query_only_live_sqlite(database, root=root, provider=FORMAT_ID)
    return private_sqlite_connection(
        database, root=root, bounds=limits, provider=FORMAT_ID
    )


def _require_schema(connection: sqlite3.Connection) -> None:
    try:
        integrity = connection.execute("PRAGMA integrity_check(1)").fetchone()
        if integrity != ("ok",):
            raise DiagnosticError("E_CORRUPT_RECORD", source="hermes", provider=FORMAT_ID)
        tables = {
            row[0]
            for row in connection.execute(
                "SELECT name FROM sqlite_master WHERE type='table' "
                "AND name IN ('schema_version','sessions','messages')"
            )
        }
        if not _REQUIRED_TABLES.issubset(tables):
            raise DiagnosticError(
                "E_UNSUPPORTED_FORMAT", source="hermes", provider=FORMAT_ID
            )
        session_cols = {
            str(row[1]) for row in connection.execute("PRAGMA table_info(sessions)")
        }
        message_cols = {
            str(row[1]) for row in connection.execute("PRAGMA table_info(messages)")
        }
        if not _SESSION_COLUMNS.issubset(session_cols) or not _MESSAGE_COLUMNS.issubset(
            message_cols
        ):
            raise DiagnosticError(
                "E_UNSUPPORTED_FORMAT", source="hermes", provider=FORMAT_ID
            )
        version_row = connection.execute(
            "SELECT MAX(version) FROM schema_version"
        ).fetchone()
        if (
            version_row is None
            or version_row[0] is None
            or int(version_row[0]) != SCHEMA_VERSION
        ):
            raise DiagnosticError(
                "E_UNSUPPORTED_FORMAT", source="hermes", provider=FORMAT_ID
            )
    except sqlite3.DatabaseError as error:
        raise DiagnosticError(
            "E_UNSUPPORTED_FORMAT", source="hermes", provider=FORMAT_ID
        ) from error
    except (TypeError, ValueError) as error:
        raise DiagnosticError(
            "E_UNSUPPORTED_FORMAT", source="hermes", provider=FORMAT_ID
        ) from error


def _exact_ref(value: str | None) -> str | None:
    if not value:
        return None
    text = value.strip()
    if not text or text == "latest":
        return None
    # Optional profile:session composite — native id is the suffix.
    if ":" in text:
        _profile, session = text.split(":", 1)
        session = session.strip()
        if session and _SESSION_ID_RE.fullmatch(session):
            return session
        return None
    if _SESSION_ID_RE.fullmatch(text):
        return text
    return None


def _safe_title(value: object) -> str | None:
    if isinstance(value, str) and value.strip():
        return value.strip()[: DEFAULT_BOUNDS.title_chars]
    return None


def _row_summary(
    *,
    database: str,
    session_id: str,
    title: object,
    cwd_value: object,
    started_at: object,
    ended_at: object,
    query: Query,
    require_age: bool,
) -> SessionSummary | None:
    if not isinstance(session_id, str) or not session_id:
        return None
    cwd = _cwd_from(cwd_value)
    if query.cwd is not None:
        if cwd is None or not same_cwd(cwd, query.cwd):
            return None
    stamp = _stamp_sql(ended_at) or _stamp_sql(started_at)
    if require_age and not within_age(
        stamp, query.within_min, default_minutes=DEFAULT_BOUNDS.listing_age_minutes
    ):
        return None
    return SessionSummary(
        source="hermes",
        session_id=session_id,
        source_path=database,
        title=_safe_title(title),
        cwd=cwd,
        branch=None,
        created_at=_stamp_sql(started_at),
        updated_at=stamp,
        provider=FORMAT_ID,
        warnings=(),
    )


def _content_text(content: object) -> str | None:
    if isinstance(content, str) and content.strip():
        return content.strip()
    return None


def _session_has_extractable_turn(
    connection: sqlite3.Connection,
    session_id: str,
    budget: ReadBudget,
) -> bool:
    row_limit = min(budget.limits.transcript_records, DEFAULT_BOUNDS.transcript_records)
    if row_limit <= 0:
        return False
    # Require a public user turn so gateway/tool-only heartbeats cannot win latest.
    rows = connection.execute(
        """
        SELECT role, content
        FROM messages
        WHERE session_id = ?
          AND COALESCE(active, 1) = 1
          AND role = 'user'
        ORDER BY id ASC
        LIMIT ?
        """,
        (session_id, row_limit),
    )
    for role, content in rows:
        if not isinstance(role, str) or role != "user":
            raise DiagnosticError("E_CORRUPT_RECORD", source="hermes", provider=FORMAT_ID)
        if content is not None and not isinstance(content, str):
            raise DiagnosticError("E_CORRUPT_RECORD", source="hermes", provider=FORMAT_ID)
        if isinstance(content, str):
            encoded = content.encode("utf-8")
            if len(encoded) > budget.limits.record_bytes:
                raise DiagnosticError.limit_exceeded()
            budget.consume_bytes(len(encoded))
        budget.consume_records()
        if _content_text(content) is not None:
            return True
    return False


def _list_sessions(
    connection: sqlite3.Connection,
    *,
    database: str,
    query: Query,
    exact_id: str | None,
    budget: ReadBudget,
) -> list[SessionSummary]:
    scan_limit = min(budget.limits.scanned_records, DEFAULT_BOUNDS.scanned_records)
    list_limit = min(budget.limits.listed_sessions, DEFAULT_BOUNDS.listed_sessions)
    if exact_id is None and list_limit <= 0:
        return []

    if exact_id is not None:
        rows = connection.execute(
            """
            SELECT id, title, cwd, started_at, ended_at, parent_session_id, archived
            FROM sessions
            WHERE id = ? OR lower(id) = lower(?)
            LIMIT 2
            """,
            (exact_id, exact_id),
        ).fetchall()
        require_age = False
        if not rows:
            return []
    else:
        if scan_limit <= 0:
            return []
        # Root sessions only: hide child/subagent and archived from default list.
        rows = connection.execute(
            """
            SELECT id, title, cwd, started_at, ended_at, parent_session_id, archived
            FROM sessions
            WHERE (parent_session_id IS NULL OR parent_session_id = '')
              AND COALESCE(archived, 0) = 0
            ORDER BY COALESCE(ended_at, started_at) DESC, started_at DESC, id ASC
            LIMIT ?
            """,
            (scan_limit,),
        ).fetchall()
        require_age = True

    values: list[SessionSummary] = []
    for row in rows:
        session_id, title, cwd_value, started_at, ended_at, parent, archived = row
        if exact_id is None:
            if isinstance(parent, str) and parent.strip():
                continue
            if archived in (1, True):
                continue
            try:
                if not _session_has_extractable_turn(
                    connection, str(session_id), budget
                ):
                    continue
            except DiagnosticError as error:
                if error.code in {
                    "E_LIMIT_EXCEEDED",
                    "E_SOURCE_BUSY",
                    "E_UNSAFE_PATH",
                    "E_CORRUPT_RECORD",
                }:
                    raise
                continue
        item = _row_summary(
            database=database,
            session_id=str(session_id),
            title=title,
            cwd_value=cwd_value,
            started_at=started_at,
            ended_at=ended_at,
            query=query,
            require_age=require_age,
        )
        if item is not None:
            values.append(item)
            budget.consume_records()
            if exact_id is None and len(values) >= list_limit:
                break
    return values


def _turn_from_message(
    role: object,
    content: object,
    tool_name: object,
) -> tuple[str, str] | None:
    if not isinstance(role, str) or role not in _PUBLIC_ROLES:
        return None
    text = _content_text(content)
    if text is None and role in {"tool", "tool_result", "function"}:
        if isinstance(tool_name, str) and tool_name.strip():
            text = tool_name.strip()
    if text is None:
        return None
    public_role = "tool" if role in {"tool", "tool_result", "function"} else role
    return public_role, text


class HermesAdapter:
    key = "hermes"

    def approved_roots(self, query: Query) -> tuple[str, ...]:
        root = _existing_root(query)
        return (root,) if root else ()

    def probe(self, query: Query) -> CapabilityReport:
        try:
            root = _existing_root(query)
            if root is None:
                return CapabilityReport(self.key, FORMAT_ID, "unavailable")
            database = _database_path(query, root)
            if database is None:
                return CapabilityReport(self.key, FORMAT_ID, "unavailable", root=root)
            with _open_connection(database, root) as connection:
                _require_schema(connection)
            return CapabilityReport(
                self.key, FORMAT_ID, "supported", root=root, evidence=(FORMAT_ID,)
            )
        except DiagnosticError as error:
            if error.code in {"E_UNSAFE_PATH", "E_SOURCE_BUSY", "E_SQLITE_LIVE_WAL"}:
                return CapabilityReport(self.key, FORMAT_ID, "unsafe")
            if error.code == "E_UNSUPPORTED_FORMAT":
                return CapabilityReport(self.key, FORMAT_ID, "unsupported")
            if error.code == "E_CORRUPT_RECORD":
                return CapabilityReport(self.key, FORMAT_ID, "unsupported")
            return CapabilityReport(self.key, FORMAT_ID, "unsupported")

    def list(self, query: Query, budget: ReadBudget) -> list[SessionSummary]:
        root = _existing_root(query)
        if root is None:
            raise DiagnosticError(
                "E_CAPABILITY_UNAVAILABLE", source=self.key, provider=FORMAT_ID
            )
        database = _database_path(query, root)
        if database is None:
            raise DiagnosticError(
                "E_CAPABILITY_UNAVAILABLE", source=self.key, provider=FORMAT_ID
            )
        exact = _exact_ref(query.ref)
        list_query = query
        if exact is not None and query.within_min is None:
            list_query = Query(
                source=query.source,
                ref=query.ref,
                cwd=query.cwd,
                within_min=0,
                source_root=query.source_root,
                max_tool_chars=query.max_tool_chars,
            )
        with _open_connection(database, root, budget) as connection:
            _require_schema(connection)
            values = _list_sessions(
                connection,
                database=database,
                query=list_query,
                exact_id=exact,
                budget=budget,
            )
        values.sort(key=lambda item: item.session_id)
        values.sort(key=lambda item: item.updated_at or "", reverse=True)
        values.sort(key=lambda item: item.updated_at is None)
        if exact is not None:
            return values
        list_limit = min(budget.limits.listed_sessions, DEFAULT_BOUNDS.listed_sessions)
        return values[:list_limit]

    def show(self, ref: ResolvedRef, query: Query, budget: ReadBudget) -> Session:
        root = _existing_root(query)
        if root is None:
            raise DiagnosticError(
                "E_CAPABILITY_UNAVAILABLE", source=self.key, provider=FORMAT_ID
            )
        database = _database_path(query, root)
        if database is None:
            raise DiagnosticError(
                "E_CAPABILITY_UNAVAILABLE", source=self.key, provider=FORMAT_ID
            )
        session_id = ref.session_id
        if not session_id or not _SESSION_ID_RE.fullmatch(session_id):
            raise DiagnosticError("E_NO_MATCH", source=self.key, provider=FORMAT_ID)

        with _open_connection(database, root, budget) as connection:
            _require_schema(connection)
            rows = connection.execute(
                """
                SELECT id, title, cwd, started_at, ended_at
                FROM sessions WHERE id = ?
                LIMIT 2
                """,
                (session_id,),
            ).fetchall()
            if len(rows) != 1:
                raise DiagnosticError("E_NO_MATCH", source=self.key, provider=FORMAT_ID)
            _sid, title, cwd_value, started_at, ended_at = rows[0]
            cwd = _cwd_from(cwd_value)
            if query.cwd is not None and (cwd is None or not same_cwd(cwd, query.cwd)):
                raise DiagnosticError("E_NO_MATCH", source=self.key, provider=FORMAT_ID)

            limit = min(budget.limits.transcript_records, DEFAULT_BOUNDS.transcript_records)
            # Select only public fields — never materialize reasoning/tool_calls blobs.
            messages = connection.execute(
                """
                SELECT role, content, tool_name
                FROM messages
                WHERE session_id = ?
                  AND COALESCE(active, 1) = 1
                ORDER BY id ASC
                LIMIT ?
                """,
                (session_id, limit + 1),
            ).fetchall()
            if len(messages) > limit:
                raise DiagnosticError.limit_exceeded()

            turns: list[Turn] = []
            warnings: list[str] = []
            turn_bounds = replace(DEFAULT_BOUNDS, tool_output_chars=query.max_tool_chars)
            for role, content, tool_name in messages:
                budget.consume_transcript_records()
                if isinstance(content, str):
                    encoded = content.encode("utf-8")
                    if len(encoded) > budget.limits.record_bytes:
                        raise DiagnosticError.limit_exceeded()
                    budget.consume_bytes(len(encoded))
                parsed = _turn_from_message(role, content, tool_name)
                if parsed is None:
                    continue
                public_role, text = parsed
                turn, turn_warnings = sanitize_turn_record(
                    {"role": public_role, "content": text},
                    ordinal=len(turns),
                    bounds=turn_bounds,
                )
                warnings.extend(turn_warnings)
                if turn is not None:
                    budget.consume_turns()
                    turns.append(turn)

        if not turns:
            raise DiagnosticError("E_NO_MATCH", source=self.key, provider=FORMAT_ID)

        last_user = next((t.content for t in reversed(turns) if t.role == "user"), None)
        last_assistant = next(
            (t.content for t in reversed(turns) if t.role == "assistant"), None
        )
        return Session(
            source="hermes",
            session_id=session_id,
            source_path=database,
            title=_safe_title(title),
            cwd=cwd,
            branch=None,
            created_at=_stamp_sql(started_at),
            updated_at=_stamp_sql(ended_at) or _stamp_sql(started_at),
            last_user_request=last_user,
            last_assistant_action=last_assistant,
            turns=tuple(turns),
            warnings=tuple(dict.fromkeys(warnings)),
        )


ADAPTER = HermesAdapter()

SHA-256: 17c0b8ea76ccc72f0540e896fdd98f15a51690522743ce571d17a0f0a9766a57