Files
hermes-agent/hermes_state_search.py
Teknium 31b1cbbe8d fix(multiplex): served profiles read their own sessions.* settings
gateway/run.py bridges the LAUNCH profile's sessions.cjk_fts / search_slow_ms
into HERMES_CJK_FTS / HERMES_SEARCH_SLOW_MS at import (and re-bridged them per
turn), and hermes_state_fts / hermes_state_search read os.getenv — so a served
secondary always got the default profile's values.

hermes_state_common.routed_sessions_setting() reads the routed profile's
config.yaml under a HERMES_HOME override and the env bridge when unscoped; both
consumers use it. The per-turn re-bridge is skipped inside a secondary's scope
so it can no longer write the default's slots from a routed turn.
2026-09-11 19:50:46 -07:00

1298 lines
72 KiB
Python

"""Full-text / trigram / CJK message search and FTS maintenance for SessionDB.
Plain mixin for ``hermes_state.SessionDB`` (no ``__init__``/state of its own).
Must never import hermes_state (cycle); shared constants live in hermes_state_common.
"""
import contextlib
import logging
import re
import sqlite3
import time
from typing import Any, Callable, Collection, Dict, List, Optional, Tuple
from agent.skill_commands import describe_skill_invocation
from hermes_state_common import (
FTS_CJK_STALE_KEY, FTS_SQL, FTS_STALE_KEY, FTS_STORAGE_VERSION, FTS_TOOL_CONTENT_PREFIX_CHARS,
FTS_TOOL_FULL_CONTENT_HIGH_WATER_KEY, FTS_TRIGRAM_EXCLUDED_SOURCES, FTS_TRIGRAM_SQL,
MAX_FTS5_QUERY_CHARS, SCHEMA_VERSION, _FTS_CJK_TRIGGERS,
escape_like as _escape_like, fts_rebuild_admission, fts_trigram_session_sql, routed_sessions_setting,
)
# Pre-split logger identity so log filtering/capture is unchanged.
logger = logging.getLogger("hermes_state")
def _search_slow_ms() -> float:
"""``sessions.search_slow_ms`` for the served profile (default 1000; 0 logs every call)."""
value = routed_sessions_setting("search_slow_ms", "HERMES_SEARCH_SLOW_MS")
try:
return 1000.0 if value is None or str(value).strip() == "" else float(value)
except (TypeError, ValueError):
return 1000.0
# Characters FTS5's query grammar rejects outside a quoted phrase (anything missing
# reaches MATCH raw and raises -> zero results). ``%`` is deliberately excluded: the
# CJK LIKE fallback needs it literal (that path escapes wildcards itself).
_FTS5_SPECIAL_CHARS = '+{}():"^@/#&|~[]<>,;!?$=\\\''
_FTS5_SPECIAL_RE = re.compile(f"[{re.escape(_FTS5_SPECIAL_CHARS)}]")
_FTS_OPERATORS = frozenset({"AND", "OR", "NOT"})
_LIKE_SKIP_TOKENS = _FTS_OPERATORS | {"NEAR"}
_LIKE_TOKEN_RE = re.compile(r'"[^"]+"|\S+')
_QUOTED_PHRASE_RE = re.compile(r'"[^"]*"')
# Column list shared by every search route (snippet + metadata, never content).
_SEARCH_SELECT_TAIL = "m.timestamp, m.tool_name, s.source, s.model, s.started_at AS session_started"
_LIKE_SNIPPET_SQL = "substr(m.content, max(1, instr(m.content, ?) - 40), 120) AS snippet"
_LIKE_ANY_COLUMN_SQL = (
"(m.content LIKE ? ESCAPE '\\' OR m.tool_name LIKE ? ESCAPE '\\' OR m.tool_calls LIKE ? ESCAPE '\\')"
)
_LIKE_COALESCED_COLUMN_SQL = (
"(COALESCE(m.content, '') LIKE ? ESCAPE '\\' OR "
"COALESCE(m.tool_name, '') LIKE ? ESCAPE '\\' OR "
"COALESCE(m.tool_calls, '') LIKE ? ESCAPE '\\')"
)
# ``sort`` -> ORDER BY for the FTS routes; unknown values are rank-only (user input passes through).
_FTS_ORDER_BY = {"newest": "ORDER BY m.timestamp DESC, rank", "oldest": "ORDER BY m.timestamp ASC, rank"}
# Indexed neighbor seeks avoid scanning whole sessions for a sparse set of hits.
_CONTEXT_WINDOW_SQL = """WITH target AS (
SELECT session_id, timestamp, id FROM messages WHERE id IN ({ids})
)
SELECT t.id AS match_id, m.role, m.content
FROM target t JOIN messages m ON m.id IN (
t.id,
(SELECT p.id FROM messages p
WHERE p.session_id = t.session_id AND (p.timestamp, p.id) < (t.timestamp, t.id)
ORDER BY p.timestamp DESC, p.id DESC LIMIT 1),
(SELECT n.id FROM messages n
WHERE n.session_id = t.session_id AND (n.timestamp, n.id) > (t.timestamp, t.id)
ORDER BY n.timestamp, n.id LIMIT 1)
)
ORDER BY t.id, m.timestamp, m.id"""
# Unified Ideographs, Extension A, Extension B, CJK Symbols, Hiragana, Katakana, Hangul Syllables.
_CJK_RANGES = (
(0x4E00, 0x9FFF), (0x3400, 0x4DBF), (0x20000, 0x2A6DF), (0x3000, 0x303F), (0x3040, 0x309F),
(0x30A0, 0x30FF), (0xAC00, 0xD7AF),
)
def _meta_row(conn, key: str) -> Optional[sqlite3.Row]:
"""Point-read one ``state_meta`` row (``None`` when absent)."""
return conn.execute("SELECT value FROM state_meta WHERE key = ?", (key,)).fetchone()
def _delete_meta(conn, *keys: str) -> None:
conn.execute(f"DELETE FROM state_meta WHERE key IN ({','.join('?' for _ in keys)})", keys)
def _is_cjk(cp: int) -> bool:
return any(lo <= cp <= hi for lo, hi in _CJK_RANGES)
def _non_operator_tokens(raw_query: str) -> List[str]:
return [t for t in raw_query.split() if t.upper() not in _FTS_OPERATORS]
def _quote_fts_tokens(raw_query: str) -> str:
"""Quote each non-operator token (neutralising FTS5 special characters), keeping AND/OR/NOT."""
return " ".join(
tok if tok.upper() in _FTS_OPERATORS else '"' + tok.replace('"', '""') + '"' for tok in raw_query.split()
)
def _like_params(term: str) -> List[str]:
"""One ``%term%`` bind per column of ``_LIKE_ANY_COLUMN_SQL``."""
return [f"%{_escape_like(term)}%"] * 3
def _flatten_text(decoded: Any) -> str:
"""Multimodal part list -> joined text (or the placeholder); str passes through; else ''."""
if isinstance(decoded, list):
parts = [p.get("text", "") for p in decoded if isinstance(p, dict) and p.get("type") == "text"]
return " ".join(t for t in parts if t).strip() or "[multimodal content]"
return decoded if isinstance(decoded, str) else ""
def _positive_int(name: str, value: Any) -> None:
if isinstance(value, bool) or not isinstance(value, int):
raise TypeError(f"{name} must be an integer")
if value <= 0:
raise ValueError(f"{name} must be greater than zero")
def _search_select_sql(snippet_sql: str, from_sql: str, where: List[str], order_by: str, limit_sql: str) -> str:
"""Result-row SELECT shared by the FTS and LIKE routes (SQL text is pinned)."""
return f"""
SELECT m.id, m.session_id, m.role,
{snippet_sql},
{_SEARCH_SELECT_TAIL}
FROM {from_sql}
JOIN sessions s ON s.id = m.session_id
WHERE {' AND '.join(where)}
{order_by}
{limit_sql}
"""
def _search_filter_clauses(
where: List[str], params: list, *, include_inactive: bool, source_filter: Optional[List[str]],
exclude_sources: Optional[List[str]], role_filter: Optional[List[str]]) -> None:
"""Append the visibility/source/role predicates every search route shares. Live rows
(active=1) AND compaction-archived rows (compacted=1) are discoverable; only
rewind/undo rows (active=0, compacted=0) are hidden."""
if not include_inactive:
where.append("(m.active = 1 OR m.compacted = 1)")
# display_kind="hidden" rows are model-facing scaffolding the person never saw; a hit would confuse.
where.append("COALESCE(m.display_kind, '') <> 'hidden'")
if source_filter is not None:
where.append(f"s.source IN ({','.join('?' for _ in source_filter)})")
params.extend(source_filter)
if exclude_sources is not None:
where.append(f"s.source NOT IN ({','.join('?' for _ in exclude_sources)})")
params.extend(exclude_sources)
if role_filter:
where.append(f"m.role IN ({','.join('?' for _ in role_filter)})")
params.extend(role_filter)
class SessionSearchMixin:
"""See module docstring — mixin for SessionDB (Search cluster)."""
_SEARCH_MESSAGE_RESULT_FIELDS = (
"id", "session_id", "role", "snippet", "timestamp", "tool_name", "source", "model", "session_started", "context"
)
@classmethod
def _search_message_fields(cls, fields: Optional[Collection[str]]) -> Optional[Tuple[str, ...]]:
"""Validate and canonically order an optional result projection."""
if fields is None:
return None
if isinstance(fields, str):
raise TypeError("search fields must be a collection of field names, not a string")
requested = set(fields)
unknown = requested.difference(cls._SEARCH_MESSAGE_RESULT_FIELDS)
if unknown:
raise ValueError(f"unknown search result field(s): {', '.join(sorted(unknown))}")
return tuple(field for field in cls._SEARCH_MESSAGE_RESULT_FIELDS if field in requested)
def _try_incremental_merge_fts(self) -> None:
"""One bounded FTS5 merge pass that never fails the already-committed write (a caller
must never replay an ambiguous, possibly-durable write — even on the bare
SystemError CPython's sqlite3 layer can raise under cross-thread errmsg scrambling)."""
if not self._fts_enabled:
return
try:
self._merge_fts_incrementally(max_pages=self._FTS_MERGE_MAX_PAGES_PER_INDEX)
except Exception as exc: # noqa: BLE001 - post-commit maintenance
# The canonical write is already committed before this cadence runs. No maintenance failure —
# including the bare SystemError the CPython sqlite3 layer can raise under cross-thread errmsg
# scrambling — may escape and make the caller replay an ambiguous, possibly-durable write
# (#90734, #85079).
logger.warning("FTS incremental merge failed after commit: %s", exc)
# ── Deferred rebuild engine (base + CJK backfills) ─────────────────────
def fts_rebuild_status(self) -> Optional[Dict[str, Any]]:
"""Deferred-rebuild progress ``{"pending", "total", "indexed", "percent"}`` or None. Reads
via the pooled reader (not get_meta/self._lock) so search never blocks on the writer."""
return self._rebuild_status("fts_rebuild")
def fts_cjk_rebuild_status(self) -> Optional[Dict[str, Any]]:
"""CJK-index backfill progress, or None when none is pending."""
return self._rebuild_status("fts_cjk_rebuild")
def _rebuild_status(self, prefix: str) -> Optional[Dict[str, Any]]:
rows = self._read_all("SELECT key, value FROM state_meta WHERE key IN (?, ?)",
(f"{prefix}_high_water", f"{prefix}_progress"))
meta = {r["key"]: r["value"] for r in rows}
high_water = meta.get(f"{prefix}_high_water")
if high_water is None or int(high_water) <= 0:
return None
total, progress = int(high_water), int(meta.get(f"{prefix}_progress") or 0)
return {"pending": True, "total": total, "indexed": progress, "percent": min(100, int(100 * progress / total))}
# Re-index rows in an id window the index is missing. docsize has one row
# per indexed doc, so the anti-join is exact. Params: (lo, hi) — the base sweep
# takes (hw, prefix_chars, lo, hi): tool rows past the high water index only a prefix.
_BOUNDARY_SWEEP_SQL = (
"INSERT INTO {table}(rowid, content, tool_name, tool_calls) "
"SELECT m.id, m.content, m.tool_name, m.tool_calls FROM messages m WHERE m.id > ? AND m.id <= ? {extra}"
"AND NOT EXISTS (SELECT 1 FROM {table}_docsize d WHERE d.id = m.id)"
)
_BASE_BOUNDARY_SWEEP_SQL = (
"INSERT INTO messages_fts(rowid, content, tool_name, tool_calls) "
"SELECT m.id, CASE WHEN m.role = 'tool' AND m.id > ? THEN substr(COALESCE(m.content, ''), 1, ?) "
"ELSE m.content END, m.tool_name, m.tool_calls FROM messages m WHERE m.id > ? AND m.id <= ? "
"AND NOT EXISTS (SELECT 1 FROM messages_fts_docsize d WHERE d.id = m.id)"
)
# Trigram excludes tool rows and FTS_TRIGRAM_EXCLUDED_SOURCES sessions; no tool_calls column.
_TRIGRAM_BOUNDARY_SWEEP_SQL = (
"INSERT INTO messages_fts_trigram(rowid, content, tool_name) "
"SELECT m.id, m.content, m.tool_name FROM messages m JOIN sessions s ON s.id = m.session_id "
f"WHERE m.id > ? AND m.id <= ? AND m.role <> 'tool' AND {fts_trigram_session_sql('s')} "
"AND NOT EXISTS (SELECT 1 FROM messages_fts_trigram_docsize d WHERE d.id = m.id)"
)
_CHUNK_INSERT_SQL = (
"INSERT INTO {table}(rowid, content, tool_name, tool_calls) "
"SELECT id, content, tool_name, tool_calls FROM messages WHERE id > ? AND id <= ?{extra}"
)
_TRIGRAM_CHUNK_INSERT_SQL = (
"INSERT INTO messages_fts_trigram(rowid, content, tool_name) "
"SELECT m.id, m.content, m.tool_name FROM messages m JOIN sessions s ON s.id = m.session_id "
f"WHERE m.id > ? AND m.id <= ? AND m.role <> 'tool' AND {fts_trigram_session_sql('s')}"
)
def _fts_rebuild_finish(self) -> None:
"""Finalize the deferred rebuild: boundary sweep + clear markers. The sweep is cheap
insurance against a write that slipped between high_water capture and trigger
activation. The trigram half is gated on ``_trigram_available``: without the
tokenizer/table an unconditional INSERT raises and aborts the whole rebuild."""
sweeps = [(self._BASE_BOUNDARY_SWEEP_SQL, True)]
if self._trigram_available:
sweeps.append((self._TRIGRAM_BOUNDARY_SWEEP_SQL, False))
self._rebuild_finish("fts_rebuild", sweeps)
logger.info("Deferred FTS rebuild complete — all messages indexed.")
def _fts_cjk_rebuild_finish(self) -> None:
"""Boundary sweep + clear the cjk markers; index becomes servable."""
sweep = self._BOUNDARY_SWEEP_SQL.format(table="messages_fts_cjk", extra="AND m.role <> 'tool' ")
self._rebuild_finish("fts_cjk_rebuild", [(sweep, False)])
self._fts_cjk_available = True
logger.info("CJK FTS index backfill complete — serving CJK search.")
def _rebuild_finish(self, prefix: str, sweep_sqls: List[Tuple[str, bool]]) -> None:
"""Sweep a generous window around the high-water boundary, then clear the markers.
``(sql, bounded)``: a bounded sweep takes the (hw, prefix_chars) tool-content params first."""
def _do(conn):
hw_row = _meta_row(conn, f"{prefix}_high_water")
if hw_row is not None:
hw = int(hw_row[0])
for sql, bounded in sweep_sqls:
params = (hw, FTS_TOOL_CONTENT_PREFIX_CHARS) if bounded else ()
conn.execute(sql, (*params, hw - 1000, hw + 1000))
_delete_meta(conn, f"{prefix}_high_water", f"{prefix}_progress")
self._execute_write(_do)
def fts_rebuild_step(self) -> bool:
"""Backfill one chunk of the deferred FTS rebuild; True while work remains. Chunks are
claimed atomically inside the write transaction, so concurrent processes interleave
instead of duplicating rows."""
if not self._fts_enabled:
return False
inserts = [self._CHUNK_INSERT_SQL.format(table="messages_fts", extra="")]
if self._trigram_available:
inserts.append(self._TRIGRAM_CHUNK_INSERT_SQL)
return self._rebuild_step("fts_rebuild", inserts, fail_msg="FTS rebuild chunk failed (will retry): %s",
finish=self._fts_rebuild_finish, finish_when_empty=True)
def fts_cjk_rebuild_step(self) -> bool:
"""Backfill one chunk of the CJK index. True while work remains."""
if not self._fts_enabled or not self._fts_cjk_loaded:
return False
insert = self._CHUNK_INSERT_SQL.format(table="messages_fts_cjk", extra=" AND role <> 'tool'")
return self._rebuild_step("fts_cjk_rebuild", [insert], finish=self._fts_cjk_rebuild_finish,
fail_msg="CJK FTS rebuild chunk failed (will retry): %s")
def _rebuild_step(self, prefix: str, insert_sqls: List[str], *, fail_msg: str, finish,
finish_when_empty: bool = False) -> bool:
"""Shared chunk engine for the base and CJK deferred backfills. ``finish_when_empty``
finalizes a high_water <= 0 marker (empty messages table) instead of leaving it pending."""
high_water_raw = self.get_meta(f"{prefix}_high_water")
if high_water_raw is None:
return False
high_water = int(high_water_raw)
chunk = self._FTS_REBUILD_CHUNK_ROWS
def _do(conn):
# Re-reading progress inside the BEGIN IMMEDIATE held by _execute_write IS
# the claim: two workers cannot read the same value.
row = _meta_row(conn, f"{prefix}_progress")
if row is None:
return False # finished (or cleared) by another process
progress = int(row[0])
if progress >= high_water:
return False
# Upper bound is an id, not a row count, so deleted-row gaps don't shrink chunks.
upper = min(progress + chunk, high_water)
for sql in insert_sqls:
conn.execute(sql, (progress, upper))
# Progress lands in the same transaction as its rows (crash-atomic).
conn.execute("UPDATE state_meta SET value = ? WHERE key = ?", (str(upper), f"{prefix}_progress"))
return upper < high_water
try:
more = self._execute_write(_do)
except sqlite3.OperationalError as exc:
logger.debug(fail_msg, exc)
return True # transient (lock contention) — caller retries
if more is False:
status = self._rebuild_status(prefix)
if (finish_when_empty and high_water <= 0) or (
status is not None and status["indexed"] >= status["total"]
):
finish()
return False
return bool(more)
def _fts_teardown_trash_step(self) -> bool:
"""Tear down one chunk of a demoted v22 FTS shadow table (a PLAIN table now); True while
work remains. INTEGER single-column-key tables drain with a high-water marker so
each chunk's scan is bounded (restarting the scan was O(n²)); compound-key tables
keep the chunked ``LIMIT`` delete — they are small by construction.
Single-column-key trash tables (the common shape — FTS shadow tables carry a rowid/integer PK) are
drained with a high-water marker mirroring :meth:`fts_rebuild_step`: each chunk deletes only rows
after the previously-drained key, so the per-chunk scan is bounded instead of re-scanning from the
start of the table every chunk (O(n²) total on large trash tables, #79324).
"""
with self._read_ctx() as conn:
trash = [r[0] for r in conn.execute(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE ? ESCAPE '\\'",
(self._FTS_TRASH_PREFIX.replace("_", "\\_") + "%",),
).fetchall()]
if not trash:
return False
tbl = trash[0]
def _do(conn):
pk_info = [(r[1], (r[2] or "").upper()) for r in conn.execute(f"PRAGMA table_info({tbl})") if r[5] > 0]
key = ", ".join(name for name, _typ in pk_info) if pk_info else "rowid"
if len(pk_info) == 1 and pk_info[0][1] == "INTEGER":
# High-water drain; marker read/written in the same BEGIN IMMEDIATE as the
# DELETE so concurrent callers claim disjoint ranges. Only INTEGER pks
# anchor the comparison (the TEXT-pk config shadow table falls through).
marker_key = f"fts_teardown_{tbl}_progress"
row = _meta_row(conn, marker_key)
high_water = int(row[0]) if row is not None else 0
# Claim the LAST row of the LIMIT window so a full chunk goes per step.
upper_rows = conn.execute(
f"SELECT {key} FROM {tbl} WHERE {key} > ? ORDER BY {key} LIMIT {self._FTS_REBUILD_CHUNK_ROWS}",
(high_water,),
).fetchall()
if not upper_rows:
return _drop(conn, marker_key)
upper = upper_rows[-1][0]
cur = conn.execute(f"DELETE FROM {tbl} WHERE {key} > ? AND {key} <= ?", (high_water, upper))
if cur.rowcount > 0:
self.set_meta(marker_key, str(upper), cursor=conn)
return True
# Compound-key or rowid trash table: legacy chunked delete. These shadow tables are small, so
# the quadratic re-scan is not a concern (#79324 keeps the high-water path for the big
# single-key tables).
cur = conn.execute(
f"DELETE FROM {tbl} WHERE ({key}) IN (SELECT {key} FROM {tbl} LIMIT {self._FTS_REBUILD_CHUNK_ROWS})"
)
return _drop(conn) if cur.rowcount == 0 else True # True: more trash tables / chunks may remain
def _drop(conn, marker_key: Optional[str] = None) -> bool:
"""Drained — the DROP is cheap now. True: re-check for more trash."""
conn.execute(f"DROP TABLE IF EXISTS {tbl}")
if marker_key is not None:
_delete_meta(conn, marker_key)
logger.info("Old FTS shadow table %s torn down.", tbl)
return True
try:
return bool(self._execute_write(_do))
except sqlite3.OperationalError as exc:
logger.debug("FTS trash teardown chunk failed (will retry): %s", exc)
return True
def _fts_cjk_reset_if_stale(self) -> None:
"""From-scratch rebuild of a stale cjk index (triggers were dropped, gap extent unknown):
drop table + triggers, clear the breadcrumb, recreate (fresh backfill markers)."""
if not self._fts_cjk_loaded:
return
def _do(conn):
if _meta_row(conn, FTS_CJK_STALE_KEY) is None:
return False
for trig in _FTS_CJK_TRIGGERS:
conn.execute(f"DROP TRIGGER IF EXISTS {trig}")
conn.execute("DROP TABLE IF EXISTS messages_fts_cjk")
conn.execute("DROP VIEW IF EXISTS messages_fts_cjk_src")
_delete_meta(conn, FTS_CJK_STALE_KEY, "fts_cjk_rebuild_high_water", "fts_cjk_rebuild_progress")
return True
if self._execute_write(_do):
# Recreate OUTSIDE the write transaction: executescript() implicitly commits.
self._ensure_cjk_schema_committed()
def _ensure_cjk_schema_committed(self) -> None:
with self._lock:
self._ensure_fts_cjk_schema(self._conn)
self._conn.commit()
def _fts_external_index_empty_with_messages(self, conn) -> bool:
"""True when the base FTS table indexes nothing while ``messages`` has rows (post-demote
empty-index shape). Caller holds ``self._lock``. docsize is the authoritative "is this
rowid indexed" surface; EXISTS not COUNT(*) because this runs on every writable open."""
try:
if not conn.execute("SELECT EXISTS(SELECT 1 FROM messages)").fetchone()[0]:
return False
return not conn.execute("SELECT EXISTS(SELECT 1 FROM messages_fts_docsize)").fetchone()[0]
except sqlite3.OperationalError:
return False # table absent / FTS disabled mid-init — not this failure class
def _reseed_missing_progress(self, conn) -> None:
"""high_water without progress: fts_rebuild_step reads missing progress as "done by
another process" and optimize would no-op then stamp. Reset to known-empty, re-seed.
Truncation goes through FTS5 ``'delete-all'`` (a plain DELETE is O(rows) and corrupts
the index when indexed rows diverged from ``messages``); the backfill worker replays
without an anti-join, so it needs a known-empty index. A missing docsize table counts
as empty."""
if _meta_row(conn, "fts_rebuild_progress") is None:
if not self._fts_index_known_empty(conn):
self._reset_fts_index_to_empty(conn)
self.set_meta("fts_rebuild_progress", "0", cursor=conn)
@staticmethod
def _fts_index_known_empty(conn) -> bool:
"""True when the base external-content index holds no rows (a missing table counts as empty)."""
try:
return int(conn.execute("SELECT COUNT(*) FROM messages_fts_docsize").fetchone()[0]) == 0
except sqlite3.OperationalError:
return True
@staticmethod
def _reset_fts_index_to_empty(conn) -> None:
"""Truncate the v23 external-content tables via FTS5 ``'delete-all'`` (O(1); a plain DELETE is
O(rows) and corrupts the index when indexed rows diverged from ``messages``)."""
for tbl in ("messages_fts", "messages_fts_trigram"):
with contextlib.suppress(sqlite3.OperationalError): # table absent — already an empty surface
conn.execute(f"INSERT INTO {tbl}({tbl}) VALUES('delete-all')")
def _seed_fts_rebuild_markers(self, conn, *, force: bool = False) -> int:
"""Write ``fts_rebuild_high_water`` / ``fts_rebuild_progress`` for a full backfill; returns
the high-water id. Without ``force`` an existing high_water only gets a missing
progress key repaired. Caller holds the write transaction."""
existing_hw = _meta_row(conn, "fts_rebuild_high_water")
if existing_hw is not None and not force:
self._reseed_missing_progress(conn)
self.set_meta(FTS_TOOL_FULL_CONTENT_HIGH_WATER_KEY, str(int(existing_hw[0])), cursor=conn)
return int(existing_hw[0])
hw = conn.execute("SELECT COALESCE(MAX(id), 0) FROM messages").fetchone()[0]
self.set_meta("fts_rebuild_high_water", str(hw), cursor=conn)
self.set_meta("fts_rebuild_progress", "0", cursor=conn)
self.set_meta(FTS_TOOL_FULL_CONTENT_HIGH_WATER_KEY, str(hw), cursor=conn)
return int(hw)
def _repair_optimize_bookkeeping(self) -> None:
"""Heal interrupted demote/backfill bookkeeping before optimize runs: orphan high_water
gets progress re-seeded; an empty external index with messages and no markers gets
a full backfill claim. Never invents markers on a still-legacy inline DB — optimize
would then skip demote and INSERT against the inline table forever.
Covers two post-#65798 failure classes:
"""
def _do(conn):
if _meta_row(conn, "fts_rebuild_high_water") is not None:
self._reseed_missing_progress(conn)
return
if self._db_has_legacy_inline_fts(conn):
return # demote owns marker creation
if self._fts_external_index_empty_with_messages(conn):
_delete_meta(conn, "fts_storage_version")
self._seed_fts_rebuild_markers(conn, force=True)
self._execute_write(_do)
def fts_optimize_available(self) -> bool:
"""True when `optimize_fts_storage()` has work: legacy inline FTS or a v23 trigram still
carrying ``tool_calls`` (``_db_needs_fts_storage_upgrade``), an interrupted optimize
(markers/trash), a CJK backfill on this tokenizer-capable host, or an empty external
index without markers. False when FTS5 is unavailable."""
if not self._fts_enabled or self.read_only:
return False
with self._read_ctx() as conn:
return (
self._db_needs_fts_storage_upgrade(conn)
or _meta_row(conn, "fts_rebuild_high_water") is not None # interrupted optimize
# CJK work is only offerable when THIS process can tokenize.
or (self._fts_cjk_loaded and (
_meta_row(conn, "fts_cjk_rebuild_high_water") is not None
or _meta_row(conn, FTS_CJK_STALE_KEY) is not None
))
or self._has_fts_trash(conn)
or self._fts_external_index_empty_with_messages(conn)
)
def _demote_legacy_fts_to_trash(self) -> int:
"""Demote upgrade-eligible FTS vtables and stage their shadow tables for chunked
teardown; returns MAX(messages.id) as the rebuild high water. O(1) schema surgery
— the heavy delete is deferred. Markers land in the same BEGIN IMMEDIATE, BEFORE
the empty v23 schema is created (``executescript`` implicitly COMMITs), closing
the crash window where trash + empty v23 tables exist with no backfill claim."""
def _stage(conn):
self._drop_fts_triggers(conn)
conn.execute("DROP VIEW IF EXISTS messages_fts_trigram_src")
if conn.execute(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name IN ('messages_fts', 'messages_fts_trigram') "
"AND sql LIKE 'CREATE VIRTUAL TABLE%' LIMIT 1"
).fetchone():
conn.execute("PRAGMA writable_schema=ON")
conn.execute(
"DELETE FROM sqlite_master WHERE type = 'table' "
"AND name IN ('messages_fts', 'messages_fts_trigram') AND sql LIKE 'CREATE VIRTUAL TABLE%'"
)
conn.execute("PRAGMA writable_schema=RESET")
for row in conn.execute(
"SELECT name FROM sqlite_master WHERE type = 'table' "
"AND (name LIKE 'messages_fts_%' ESCAPE '\\' "
"OR name LIKE 'messages_fts_trigram_%' ESCAPE '\\') "
# messages_fts_cjk* is an independent v23+ index, not part of the
# demoted legacy layout: fts5's xRename renames the entire shadow
# family in one step, so sweeping the cjk vtable here aborts the
# loop on the next cjk shadow entry and drags _config — needed by
# the vtable constructor — into the trash family (#103647).
"AND name NOT LIKE 'messages\\_fts\\_cjk%' ESCAPE '\\'"
).fetchall():
conn.execute(f"ALTER TABLE {row[0]} RENAME TO fts_v22_trash_{row[0]}")
# Claim the backfill BEFORE the empty v23 tables exist so a crash before
# schema ensure resumes instead of stamping an empty index.
hw = self._seed_fts_rebuild_markers(conn, force=True)
_delete_meta(conn, "fts_optimize_available")
return hw
hw = int(self._execute_write(_stage))
# Outside the write transaction (executescript commits); markers are durable.
self._ensure_v23_fts_tables("failed to create v23 messages_fts during optimize-storage demote")
return hw
def _ensure_v23_fts_tables(self, failure_message: str) -> None:
"""Ensure the v23 base + trigram tables under the lock (IF NOT EXISTS, cheap); raise
*failure_message* without the base table (the backfill loop would retry forever)."""
with self._lock:
base_ok = self._ensure_fts_schema(self._conn, "messages_fts", FTS_SQL)
trigram_ok = self._ensure_fts_schema(self._conn, "messages_fts_trigram", FTS_TRIGRAM_SQL)
self._trigram_available = bool(trigram_ok)
if not base_ok:
raise sqlite3.OperationalError(failure_message)
self._conn.commit()
def _optimize_vacuum(self) -> bool:
"""Phase 3: reclaim freed pages to the OS. False when VACUUM failed (usually no free disk
for its temp copy; a later VACUUM reclaims)."""
try:
with self._lock:
self._conn.execute("VACUUM")
vacuum_ok = True
except sqlite3.OperationalError as exc:
logger.warning("VACUUM after FTS optimize failed: %s", exc)
vacuum_ok = False
# Best-effort WAL fold-back, REFUSED (SQLITE_BUSY) while another connection holds a
# read-mark (callers size via logical_size_bytes, not stat()). PASSIVE, never TRUNCATE:
# a TRUNCATE reset from a transient CLI would race a live writer.
try:
with self._lock:
# Best-effort: fold the WAL back into the main file so the on-disk size settles now rather
# than at close(). Callers must therefore NOT size the result by stat()ing the file; use
# :meth:`logical_size_bytes`, which is truthful immediately regardless of readers. See
# #45383.
self._conn.execute("PRAGMA wal_checkpoint(PASSIVE)")
except Exception as exc:
logger.debug("WAL checkpoint (PASSIVE) after optimize VACUUM failed: %s", exc)
return vacuum_ok
def _optimize_settle(self, conn) -> Optional[str]:
"""Phase 4 (inside the write transaction, so a concurrent writer cannot race a stamp past
incomplete work): stamp the FTS layout (source of truth for "optimized"), clear the
"available" flag, advance a lagging schema_version. Returns a refusal reason or None.
Refuses while optimize work remains; an empty base index against non-empty messages
also refuses (settling there meant permanent search-index loss)."""
if _meta_row(conn, "fts_rebuild_high_water") is not None:
return "backfill_incomplete"
if self._has_fts_trash(conn):
return "teardown_incomplete"
if self._fts_external_index_empty_with_messages(conn):
return "backfill_incomplete"
self.set_meta("fts_storage_version", str(FTS_STORAGE_VERSION), cursor=conn)
_delete_meta(conn, "fts_optimize_available")
conn.execute("UPDATE schema_version SET version = ? WHERE version < ?", (SCHEMA_VERSION, SCHEMA_VERSION))
return None
def optimize_fts_storage(
self, *, progress_cb: Optional[Callable[[Dict[str, Any]], None]] = None, vacuum: bool = True
) -> Dict[str, Any]:
"""Repair an older FTS layout into the current v23 shape, foreground and to completion:
legacy-v22 inline -> external-content, or a v23 ``messages_fts_trigram`` that still stores
``tool_calls``. Re-running resumes. ``progress_cb`` receives {"phase", "percent",
"indexed", "total"}. A missing trigram tokenizer is not fatal (CJK falls back to LIKE)."""
if not self._fts_enabled:
return {"ok": False, "reason": "fts5_unavailable"}
if self.read_only:
return {"ok": False, "reason": "read_only"}
# Heal bookkeeping BEFORE deciding whether to demote again.
self._repair_optimize_bookkeeping()
with self._lock:
needs_storage_upgrade = self._db_needs_fts_storage_upgrade(self._conn)
pending = self.get_meta("fts_rebuild_high_water") is not None
if needs_storage_upgrade and not pending:
self._demote_legacy_fts_to_trash()
elif pending and not needs_storage_upgrade:
# Resume mid-demote: the process may have died between the staged demote
# commit and schema ensure.
self._ensure_v23_fts_tables("failed to re-create v23 messages_fts on optimize-storage resume")
# A stale CJK index can only be recovered from scratch; then ensure table +
# markers exist (a v23 DB gaining the cjk index for the first time).
self._fts_cjk_reset_if_stale()
if self._fts_cjk_loaded:
self._ensure_cjk_schema_committed()
def _emit(phase: str) -> None:
if progress_cb is None:
return
st = self.fts_rebuild_status() or self.fts_cjk_rebuild_status()
progress_cb({"phase": phase, "percent": st["percent"] if st else 100,
"indexed": st["indexed"] if st else 0, "total": st["total"] if st else 0})
def _drive(phase: str, step) -> None:
"""Run *step* to completion; the inter-chunk sleep is the single place the duty
cycle is enforced — back-to-back BEGIN IMMEDIATE chunks starve a live
gateway/CLI out of its lock retries."""
while True:
_t0 = time.monotonic()
if not step():
break
_emit(phase)
time.sleep(max(self._FTS_REBUILD_MIN_PAUSE, (time.monotonic() - _t0) * self._FTS_REBUILD_DUTY_FACTOR))
# Phase 1: base backfill; 1b: CJK-bigram backfill (own marker pair).
_emit("backfill")
_drive("backfill", self.fts_rebuild_step)
_emit("backfill")
_drive("backfill", self.fts_cjk_rebuild_step)
# Phase 2: tear down the demoted legacy shadow tables in chunks.
_emit("teardown")
_drive("teardown", self._fts_teardown_trash_step)
with self._read_ctx() as conn:
still_pending = _meta_row(conn, "fts_rebuild_high_water") is not None
still_trash = self._has_fts_trash(conn)
empty_index = self._fts_external_index_empty_with_messages(conn)
if still_pending or still_trash or empty_index:
reason = "backfill_incomplete" if still_pending or empty_index else "teardown_incomplete"
logger.warning("FTS storage optimization did not settle (%s): pending=%s trash=%s empty_index=%s",
reason, still_pending, still_trash, empty_index)
return {"ok": False, "reason": reason, "vacuumed": None}
vacuum_ok = None
if vacuum:
_emit("vacuum")
vacuum_ok = self._optimize_vacuum()
refusal = self._execute_write(self._optimize_settle)
if refusal is not None:
# A concurrent process changed state since the pre-vacuum check; a re-run can still settle.
logger.warning("FTS storage optimization settle refused (%s)", refusal)
return {"ok": False, "reason": refusal, "vacuumed": vacuum_ok}
_emit("done")
logger.info("FTS storage optimization complete (layout v%d).", FTS_STORAGE_VERSION)
return {"ok": True, "vacuumed": vacuum_ok}
# ── Read views ─────────────────────────────────────────────────────────
def get_anchored_view(
self, session_id: str, around_message_id: int, window: int = 5, bookend: int = 3,
keep_roles: Optional[Tuple[str, ...]] = ("user", "assistant")) -> Dict[str, Any]:
"""Anchored window (``get_messages_around``) plus session bookends, so one call yields the
goal and the resolution of a long session. ``window`` is filtered to ``keep_roles``
(None disables) EXCEPT the anchor; ``bookend_start`` / ``bookend_end`` are the
first/last ``bookend`` non-empty-content messages with ids strictly outside the
window (empty when it overlaps the head/tail). Empty result when the anchor isn't
in the session."""
bookend = max(bookend, 0)
primitive = self.get_messages_around(session_id, around_message_id, window=window)
window_rows = primitive["window"]
if not window_rows:
return {"window": [], "messages_before": 0, "messages_after": 0, "bookend_start": [], "bookend_end": []}
filtered_window = window_rows
if keep_roles is not None:
keep_set = set(keep_roles)
filtered_window = [m for m in window_rows if m.get("id") == around_message_id or m.get("role") in keep_set]
bookend_start_rows: List[Any] = []
bookend_end_rows: List[Any] = []
if bookend > 0:
role_clause = "" if keep_roles is None else f" AND role IN ({','.join('?' for _ in keep_roles)})"
role_params = [] if keep_roles is None else list(keep_roles)
with self._read_ctx() as conn:
def _bookend(op: str, boundary_id: int, order: str):
return conn.execute(
f"SELECT * FROM messages "
f"WHERE session_id = ? AND id {op} ?{role_clause} "
f"AND length(content) > 0 "
f"ORDER BY id {order} LIMIT ?",
(session_id, boundary_id, *role_params, bookend),
).fetchall()
bookend_start_rows = _bookend("<", window_rows[0]["id"], "ASC")
# End rows come back DESC for the LIMIT cap; flip to ASC.
bookend_end_rows = list(reversed(_bookend(">", window_rows[-1]["id"], "DESC")))
def _hydrate(row) -> Dict[str, Any]:
return self._row_to_message_dict(row, warn_context="get_anchored_view", summary_flag=False)
return {
"window": filtered_window, "messages_before": primitive["messages_before"],
"messages_after": primitive["messages_after"],
"bookend_start": [_hydrate(r) for r in bookend_start_rows],
"bookend_end": [_hydrate(r) for r in bookend_end_rows],
}
def list_recent_user_messages(
self, session_id: str, limit: int = 20, include_inactive: bool = False) -> List[Dict[str, Any]]:
"""The *limit* most-recent real user turns, newest first, as ``{id, timestamp, preview}``
(80 chars, whitespace collapsed); used by /rewind and ``/undo [N]``. Bookkeeping rows
(``display_kind`` set) are excluded. Legacy compaction handoffs are role='user' rows
with NO display_kind — invisible to SQL — so fetch with headroom and drop them in the
decode loop; otherwise ``/undo N`` pairs an in-memory count that excludes handoffs
with a DB pick that includes them."""
active_clause = "" if include_inactive else " AND active = 1"
# A /steer row is typed for the renderer but is human input: keep it so the DB pick agrees
# with the in-memory user_originated_turn_view count.
display_clause = " AND (display_kind IS NULL OR display_kind = '' OR display_kind = 'steer')"
with self._read_ctx() as conn:
rows = conn.execute(
"SELECT id, timestamp, content FROM messages WHERE session_id = ? AND role = 'user'"
f"{active_clause}{display_clause} "
"ORDER BY id DESC LIMIT ?",
(session_id, int(limit) * 2 + 5),
).fetchall()
from agent.context_compressor import ContextCompressor
result: List[Dict[str, Any]] = []
for row in rows:
if len(result) >= int(limit):
break
decoded = self._decode_content(row["content"])
if ContextCompressor._is_context_summary_content(decoded):
continue # compaction handoff — never a user-originated turn
if isinstance(decoded, str): # a /skill turn embeds the whole skill body; show what was typed
preview = describe_skill_invocation(decoded) or decoded
else:
preview = _flatten_text(decoded)
preview = " ".join(preview.split())
if len(preview) > 80:
preview = preview[:77] + "..."
result.append({"id": row["id"], "timestamp": row["timestamp"], "preview": preview})
return result
# ── Query analysis ─────────────────────────────────────────────────────
@staticmethod
def _sanitize_fts5_query(query: str) -> str:
"""Sanitize user input for FTS5 MATCH (raw special characters raise): preserve paired
quoted phrases, strip unmatched special characters, and quote hyphenated/dotted
terms so FTS5 matches them as phrases (``chat-send``, ``P2.2``, ``my-app.config.ts``)."""
# Cap before any regex processing so adversarial input stays bounded.
query = query[:MAX_FTS5_QUERY_CHARS]
# 1. Protect balanced quoted phrases via numbered placeholders (``"[^"]*"`` pairs
# left-to-right without backtracking); a leftover unmatched quote becomes whitespace.
_quoted_parts: list = []
def _hold(m: "re.Match[str]") -> str:
_quoted_parts.append(m.group(0))
return f"\x00Q{len(_quoted_parts) - 1}\x00"
sanitized = _QUOTED_PHRASE_RE.sub(_hold, query).replace('"', " ")
# 2. Strip FTS5-special characters (an unquoted ``TODO: fix`` parses as
# ``column:term``). ``%`` is only spared for the CJK LIKE fallback.
sanitized = _FTS5_SPECIAL_RE.sub(" ", sanitized)
if "%" in sanitized and not SessionSearchMixin._contains_cjk(sanitized):
sanitized = sanitized.replace("%", " ")
# 3. Collapse repeated * and drop leading * (prefix needs a char).
sanitized = re.sub(r"\*+", "*", sanitized)
sanitized = re.sub(r"(^|\s)\*", r"\1", sanitized)
# 4. Drop dangling boolean operators at start/end (syntax errors).
sanitized = re.sub(r"(?i)^(AND|OR|NOT)\b\s*", "", sanitized.strip())
sanitized = re.sub(r"(?i)\s+(AND|OR|NOT)\s*$", "", sanitized.strip())
# 5. Quote dotted/hyphenated/underscored terms in ONE pass (sequential passes
# double-quote ``my-app.config``).
sanitized = re.sub(r"\b(\w+(?:[._-]\w+)+)\b", r'"\1"', sanitized)
# 6. Restore preserved quoted phrases.
for i, quoted in enumerate(_quoted_parts):
sanitized = sanitized.replace(f"\x00Q{i}\x00", quoted)
return sanitized.strip()
@staticmethod
def _contains_cjk(text: str) -> bool:
return any(_is_cjk(ord(ch)) for ch in text)
@staticmethod
def _count_cjk(text: str) -> int:
return sum(1 for ch in text if _is_cjk(ord(ch)))
@staticmethod
def _has_lone_cjk_run(query: str) -> bool:
"""True when any maximal CJK run is a single char: the cjk-bigram index stores unigrams
only for isolated chars, so such a term can't match inside longer runs — keep LIKE."""
run = 0
for ch in query:
if _is_cjk(ord(ch)):
run += 1
else:
if run == 1:
return True
run = 0
return run == 1
@staticmethod
def _trigram_eligible_tokens(query: str) -> bool:
"""True when every non-operator token is >=3 chars: a shorter token produces no
trigrams, and with FTS5's implicit AND one such token empties the whole MATCH."""
tokens = _non_operator_tokens(query.strip('"').strip())
return bool(tokens) and all(len(t) >= 3 for t in tokens)
@classmethod
def _has_short_cjk_token(cls, raw_query: str) -> bool:
"""True when any non-operator CJK token has fewer than 3 CJK chars — the trigram
tokenizer needs >=3 per token, so such a query must take the LIKE route."""
return any(cls._count_cjk(t) < 3 for t in _non_operator_tokens(raw_query) if cls._contains_cjk(t))
def _trigram_route_ok(self, raw_query: str) -> bool:
"""Per-token CJK length gate for the trigram index: ``广西 OR 桂林 OR 漓江`` has 6
CJK chars total but 2 per token, so trigram returns 0."""
return (self._count_cjk(raw_query) >= 3 and not self._has_short_cjk_token(raw_query)
and self._trigram_available)
def _describe_search_path(self, query: str) -> str:
"""Best-effort name of the routing path a query takes (log-only)."""
try:
if self._fts_stale:
return "like_scan_fts_stale"
sanitized = self._sanitize_fts5_query(query or "")
if not sanitized:
return "empty"
if not self._contains_cjk(sanitized):
return "fts5"
raw = sanitized.strip('"').strip()
if self._fts_cjk_available and not self._has_lone_cjk_run(raw):
return "fts_cjk"
return "trigram" if self._trigram_route_ok(raw) else "like_scan"
except Exception:
return "unknown"
# ── Query builders / runners ───────────────────────────────────────────
@staticmethod
def _fts_match_sql(table: str, match_query: str, order_by_sql: str, *, limit: int, offset: int,
**filters) -> Tuple[str, list]:
"""MATCH query + params against one FTS5 index joined to messages/sessions."""
where = [f"{table} MATCH ?"]
params: list = [match_query]
_search_filter_clauses(where, params, **filters)
params.extend([limit, offset])
sql = _search_select_sql(
f"snippet({table}, -1, '>>>', '<<<', '...', 40) AS snippet",
f"{table}\n JOIN messages m ON m.id = {table}.rowid", where, order_by_sql, "LIMIT ? OFFSET ?",
)
return sql, params
def _match_rows(self, table: str, match_query: str, order_by_sql: str, *, fail_open: Optional[str] = None,
operational_debug: Optional[str] = None, **kwargs) -> Optional[List[Dict[str, Any]]]:
"""Run one MATCH against *table*; ``None`` when the query cannot execute (tokenizer /
syntax) so the caller falls back. *fail_open* names the index for the
substring-capable routes: a corruption-class ``DatabaseError`` there detaches the
derived indexes (``_enter_fts_fail_open``) and answers from canonical rows — a live
search never runs the unbounded rebuild. Other ``DatabaseError``s propagate."""
sql, params = self._fts_match_sql(table, match_query, order_by_sql, **kwargs)
try:
return [dict(row) for row in self._read_all(sql, params)]
except sqlite3.OperationalError:
if operational_debug:
logger.debug(operational_debug, exc_info=True)
return None
except sqlite3.DatabaseError as exc:
if fail_open is None or not self._enter_fts_fail_open(exc):
raise
logger.warning(
"%s FTS search hit a corruption error (%s); detached FTS and falling back to canonical LIKE.",
fail_open, exc)
return None
def _like_rows(self, where: List[str], params: list, *, order_by: str, limit_sql: str) -> List[Dict[str, Any]]:
"""Canonical-table LIKE scan; ``params[0]`` is the snippet anchor term."""
sql = _search_select_sql(_LIKE_SNIPPET_SQL, "messages m", where, order_by, limit_sql)
return [dict(row) for row in self._read_all(sql, params)]
@staticmethod
def _compile_like_boolean_query(query: str) -> Tuple[str, List[Any], Optional[str]]:
"""Compile the supported FTS boolean subset into LIKE predicates: terms within an OR
group are ANDed (FTS5's implicit conjunction) and ``NOT`` negates the next term."""
groups: List[List[Tuple[str, bool]]] = [[]]
negate_next = False
for raw_token in _LIKE_TOKEN_RE.findall(query):
operator = raw_token.upper()
if operator == "OR":
if groups[-1]:
groups.append([])
negate_next = False
continue
if operator in {"AND", "NEAR"}:
continue
if operator == "NOT":
negate_next = True
continue
term = raw_token.strip('"').strip("*").strip()
if term:
groups[-1].append((term, negate_next))
negate_next = False
compiled_groups: List[str] = []
params: List[Any] = []
snippet_term: Optional[str] = None
for group in groups:
if not group or not any(not negated for _, negated in group):
continue
clauses: List[str] = []
for term, negated in group:
clauses.append(f"NOT {_LIKE_COALESCED_COLUMN_SQL}" if negated else _LIKE_COALESCED_COLUMN_SQL)
params.extend(_like_params(term))
if snippet_term is None and not negated:
snippet_term = term
compiled_groups.append(f"({' AND '.join(clauses)})")
return " OR ".join(compiled_groups), params, snippet_term
def _search_messages_like_fallback(
self, query: str, *, limit: int, offset: int, sort: Optional[str], **filters) -> List[Dict[str, Any]]:
"""Search canonical messages while derived FTS state is stale."""
predicate, params, snippet_term = self._compile_like_boolean_query(query)
if not predicate or snippet_term is None:
return []
where = [f"({predicate})"]
_search_filter_clauses(where, params, **filters)
order = "ASC" if isinstance(sort, str) and sort.strip().lower() == "oldest" else "DESC"
return self._like_rows(where, [snippet_term, *params, limit, offset],
order_by=f"ORDER BY m.timestamp {order}, m.id {order}", limit_sql="LIMIT ? OFFSET ?")
def _refresh_fts_stale_state(self) -> None:
"""Observe fail-open initiated by another process sharing state.db."""
if self._fts_stale or not self._fts_enabled:
return
try:
stale = self._read_one("SELECT 1 FROM state_meta WHERE key = ? LIMIT 1", (FTS_STALE_KEY,))
except sqlite3.Error:
return
if stale is not None:
self._fts_stale = True
self._fts_enabled = self._trigram_available = self._fts_cjk_available = False
def _finalize_search_matches(
self, matches: List[Dict[str, Any]], result_fields: Optional[Collection[str]] = None) -> List[Dict[str, Any]]:
"""Attach neighboring messages in bounded batches, only when context is requested."""
if result_fields is None or "context" in result_fields:
for start in range(0, len(matches), 500):
batch = matches[start:start + 500]
contexts = {match["id"]: [] for match in batch}
try:
sql = _CONTEXT_WINDOW_SQL.format(ids=",".join("?" for _ in contexts))
with self._read_ctx() as conn:
rows = conn.execute(sql, list(contexts)).fetchall()
for row in rows:
contexts[row["match_id"]].append(row)
except Exception:
contexts = {}
for match in batch:
try:
match["context"] = [
{"role": row["role"], "content": _flatten_text(self._decode_content(row["content"]))[:200]}
for row in contexts.get(match["id"], [])]
except Exception:
match["context"] = []
# No route selects full content; the pop guards any future one that does.
for match in matches:
match.pop("content", None)
if result_fields is not None:
matches = [{field: match[field] for field in result_fields if field in match} for match in matches]
return matches
# ── search_messages ────────────────────────────────────────────────────
def search_messages(
self, query: str, source_filter: List[str] = None, exclude_sources: List[str] = None,
role_filter: List[str] = None, limit: int = 20, offset: int = 0, sort: str = None,
include_inactive: bool = False, fields: Optional[Collection[str]] = None,
) -> List[Dict[str, Any]]:
""":meth:`_search_messages_impl` plus one log line per slow search with the routing
path taken. Threshold HERMES_SEARCH_SLOW_MS (default 1000; 0 logs every call)."""
started = time.time()
rows = None
try:
rows = self._search_messages_impl(
query, source_filter=source_filter, exclude_sources=exclude_sources, role_filter=role_filter,
limit=limit, offset=offset, sort=sort, include_inactive=include_inactive, fields=fields)
return rows
finally:
elapsed_ms = (time.time() - started) * 1000.0
if elapsed_ms >= _search_slow_ms():
logger.info("slow session search: path=%s elapsed=%.0fms rows=%s query=%r",
self._describe_search_path(query), elapsed_ms, len(rows) if rows is not None else "err",
query[: 200])
def _search_messages_impl(
self, query: str, source_filter: List[str] = None, exclude_sources: List[str] = None,
role_filter: List[str] = None, limit: int = 20, offset: int = 0, sort: str = None,
include_inactive: bool = False, fields: Optional[Collection[str]] = None,
) -> List[Dict[str, Any]]:
"""FTS5 search across session messages (keywords, ``"phrases"``, AND/OR/NOT, ``prefix*``).
Returns snippet + session metadata + 1-message context per hit; ``fields`` selects a
projection. ``sort``: None = BM25 rank; "newest"/"oldest" = timestamp then rank (the
CJK LIKE fallback ignores it). Rewound rows (``active=0, compacted=0``) are excluded
by default; compaction-archived rows ARE included; ``include_inactive`` = every row."""
result_fields = self._search_message_fields(fields)
if not query or not query.strip():
return []
query = self._sanitize_fts5_query(query)
if not query:
return []
filters = dict(include_inactive=include_inactive, source_filter=source_filter,
exclude_sources=exclude_sources, role_filter=role_filter)
# New oversized tool results index only a bounded prefix; an explicit tool-role search is the
# opt-in full-body path and scans canonical rows via LIKE.
if role_filter and "tool" in role_filter:
matches = self._search_messages_like_fallback(query, limit=limit, offset=offset, sort=sort, **filters)
return self._finalize_search_matches(matches, result_fields=result_fields)
self._refresh_fts_stale_state()
if self._fts_stale:
matches = self._search_messages_like_fallback(query, limit=limit, offset=offset, sort=sort, **filters)
return self._finalize_search_matches(matches, result_fields=result_fields)
if not self._fts_enabled:
return []
order_by_sql = _FTS_ORDER_BY.get(sort.strip().lower() if isinstance(sort, str) else None, "ORDER BY rank")
route = dict(order_by_sql=order_by_sql, limit=limit, offset=offset, **filters)
# Tool rows and FTS_TRIGRAM_EXCLUDED_SOURCES sessions are excluded from the trigram/cjk
# indexes (see FTS_TRIGRAM_SQL); an explicit filter for them must scan the base table.
wants_unindexed_rows = (bool(role_filter) and "tool" in role_filter) or (
bool(source_filter) and any(src in FTS_TRIGRAM_EXCLUDED_SOURCES for src in source_filter))
is_cjk = self._contains_cjk(query)
if is_cjk:
matches = self._search_cjk(query, wants_unindexed_rows, route)
else:
sql, params = self._fts_match_sql("messages_fts", query, **route)
try:
matches = [dict(row) for row in self._read_all(sql, params)]
except sqlite3.OperationalError:
return [] # FTS5 syntax error despite sanitization
except sqlite3.DatabaseError as exc:
# Corruption parent class: detach the derived indexes and answer from
# canonical rows; repair paths own the rebuild.
# A corrupt FTS index raises the malformed / "fts5: corrupt structure record" class on the
# MATCH read, the same class the write path handles (#66296). OperationalError (query
# syntax) is a subclass caught above; this arm is the corruption parent. The existing
# stale-open/repair paths retain rebuild ownership.
if not self._enter_fts_fail_open(exc):
raise
matches = self._search_messages_like_fallback(query, limit=limit, offset=offset, sort=sort, **filters)
# Deferred-rebuild supplement: while the backfill is pending the FTS indexes miss
# the (progress, high_water] gap; top up with a bounded LIKE scan so old messages
# never vanish mid-rebuild. Cost decays to zero as the backfill advances.
if self.fts_rebuild_status() is not None and len(matches) < limit:
try:
gap_matches = self._search_unindexed_gap(query, limit - len(matches), **filters)
seen_ids = {m["id"] for m in matches}
matches.extend(m for m in gap_matches if m["id"] not in seen_ids)
except sqlite3.OperationalError as exc:
logger.debug("Unindexed-gap supplement skipped: %s", exc)
# unicode61 puts no boundary between Latin and adjacent CJK ("修改youer服务端" is
# one token, so MATCH "youer" misses). On a zero-result Latin miss retry the
# substring-capable indexes: cjk first (exact ranked match), then trigram (>=3-char
# tokens). Gated on a miss so hits keep their ranking ("cat" may then match
# "concatenate"). Skipped for role='tool' (both indexes exclude tool rows).
if not matches and not is_cjk and not (bool(role_filter) and "tool" in role_filter):
fb_query = _quote_fts_tokens(query.strip('"').strip())
# ── CJK-bigram route (messages_fts_cjk, cjk_unicode61) ────── When the bigram index is
# available it serves EVERY CJK query shape the legacy code split between trigram (>=3
# chars/token) and LIKE full scans (1-2 char tokens) — the whole point of the index (PR #65544).
# Exceptions stay on the legacy routes: - role_filter=['tool'] queries (tool rows aren't in the
# cjk index, same exclusion as trigram), - queries containing a LONE 1-char CJK run: the index
# stores bigrams for runs >=2, so a single-char term can only match isolated chars — LIKE
# substring semantics are broader.
if self._fts_cjk_available:
matches = self._match_rows("messages_fts_cjk", fb_query, **route) or matches
if not matches and self._trigram_available and self._trigram_eligible_tokens(query):
matches = self._match_rows("messages_fts_trigram", fb_query, **route) or matches
return self._finalize_search_matches(matches, result_fields=result_fields)
def _search_cjk(self, query: str, wants_unindexed_rows: bool, route: Dict[str, Any]) -> List[Dict[str, Any]]:
"""CJK routing: the unicode61 table splits CJK into single characters (false positives,
missed phrases). cjk-bigram serves every shape except queries wanting rows the
substring indexes exclude (role='tool', cron/subagent sources) and LONE
1-char CJK runs (bigrams only exist for runs >=2 — LIKE is broader); then trigram
(>=3 CJK chars per token); then a LIKE substring scan with one clause per
non-operator token so "广西 OR 桂林 OR 漓江" matches each term."""
raw_query = query.strip('"').strip()
match_query = _quote_fts_tokens(raw_query)
if self._fts_cjk_available and not wants_unindexed_rows and not self._has_lone_cjk_run(raw_query):
matches = self._match_rows(
"messages_fts_cjk", match_query, fail_open="CJK-bigram",
operational_debug="messages_fts_cjk query failed; falling back to trigram/LIKE", **route)
if matches is not None:
return matches
if self._trigram_route_ok(raw_query) and not wants_unindexed_rows:
matches = self._match_rows("messages_fts_trigram", match_query, fail_open="Trigram", **route)
if matches is not None:
return matches
non_op_tokens = _non_operator_tokens(raw_query) or [raw_query]
like_params: list = [p for tok in non_op_tokens for p in _like_params(tok)]
like_where = [f"({' OR '.join([_LIKE_ANY_COLUMN_SQL] * len(non_op_tokens))})"]
filters = {k: route[k] for k in ("include_inactive", "source_filter", "exclude_sources", "role_filter")}
_search_filter_clauses(like_where, like_params, **filters)
# instr() for the snippet uses the first search token.
return self._like_rows(like_where, [non_op_tokens[0], *like_params, route["limit"], route["offset"]],
order_by="ORDER BY m.timestamp DESC", limit_sql="LIMIT ? OFFSET ?")
def _search_unindexed_gap(self, fts_query: str, limit: int, **filters) -> List[Dict[str, Any]]:
"""LIKE-scan ids in (fts_rebuild_progress, fts_rebuild_high_water] — rows the deferred
rebuild hasn't indexed yet. The FTS query degrades to AND-joined substring terms
(quoted phrases kept whole): recall-over-precision mid-rebuild."""
status = self.fts_rebuild_status()
if status is None or limit <= 0:
return []
terms = [tok for tok in (t.strip('"').strip("*").strip() for t in _LIKE_TOKEN_RE.findall(fts_query))
if tok and tok.upper() not in _LIKE_SKIP_TOKENS]
if not terms:
return []
where = ["m.id > ? AND m.id <= ?", *([_LIKE_ANY_COLUMN_SQL] * len(terms))]
params: list = [status["indexed"], status["total"], *(p for term in terms for p in _like_params(term))]
_search_filter_clauses(where, params, **filters)
return self._like_rows(where, [terms[0], *params, limit], order_by="ORDER BY m.timestamp DESC",
limit_sql="LIMIT ?")
def search_sessions_by_id(
self, query: str, limit: int = 20, include_archived: bool = True, source: str = None,
sources: List[str] = None, exclude_sources: List[str] = None) -> List[Dict[str, Any]]:
"""Search surfaced sessions by exact/prefix/substring session id. Also matches
``_lineage_root_id`` so an old compression root id resolves to the live continuation."""
needle = (query or "").strip().lower()
if not needle or limit <= 0:
return []
# list_sessions_rich pushes the id LIKE filter (own id + forward compression
# chain) into SQL; over-fetch so the in-Python ranking has candidates.
candidates = self.list_sessions_rich(
source=source, sources=sources, exclude_sources=exclude_sources, limit=max(limit * 4, limit),
offset=0, include_archived=include_archived, order_by_last_active=True, id_query=needle)
def score(row: Dict[str, Any]) -> int:
normalized = [v.lower() for v in (str(row.get("id") or ""), str(row.get("_lineage_root_id") or "")) if v]
if any(value == needle for value in normalized):
return 0
return 1 if any(value.startswith(needle) for value in normalized) else 2
ranked = sorted(enumerate(candidates), key=lambda item: (score(item[1]), item[0]))
return [row for _, row in ranked[:limit]]
# ── FTS maintenance commands ───────────────────────────────────────────
def _fts_table_exists(self, name: str) -> bool:
"""True if an FTS5 virtual table is queryable ("no such table" and "vtable
constructor failed" — missing tokenizer / mid-teardown — both count as not)."""
try:
self._conn.execute(f"SELECT 1 FROM {name} LIMIT 0")
return True
except sqlite3.DatabaseError:
return False
def _present_fts_tables(self) -> List[str]:
"""Queryable FTS tables (caller holds ``self._lock``)."""
return [tbl for tbl in self._FTS_TABLES if self._fts_table_exists(tbl)]
def optimize_fts(self) -> int:
"""Merge fragmented FTS5 segments into one per index (``'optimize'``). Pure
maintenance: changes neither results nor ``snippet()`` output, only layout and
speed; VACUUM then returns the freed pages. Returns the number optimized. A quarantined
handle never issues ``'optimize'``: it rewrites index segments in place and would compound
structural damage (or a split WAL generation) instead of leaving it diagnosable."""
self._raise_if_db_corrupt()
self._raise_if_db_replaced()
optimized = 0
with self._lock:
for tbl in self._present_fts_tables():
try:
self._conn.execute(f"INSERT INTO {tbl}({tbl}) VALUES('optimize')")
optimized += 1
except sqlite3.OperationalError as exc:
logger.warning("FTS optimize failed for %s: %s", tbl, exc)
return optimized
def rebuild_fts(self) -> int:
"""Rebuild FTS5 indexes from ``messages`` (``'rebuild'``) — the recovery for a corrupt index
that rejects writes while reads succeed. Two processes rebuilding one state.db
concurrently corrupted production DBs, so this admits through
``fts_rebuild_admission`` and FAILS CLOSED, returning 0 on deferral (callers treat 0
as "no progress" and use the stale-FTS breadcrumb path). Returns indexes rebuilt.
Uses the FTS5 ``'rebuild'`` command, which rewrites the internal b-tree segments from the content
rows. Unlike ``optimize_fts`` (which merges existing segments), ``rebuild`` discards and recreates
the index data entirely — the more destructive of the two, so it is quarantined the same way. See
#50502.
A full structural rebuild must never run concurrently in two processes sharing one state.db — that
interleaving has structurally corrupted the database in production (PR #93200) — so this admits
through the cross-process ``fts_rebuild_admission`` authority and FAILS CLOSED: if another process
holds the rebuild lock beyond the bounded wait, this call defers (returns 0) rather than racing it.
Callers already treat 0 as "rebuild made no progress" and fall back to the stale-FTS breadcrumb
path, which retries in-process from the gateway housekeeping tick (``retry_deferred_fts_recovery``)
and at next startup.
"""
self._raise_if_db_corrupt()
self._raise_if_db_replaced()
rebuilt = 0
with fts_rebuild_admission(self.db_path) as admitted:
if not admitted:
logger.warning(
"Deferred in-place FTS rebuild: another process holds the rebuild authority for this state.db.")
return 0
with self._lock:
high_water = self._conn.execute("SELECT COALESCE(MAX(id), 0) FROM messages").fetchone()[0]
self._conn.execute(
"INSERT INTO state_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
(FTS_TOOL_FULL_CONTENT_HIGH_WATER_KEY, str(high_water)),
)
for tbl in self._present_fts_tables():
try:
self._conn.execute(f"INSERT INTO {tbl}({tbl}) VALUES('rebuild')")
self._conn.commit()
rebuilt += 1
except sqlite3.OperationalError as exc:
self._conn.rollback()
logger.warning("FTS rebuild failed for %s: %s", tbl, exc)
return rebuilt
def _merge_fts_incrementally(self, *, max_pages: int, max_commands: Optional[int] = None) -> int:
"""Run bounded FTS5 ``'merge'`` commands against each present index. A positive merge rank
stops after ~that many output pages, so each command holds the write lock for
milliseconds regardless of index size (``'optimize'`` takes 9-18 s per index on a
10 GB DB). ``usermerge`` is lowered to its minimum of 2 (persisted in ``%_config``,
once per instance) so a merge acts on ANY level with >= 2 segments; at the default
4 a fragmented index cannot converge. Up to *max_commands* per index, stopping on
the no-progress signal ``total_changes`` delta < 2 (the INSERT itself is 1). Each
command is its own implicit transaction, so processes interleave mid-pass. Missing
tables are skipped (optimize_fts_storage drops + backfills them live); other SQLite
errors propagate. Returns commands executed."""
_positive_int("max_pages", max_pages)
if max_commands is None:
max_commands = self._FTS_MERGE_COMMANDS_PER_PASS
_positive_int("max_commands", max_commands)
executed = 0
with self._lock:
for tbl in self._present_fts_tables():
if not self._fts_usermerge_floor_applied:
self._conn.execute(f"INSERT INTO {tbl}({tbl}, rank) VALUES('usermerge', 2)")
for _ in range(max_commands):
before = self._conn.total_changes
self._conn.execute(f"INSERT INTO {tbl}({tbl}, rank) VALUES('merge', ?)", (max_pages,))
executed += 1
if self._conn.total_changes - before < 2:
break
self._fts_usermerge_floor_applied = True
return executed
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
import json # noqa: F401,E402
import os # noqa: F401,E402
# ---- END PLUGIN-COMPAT ----