diff --git a/hermes_state.py b/hermes_state.py index 8d51a39507..bf9a24d31d 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -56,6 +56,7 @@ from hermes_state_common import ( # noqa: F401 (re-exported for back-compat) _shape_preview, _sql_session_last_active, _sql_session_last_active_by_id, + escape_like as _escape_like, DEFERRED_INDEX_SQL, FTS_CJK_STALE_KEY, FTS_SQL, @@ -176,17 +177,6 @@ def _delegate_from_json(col: str = "model_config") -> str: _MODEL_CONFIG_ROW_MISSING = object() -def _escape_like(text: str) -> str: - """Escape SQL LIKE wildcards so an operator-supplied filter matches - literally. Pair with ``ESCAPE '\\'`` in the clause. - - ``%`` and ``_`` are wildcards to LIKE, and ``_`` in particular is common - in the values these filters run against (branch names, session titles). - A filter documented as a substring match must not silently widen. - """ - return text.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") - - def _cwd_prefix_clause(cwd_prefix: str) -> Tuple[str, List[str]]: prefix = cwd_prefix.rstrip("/\\") or cwd_prefix # ``_`` and ``%`` are LIKE wildcards but ordinary characters in a path @@ -5286,12 +5276,7 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin) if exact: return exact["id"] - escaped = ( - session_id_or_prefix - .replace("\\", "\\\\") - .replace("%", "\\%") - .replace("_", "\\_") - ) + escaped = _escape_like(session_id_or_prefix) with self._lock: cursor = self._conn.execute( "SELECT id FROM sessions WHERE id LIKE ? ESCAPE '\\' ORDER BY started_at DESC LIMIT 2", @@ -5680,7 +5665,7 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin) # 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("_", "\\_") + escaped = _escape_like(title) with self._read_ctx() as conn: cursor = conn.execute( "SELECT id, title, started_at FROM sessions " @@ -5711,7 +5696,7 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin) # Find all existing numbered variants # Escape SQL LIKE wildcards (%, _) in the base to prevent false matches - escaped = base.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + escaped = _escape_like(base) with self._lock: cursor = self._conn.execute( "SELECT title FROM sessions WHERE title = ? OR title LIKE ? ESCAPE '\\'", @@ -5964,10 +5949,7 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin) filter_clauses: List[str] = [] def _like_pattern(needle: str) -> str: - escaped = ( - needle.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") - ) - return f"%{escaped}%" + return f"%{_escape_like(needle)}%" if id_needle: # Admit a surfaced row if its own id or any id in its forward @@ -8856,7 +8838,7 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin) cursor = conn.execute( "UPDATE sessions SET source = 'kanban' " "WHERE source = 'cli' AND (cwd = ? OR cwd LIKE ? ESCAPE '\\')", - (prefix, prefix.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "/%"), + (prefix, _escape_like(prefix) + "/%"), ) # Read rowcount before set_meta reuses this cursor for its INSERT, # which would otherwise overwrite it with the meta write's count. diff --git a/hermes_state_common.py b/hermes_state_common.py index 4e66115f81..be24d8a115 100644 --- a/hermes_state_common.py +++ b/hermes_state_common.py @@ -34,6 +34,18 @@ _PREVIEW_SCAFFOLD_WINDOW = 400 _PREVIEW_MAX_CHARS = 60 +def escape_like(text: str) -> str: + """Escape SQL LIKE wildcards so operator/session-derived text matches + literally. Pair with ``ESCAPE '\\'`` in the clause. + + ``%`` and ``_`` are wildcards to LIKE, and ``_`` in particular is common + in the values these patterns run against (branch names, session titles, + filesystem paths). A match documented as substring/prefix must not + silently widen. + """ + return text.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + + _PREVIEW_CONTENT_SQL = "REPLACE(REPLACE(m.content, X'0A', ' '), X'0D', ' ')" diff --git a/hermes_state_search.py b/hermes_state_search.py index 756884b3f2..600e2fdc9f 100644 --- a/hermes_state_search.py +++ b/hermes_state_search.py @@ -25,6 +25,7 @@ from hermes_state_common import ( MAX_FTS5_QUERY_CHARS, SCHEMA_VERSION, _FTS_CJK_TRIGGERS, + escape_like as _escape_like, ) # Moved methods logged under the "hermes_state" logger before the split; @@ -1730,7 +1731,7 @@ class SessionSearchMixin: token_clauses = [] like_params: list = [] for tok in non_op_tokens: - esc = tok.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + esc = _escape_like(tok) token_clauses.append( "(m.content LIKE ? ESCAPE '\\' OR m.tool_name LIKE ? ESCAPE '\\' OR m.tool_calls LIKE ? ESCAPE '\\')" ) @@ -1986,7 +1987,7 @@ class SessionSearchMixin: where = ["m.id > ? AND m.id <= ?"] params: list = [progress, high_water] for term in terms: - esc = term.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + esc = _escape_like(term) where.append( "(m.content LIKE ? ESCAPE '\\' OR m.tool_name LIKE ? ESCAPE '\\' " "OR m.tool_calls LIKE ? ESCAPE '\\')"