{"repo":"NousResearch/hermes-agent","public_id":"10897","display_label":"SQLite FTS5 missing: graceful degradation fixes","summary":"These issues and pull requests all concern failures when the SQLite build lacks the FTS5 module, particularly on macOS Python 3.11. The work focuses on detecting the missing module and degrading gracefully rather than crashing.","subsystem":"gateway","counts":{"members":7,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":1,"attempt_prs":4},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":35103},"resolution":{"state":"partially_resolved","by":"pr-35103"},"freshness":{"state":"verified","last_verified_at":"2026-05-30T03:11:08+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.3028571428571429},"fix_edges":[{"issue_number":10897,"pr_number":35103,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":10897,"title":"[Bug]:  no such module: fts5","url":null,"state":"closed"},{"kind":"issue","number":13029,"title":"Session store fails to open when SQLite lacks FTS5 module (common on Python 3.11 macOS installs)","url":null,"state":"closed"},{"kind":"pr","number":10972,"title":"fix(session): degrade gracefully when SQLite lacks FTS5","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"355af2c20f495b97c22c9aeb4c227fb0ca010da7","number":0},{"kind":"pr","value":"#35103","number":35103}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":13222,"title":"fix(hermes_state): gracefully degrade when SQLite build lacks FTS5","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#10972","number":10972}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":35086,"title":"fix(session): degrade gracefully when SQLite lacks FTS5","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#35103","number":35103},{"kind":"pr","value":"#35103","number":35103},{"kind":"pr","value":"#35103","number":35103}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":35103,"title":"fix(session): survive, prevent, and explain missing SQLite FTS5","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_state.py","scripts/install.sh","tests/test_hermes_state.py"],"fix_hunks":[{"file":"hermes_state.py","hunks":["@@ -380,6 +380,7 @@ def __init__(self, db_path: Path = None):\n \n         self._lock = threading.Lock()\n         self._write_count = 0\n+        self._fts_enabled = False\n         try:\n             self._conn = sqlite3.connect(\n                 str(self.db_path),","@@ -388,7 +389,6 @@ def __init__(self, db_path: Path = None):\n                 # handles contention instead of sitting in SQLite's internal\n                 # busy handler for up to 30s.\n                 timeout=1.0,\n-                # Autocommit mode: Python's default isolation_level=\"\"\n                 # auto-starts transactions on DML, which conflicts with our\n                 # explicit BEGIN IMMEDIATE.  None = we manage transactions\n                 # ourselves.","@@ -724,14 +724,44 @@ def _init_schema(self):\n         # FTS5 setup (separate because CREATE VIRTUAL TABLE can't be in executescript with IF NOT EXISTS reliably)\n         try:\n             cursor.execute(\"SELECT * FROM messages_fts LIMIT 0\")\n-        except sqlite3.OperationalError:\n-            cursor.executescript(FTS_SQL)\n+            self._fts_enabled = True\n+        except sqlite3.OperationalError as exc:\n+            if \"no such table\" not in str(exc).lower():\n+                raise\n+            try:\n+                cursor.executescript(FTS_SQL)\n+                self._fts_enabled = True\n+            except sqlite3.OperationalError as fts_exc:\n+                err = str(fts_exc).lower()\n+                if \"fts5\" not in err and \"no such module\" not in err:\n+                    raise\n+                logger.warning(\n+                    \"SQLite FTS5 unavailable for %s; full-text session search \"\n+                    \"disabled. This usually means Hermes is running on an \"\n+                    \"unsupported install (e.g. a pip-installed or pip-managed \"\n+                    \"Python whose bundled SQLite lacks FTS5) rather than a \"\n+                    \"mainline install. Some features may be missing or behave \"\n+                    \"differently. Install the supported way: \"\n+                    \"https://hermes-agent.nousresearch.com (underlying error: %s)\",\n+                    self.db_path,\n+                    fts_exc,\n+                )\n \n         # Trigram FTS5 for CJK/substring search\n         try:\n             cursor.execute(\"SELECT * FROM messages_fts_trigram LIMIT 0\")\n-        except sqlite3.OperationalError:\n-            cursor.executescript(FTS_TRIGRAM_SQL)\n+        except sqlite3.OperationalError as exc:\n+            if \"no such table\" not in str(exc).lower():\n+                raise\n+            try:\n+                cursor.executescript(FTS_TRIGRAM_SQL)\n+            except sqlite3.OperationalError as fts_exc:\n+                err = str(fts_exc).lower()\n+                if \"fts5\" not in err and \"no such module\" not in err:\n+                    raise\n+                # Same FTS5-unavailable cause already warned about above for\n+                # messages_fts; the trigram table is an additional CJK index,\n+                # so just degrade silently here. CJK search falls back to LIKE.\n \n         self._conn.commit()\n ","@@ -2317,6 +2347,9 @@ def search_messages(\n         ignores ``sort``. The trigram CJK path honours ``sort`` like the main\n         FTS5 path.\n         \"\"\"\n+        if not self._fts_enabled:\n+            return []\n+\n         if not query or not query.strip():\n             return []\n "]},{"file":"scripts/install.sh","hunks":["@@ -473,6 +473,7 @@ check_python() {\n     if PYTHON_PATH=\"$(\"$UV_CMD\" python find \"$PYTHON_VERSION\" 2>/dev/null)\"; then\n         PYTHON_FOUND_VERSION=\"$(\"$PYTHON_PATH\" --version 2>/dev/null)\"\n         log_success \"Python found: $PYTHON_FOUND_VERSION\"\n+        ensure_fts5\n         return 0\n     fi\n ","@@ -482,6 +483,7 @@ check_python() {\n         PYTHON_PATH=\"$(\"$UV_CMD\" python find \"$PYTHON_VERSION\")\"\n         PYTHON_FOUND_VERSION=\"$(\"$PYTHON_PATH\" --version 2>/dev/null)\"\n         log_success \"Python installed: $PYTHON_FOUND_VERSION\"\n+        ensure_fts5\n     else\n         log_error \"Failed to install Python $PYTHON_VERSION\"\n         log_info \"Install Python $PYTHON_VERSION manually, then re-run this script\"","@@ -489,6 +491,51 @@ check_python() {\n     fi\n }\n \n+# Probe whether $1 (a python executable) links a SQLite with the FTS5\n+# module compiled in. Hermes' session store (hermes_state.py) creates FTS5\n+# virtual tables for full-text session search; a SQLite without FTS5 makes\n+# the bundled-python path unusable for that feature. Returns 0 if FTS5 works.\n+_python_has_fts5() {\n+    \"$1\" - <<'PY' 2>/dev/null\n+import sqlite3, sys\n+try:\n+    sqlite3.connect(\":memory:\").execute(\"CREATE VIRTUAL TABLE t USING fts5(x)\")\n+except Exception:\n+    sys.exit(1)\n+PY\n+}\n+\n+# Guarantee the resolved uv-managed interpreter ships FTS5. uv's Python\n+# distributions only gained FTS5 in mid-2025 (python-build-standalone #694),\n+# so a stale interpreter already in uv's store — which `uv python find`\n+# happily reuses — can lack it. When that happens, force a reinstall of the\n+# latest patch for $PYTHON_VERSION (which has FTS5) and re-resolve. This keeps\n+# the supported install path's session search working without bundling a\n+# second SQLite or asking the user to do anything.\n+ensure_fts5() {\n+    [ -n \"${PYTHON_PATH:-}\" ] || return 0\n+    if _python_has_fts5 \"$PYTHON_PATH\"; then\n+        return 0\n+    fi\n+\n+    log_warn \"Resolved Python's SQLite lacks the FTS5 module (session search needs it).\"\n+    log_info \"Reinstalling a current Python $PYTHON_VERSION with FTS5 via uv...\"\n+    if \"$UV_CMD\" python install \"$PYTHON_VERSION\" --reinstall >/dev/null 2>&1; then\n+        PYTHON_PATH=\"$(\"$UV_CMD\" python find \"$PYTHON_VERSION\" 2>/dev/null)\"\n+        PYTHON_FOUND_VERSION=\"$(\"$PYTHON_PATH\" --version 2>/dev/null)\"\n+    fi\n+\n+    if [ -n \"${PYTHON_PATH:-}\" ] && _python_has_fts5 \"$PYTHON_PATH\"; then\n+        log_success \"FTS5 available ($PYTHON_FOUND_VERSION)\"\n+    else\n+        # Could not obtain an FTS5-capable interpreter (offline, pinned env,\n+        # etc.). Install proceeds — Hermes degrades gracefully and disables\n+        # only full-text session search — but warn so it isn't a silent gap.\n+        log_warn \"Could not obtain an FTS5-capable Python. Hermes will run, but\"\n+        log_warn \"full-text session search will be disabled until FTS5 is present.\"\n+    fi\n+}\n+\n check_git() {\n     log_info \"Checking Git...\"\n "]},{"file":"tests/test_hermes_state.py","hunks":["@@ -1,11 +1,35 @@\n \"\"\"Tests for hermes_state.py — SessionDB SQLite CRUD, FTS5 search, export.\"\"\"\n \n+import sqlite3\n import time\n import pytest\n \n from hermes_state import SessionDB\n \n \n+class _NoFtsCursor(sqlite3.Cursor):\n+    \"\"\"Simulate a SQLite build without the fts5 module.\"\"\"\n+\n+    def execute(self, sql, parameters=()):\n+        probe = sql.strip()\n+        if probe in (\n+            \"SELECT * FROM messages_fts LIMIT 0\",\n+            \"SELECT * FROM messages_fts_trigram LIMIT 0\",\n+        ):\n+            raise sqlite3.OperationalError(\"no such table: \" + probe.split()[-3])\n+        return super().execute(sql, parameters)\n+\n+    def executescript(self, sql_script):\n+        if \"USING fts5\" in sql_script:\n+            raise sqlite3.OperationalError(\"no such module: fts5\")\n+        return super().executescript(sql_script)\n+\n+\n+class _NoFtsConnection(sqlite3.Connection):\n+    def cursor(self, factory=None):\n+        return super().cursor(factory or _NoFtsCursor)\n+\n+\n @pytest.fixture()\n def db(tmp_path):\n     \"\"\"Create a SessionDB with a temp database file.\"\"\"","@@ -135,6 +159,33 @@ def test_parent_session(self, db):\n         child = db.get_session(\"child\")\n         assert child[\"parent_session_id\"] == \"parent\"\n \n+    def test_db_initializes_without_fts5_module(self, tmp_path, monkeypatch):\n+        real_connect = sqlite3.connect\n+\n+        def connect_without_fts(*args, **kwargs):\n+            kwargs[\"factory\"] = _NoFtsConnection\n+            return real_connect(*args, **kwargs)\n+\n+        monkeypatch.setattr(\"hermes_state.sqlite3.connect\", connect_without_fts)\n+\n+        db = SessionDB(db_path=tmp_path / \"state.db\")\n+        try:\n+            assert db._fts_enabled is False\n+            # Neither FTS5 virtual table should have been created on a build\n+            # that lacks the fts5 module — both init paths must degrade.\n+            assert db._fts_table_exists(\"messages_fts\") is False\n+            assert db._fts_table_exists(\"messages_fts_trigram\") is False\n+\n+            db.create_session(session_id=\"s1\", source=\"cli\")\n+            db.append_message(\"s1\", role=\"user\", content=\"hello from sqlite without fts\")\n+\n+            messages = db.get_messages(\"s1\")\n+            assert len(messages) == 1\n+            assert messages[0][\"content\"] == \"hello from sqlite without fts\"\n+            assert db.search_messages(\"hello\") == []\n+        finally:\n+            db.close()\n+\n \n # =========================================================================\n # Message storage\n"]}]},{"kind":"pr","number":67389,"title":"fix(db): close SQLite FDs on context exit and fallback gracefully if FTS5 is missing","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"b405c3980","number":0},{"kind":"commit","value":"5eb55835f","number":0},{"kind":"commit","value":"0773b6644","number":0},{"kind":"commit","value":"99e1d39c1","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}