feat(state): messages_fts_cjk — CJK-bigram index on the v23 external-content layout

Integration layer for the cjk_unicode61 tokenizer, rebuilt on the v23
schema (the contributed integration in PR #65544 predated it):

- messages_fts_cjk: external-content FTS5 over a tool-row-excluding view
  (same v23 storage discipline as the trigram index it supersedes — zero
  inline text copies). Serves EVERY CJK query shape the legacy routing
  split between trigram (>=3 chars/token) and LIKE full scans (1-2 char
  tokens). Lone 1-char CJK runs and role_filter=['tool'] queries keep
  their legacy routes.
- Dedicated marker pair (fts_cjk_rebuild_high_water/progress) gates the
  id-scoped triggers, so a cjk-only backfill never gates the complete
  messages_fts/trigram triggers.
- Transitions ride  (the existing
  throttled/resumable chunk engine): fresh DBs are born with the index;
  legacy v22 DBs land on v23+cjk in one run; already-optimized v23 DBs
  gaining the tokenizer get a marker-gated backfill; live writes are
  indexed immediately in every case.
- Tokenizer-loss self-heal: a process that can't load the extension drops
  the cjk triggers (writes keep working), leaves a stale breadcrumb, and
  the index is rebuilt from scratch on the next optimize run — triggers
  are never reinstalled over a gap (external-content 'delete' on an
  unindexed rowid is the FTS5 corruption hazard the marker gating exists
  to prevent).
- Capability classification: 'no such tokenizer: cjk_unicode61' joins the
  degraded-runtime error class everywhere (read probe, write probe,
  repair) so tokenizer absence is never misclassified as corruption.
- Config: sessions.cjk_fts (default on, inert without the .so) and
  sessions.search_slow_ms in config.yaml, bridged to env by CLI + gateway
  (startup + per-turn reload). build.sh falls back to vendored SQLite
  headers so no libsqlite3-dev is needed.

Slow-query log path attribution updated: fts_cjk / fts5 / trigram /
like_scan. Tests: 14 lifecycle tests (fresh/legacy/stale/backfill paths,
tokenizer-loss round-trip) + 5 config-bridge tests + slow-log suite.
This commit is contained in:
Teknium
2026-07-22 07:42:01 -07:00
parent 8364576e33
commit f13f845116
13 changed files with 15581 additions and 22 deletions
+1
View File
@@ -169,3 +169,4 @@ apps/desktop/demo/
# PR body is the archive. See the hermes-agent-dev skill's
# pr-infographic-workflow reference (storage rule + lapse #8 / #COMMIT-1).
infographic/
native/fts5_cjk/*.so
+10
View File
@@ -772,6 +772,16 @@ def load_cli_config() -> Dict[str, Any]:
if redact is not None:
os.environ["HERMES_REDACT_SECRETS"] = str(redact).lower()
# Session-search index knobs (hermes_state reads the env carriers).
sessions_config = defaults.get("sessions", {})
if isinstance(sessions_config, dict):
if "cjk_fts" in sessions_config:
os.environ["HERMES_CJK_FTS"] = str(sessions_config["cjk_fts"])
if "search_slow_ms" in sessions_config:
os.environ["HERMES_SEARCH_SLOW_MS"] = str(
sessions_config["search_slow_ms"]
)
return defaults
# Load configuration at module startup
+18
View File
@@ -1481,6 +1481,14 @@ def _bridge_max_turns_from_config(home: "Path") -> None:
agent_cfg = cfg.get("agent", {})
if isinstance(agent_cfg, dict) and "max_turns" in agent_cfg:
os.environ["HERMES_MAX_ITERATIONS"] = str(agent_cfg["max_turns"])
# config-authoritative knobs for the session-search index (config.yaml
# sessions.* wins over stale env; env stays the cross-process carrier).
sessions_cfg = cfg.get("sessions", {})
if isinstance(sessions_cfg, dict):
if "cjk_fts" in sessions_cfg:
os.environ["HERMES_CJK_FTS"] = str(sessions_cfg["cjk_fts"])
if "search_slow_ms" in sessions_cfg:
os.environ["HERMES_SEARCH_SLOW_MS"] = str(sessions_cfg["search_slow_ms"])
def _current_max_iterations() -> int:
@@ -1763,6 +1771,16 @@ if _config_path.exists():
os.environ["HERMES_AUTO_CONTINUE_FRESHNESS"] = str(
_agent_cfg["gateway_auto_continue_freshness"]
)
# config-authoritative knobs for the session-search index; same
# bridge semantics as the agent settings above.
_sessions_cfg = _cfg.get("sessions", {})
if _sessions_cfg and isinstance(_sessions_cfg, dict):
if "cjk_fts" in _sessions_cfg:
os.environ["HERMES_CJK_FTS"] = str(_sessions_cfg["cjk_fts"])
if "search_slow_ms" in _sessions_cfg:
os.environ["HERMES_SEARCH_SLOW_MS"] = str(
_sessions_cfg["search_slow_ms"]
)
_display_cfg = _cfg.get("display", {})
if _display_cfg and isinstance(_display_cfg, dict):
if "busy_input_mode" in _display_cfg:
+14
View File
@@ -3249,6 +3249,20 @@ DEFAULT_CONFIG = {
# enforcement is a copy/gating change, not new migration code.
# "off": suppress the notice entirely.
"fts_optimize_notice": "advise",
# CJK-bigram search index (messages_fts_cjk, cjk_unicode61 loadable
# tokenizer). When the extension is built (native/fts5_cjk/build.sh →
# ~/.hermes/lib/libfts5_cjk.so), 1-2 char CJK terms (일본, 项目, ...)
# get index-speed exact matching instead of LIKE full-table scans.
# True (default): use the index when the extension is present; the
# setting is inert when it isn't. False: never load the extension or
# serve the cjk index. Bridged to HERMES_CJK_FTS (internal carrier).
"cjk_fts": True,
# Slow session-search log threshold in milliseconds: searches at or
# above it log one INFO line with the routing path taken (fts_cjk /
# fts5 / trigram / like_scan) so latency regressions stay
# attributable per query shape. 0 logs every search. Bridged to
# HERMES_SEARCH_SLOW_MS (internal carrier).
"search_slow_ms": 1000,
},
# Contextual first-touch onboarding hints (see agent/onboarding.py).
+4
View File
@@ -6406,6 +6406,10 @@ def _print_fts_optimize_available_notice() -> None:
"SELECT 1 FROM sqlite_master WHERE type = 'table' "
"AND name LIKE 'fts\\_v22\\_trash\\_%' ESCAPE '\\' LIMIT 1"
).fetchone()
or db._conn.execute(
"SELECT 1 FROM state_meta WHERE key IN "
"('fts_cjk_rebuild_high_water', 'fts_cjk_stale') LIMIT 1"
).fetchone()
)
except Exception:
return
+597 -13
View File
@@ -17,6 +17,7 @@ Key design decisions:
import asyncio
import json
import logging
import os
import random
import re
import sqlite3
@@ -582,6 +583,13 @@ def _db_opens_cleanly(db_path: Path) -> Optional[str]:
"""
conn = sqlite3.connect(str(db_path), isolation_level=None)
try:
# Best-effort tokenizer load: a DB carrying the messages_fts_cjk
# index needs the cjk_unicode61 extension before any statement can
# touch that table — including the trigger-driven write probe below.
# Without it, this probe sees the DB exactly as a tokenizer-less
# SessionDB open would (which drops the cjk triggers to keep writes
# working), so tokenizer absence must never classify as corruption.
load_fts5_cjk_extension(conn)
conn.execute("PRAGMA journal_mode").fetchone()
rows = conn.execute("PRAGMA integrity_check").fetchall()
problems = [str(r[0]) for r in rows if r and str(r[0]).lower() != "ok"]
@@ -603,7 +611,7 @@ def _db_opens_cleanly(db_path: Path) -> Optional[str]:
# Catch the full sqlite3 exception hierarchy (not just
# OperationalError) so the malformed-shadow-table class is reported
# rather than letting it crash the caller.
for fts_table in ("messages_fts", "messages_fts_trigram"):
for fts_table in ("messages_fts", "messages_fts_trigram", "messages_fts_cjk"):
try:
# No-op queries against the actual FTS5 APIs the search
# tools use. The trigram table is included because it backs
@@ -674,6 +682,12 @@ def _db_opens_cleanly(db_path: Path) -> Optional[str]:
msg = str(exc).lower()
if "no such table" in msg or "no such column" in msg:
return None
if "no such tokenizer: cjk_unicode61" in msg:
# This probe process couldn't load the cjk extension while
# the DB carries the cjk index — capability gap, not
# corruption. A tokenizer-capable SessionDB serves it fine;
# a tokenizer-less one self-heals by dropping the triggers.
return None
return str(exc)
return None
except sqlite3.DatabaseError as exc:
@@ -737,13 +751,19 @@ def repair_state_db_schema(db_path: Path, *, backup: bool = True) -> Dict[str, A
try:
conn = sqlite3.connect(str(db_path), isolation_level=None)
try:
for table_name in ("messages_fts", "messages_fts_trigram"):
# The cjk index can only be rebuilt with its tokenizer loaded;
# best-effort (a tokenizer-less host skips it at the probe below).
load_fts5_cjk_extension(conn)
for table_name in (
"messages_fts", "messages_fts_trigram", "messages_fts_cjk"
):
try:
conn.execute(
f"INSERT INTO {table_name}({table_name}) VALUES('rebuild')"
)
except sqlite3.OperationalError:
# Table absent (FTS disabled / trigram off) — skip it.
# Table absent (FTS disabled / trigram off / cjk not
# present or tokenizer unavailable) — skip it.
continue
finally:
conn.close()
@@ -1149,6 +1169,151 @@ BEGIN
END;
"""
# ── CJK-bigram FTS index (replaces the trigram index when available) ────
#
# The trigram tokenizer needs >=3 chars per query term, so 1-2 char CJK
# terms (ubiquitous in Korean/Chinese: 일본, 구글, 项目, ...) fall through
# to a LIKE full-table scan — measured 3-6s CPU per query on multi-GB
# installs and the dominant base cost of session_search on CJK workloads.
#
# ``cjk_unicode61`` (native/fts5_cjk/, a ~250-line loadable FTS5 tokenizer
# with no dependencies) wraps unicode61: maximal CJK runs are re-emitted as
# overlapping character bigrams (Lucene CJKAnalyzer semantics), everything
# else passes through unchanged. FTS5 phrase semantics turn a query term's
# consecutive bigrams into exact substring matching down to 2 chars at
# index speed. Contributed by Soju06 (PR #65544).
#
# Same v23 storage discipline as the trigram table it replaces:
# external-content over a tool-row-excluding view (zero inline text
# copies; tool rows stay searchable via ``messages_fts``), triggers gated
# on a DEDICATED marker pair (``fts_cjk_rebuild_high_water`` /
# ``fts_cjk_rebuild_progress``) so a cjk-only backfill — e.g. the
# trigram→cjk upgrade on an already-optimized DB — never gates the
# complete ``messages_fts`` index's triggers.
#
# The table exists ONLY when the loadable tokenizer is available
# (``~/.hermes/lib/libfts5_cjk.so``, built by ``native/fts5_cjk/build.sh``).
# A process that cannot load it self-heals by dropping the cjk triggers
# (message writes keep working; the index goes stale and is rebuilt by the
# next ``hermes sessions optimize-storage`` on a capable host).
#
# Split DDL: the table/view part is safe to ensure any time; the triggers
# are created ONLY while the index is complete-or-marker-gated. A stale
# index (trigger gap of unknown extent) must keep its triggers DROPPED —
# an external-content 'delete' op for a rowid the index never held is the
# canonical FTS5 index-corruption hazard the v23 marker gating exists to
# prevent.
FTS_CJK_TABLE_SQL = """
CREATE VIEW IF NOT EXISTS messages_fts_cjk_src AS
SELECT id, role, content, tool_name, tool_calls
FROM messages
WHERE role <> 'tool';
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts_cjk USING fts5(
content,
tool_name,
tool_calls,
content='messages_fts_cjk_src',
content_rowid='id',
tokenize='cjk_unicode61'
);
"""
FTS_CJK_TRIGGER_SQL = """
CREATE TRIGGER IF NOT EXISTS messages_fts_cjk_insert AFTER INSERT ON messages
WHEN new.role <> 'tool'
AND (new.id > COALESCE((SELECT CAST(value AS INTEGER) FROM state_meta
WHERE key = 'fts_cjk_rebuild_high_water'), -1)
OR new.id <= COALESCE((SELECT CAST(value AS INTEGER) FROM state_meta
WHERE key = 'fts_cjk_rebuild_progress'), -1))
BEGIN
INSERT INTO messages_fts_cjk(rowid, content, tool_name, tool_calls)
VALUES (new.id, new.content, new.tool_name, new.tool_calls);
END;
CREATE TRIGGER IF NOT EXISTS messages_fts_cjk_delete AFTER DELETE ON messages
WHEN old.role <> 'tool'
AND (old.id > COALESCE((SELECT CAST(value AS INTEGER) FROM state_meta
WHERE key = 'fts_cjk_rebuild_high_water'), -1)
OR old.id <= COALESCE((SELECT CAST(value AS INTEGER) FROM state_meta
WHERE key = 'fts_cjk_rebuild_progress'), -1))
BEGIN
INSERT INTO messages_fts_cjk(messages_fts_cjk, rowid, content, tool_name, tool_calls)
VALUES ('delete', old.id, old.content, old.tool_name, old.tool_calls);
END;
CREATE TRIGGER IF NOT EXISTS messages_fts_cjk_update AFTER UPDATE ON messages
WHEN (old.content IS NOT new.content
OR old.tool_name IS NOT new.tool_name
OR old.tool_calls IS NOT new.tool_calls
OR old.role IS NOT new.role)
AND (old.id > COALESCE((SELECT CAST(value AS INTEGER) FROM state_meta
WHERE key = 'fts_cjk_rebuild_high_water'), -1)
OR old.id <= COALESCE((SELECT CAST(value AS INTEGER) FROM state_meta
WHERE key = 'fts_cjk_rebuild_progress'), -1))
BEGIN
INSERT INTO messages_fts_cjk(messages_fts_cjk, rowid, content, tool_name, tool_calls)
SELECT 'delete', old.id, old.content, old.tool_name, old.tool_calls
WHERE old.role <> 'tool';
INSERT INTO messages_fts_cjk(rowid, content, tool_name, tool_calls)
SELECT new.id, new.content, new.tool_name, new.tool_calls
WHERE new.role <> 'tool';
END;
"""
_FTS_CJK_TRIGGERS = (
"messages_fts_cjk_insert",
"messages_fts_cjk_delete",
"messages_fts_cjk_update",
)
# state_meta breadcrumb set when a tokenizer-less process had to drop the
# cjk triggers to keep message writes alive: rows written from that moment
# on are missing from the cjk index, so it must not serve reads until
# `hermes sessions optimize-storage` rebuilds it on a capable host.
FTS_CJK_STALE_KEY = "fts_cjk_stale"
def fts5_cjk_so_path() -> Path:
"""Location of the cjk_unicode61 loadable extension."""
env = os.getenv("HERMES_FTS5_CJK_SO")
if env:
return Path(env).expanduser()
return get_hermes_home() / "lib" / "libfts5_cjk.so"
def _cjk_fts_config_enabled() -> bool:
"""config.yaml ``sessions.cjk_fts`` (default on), via its env bridge."""
return os.getenv("HERMES_CJK_FTS", "1").strip().lower() not in (
"0", "false", "off", "no",
)
def load_fts5_cjk_extension(conn: sqlite3.Connection) -> bool:
"""Best-effort load of the cjk_unicode61 tokenizer into ``conn``.
Returns False (never raises) when the .so is absent, the feature is
disabled via ``sessions.cjk_fts``, or this Python build has extension
loading compiled out — every caller treats False as "behave exactly as
before the cjk index existed".
"""
if not _cjk_fts_config_enabled():
return False
path = fts5_cjk_so_path()
if not path.exists():
return False
try:
conn.enable_load_extension(True)
try:
conn.load_extension(str(path))
finally:
conn.enable_load_extension(False)
return True
except Exception:
logger.warning("fts5_cjk extension load failed (%s)", path, exc_info=True)
return False
# ── Legacy (v22 / inline-content) FTS DDL ──────────────────────────────
# Used ONLY to keep an existing pre-v23 install's search working and its
@@ -1269,6 +1434,12 @@ class SessionDB:
self._fts_runtime_rebuild_attempted = False
self._fts_enabled = False
self._trigram_available = False
# CJK-bigram index (cjk_unicode61 loadable tokenizer). _fts_cjk_loaded:
# extension present on the writer connection; _fts_cjk_available: the
# messages_fts_cjk table is queryable AND not marked stale. Set during
# _init_schema / _probe_fts_cjk.
self._fts_cjk_loaded = False
self._fts_cjk_available = False
self._fts_unavailable_warned = False
self._conn = None
try:
@@ -1309,6 +1480,7 @@ class SessionDB:
self._conn.row_factory = sqlite3.Row
apply_wal_with_fallback(self._conn, db_label="state.db")
self._conn.execute("PRAGMA foreign_keys=ON")
self._fts_cjk_loaded = load_fts5_cjk_extension(self._conn)
self._init_schema()
try:
@@ -1372,12 +1544,25 @@ class SessionDB:
# Scope to trigram specifically to avoid masking unrelated tokenizer errors.
if "no such tokenizer: trigram" in err:
return True
# The cjk_unicode61 tokenizer is a loadable extension — a process
# that couldn't load it sees the same capability-error shape.
if "no such tokenizer: cjk_unicode61" in err:
return True
return False
@staticmethod
def _is_trigram_unavailable_error(exc: sqlite3.OperationalError) -> bool:
"""True when only the trigram tokenizer is missing (FTS5 itself works)."""
return "no such tokenizer: trigram" in str(exc).lower()
"""True when only an optional tokenizer is missing (FTS5 itself works).
Covers the built-in trigram tokenizer (needs SQLite >= 3.34) and the
loadable cjk_unicode61 tokenizer — both mean "this one index can't be
served here", never "disable FTS".
"""
err = str(exc).lower()
return (
"no such tokenizer: trigram" in err
or "no such tokenizer: cjk_unicode61" in err
)
@staticmethod
def _db_has_legacy_inline_fts(cursor: sqlite3.Cursor) -> bool:
@@ -1444,6 +1629,124 @@ class SessionDB:
self._warn_fts5_unavailable(exc)
return False
def _ensure_fts_cjk_schema(self, cursor) -> None:
"""Create / repair / self-heal the CJK-bigram index surface.
``cursor`` may be a Cursor or a Connection (both expose execute /
executescript). Called only for v23-shape DBs with the base FTS
surface healthy. Sets ``self._fts_cjk_available``. Never raises;
every failure mode degrades to "no cjk index" (trigram/LIKE routing
keeps working).
Cases:
tokenizer loaded, table absent → create. Empty DB: index is
complete by construction (triggers cover everything). Populated
DB: set the cjk backfill markers so the id-gated triggers stay
correct and `optimize-storage` can backfill; the index is NOT
served until the backfill completes.
tokenizer loaded, table present → ensure triggers (recreates any
dropped by a tokenizer-less process), honour the stale
breadcrumb (serve only when absent and no backfill pending).
tokenizer NOT loaded, table present with live triggers → drop the
cjk triggers so message INSERTs don't fail at trigger time,
and leave the stale breadcrumb (#self-heal). The table itself
stays for a later capable open to rebuild.
"""
cjk_present = bool(cursor.execute(
"SELECT 1 FROM sqlite_master WHERE type = 'table' "
"AND name = 'messages_fts_cjk'"
).fetchone())
if not self._fts_cjk_loaded:
if cjk_present:
live = [
r[0] for r in cursor.execute(
"SELECT name FROM sqlite_master WHERE type = 'trigger' "
f"AND name IN ({','.join('?' for _ in _FTS_CJK_TRIGGERS)})",
_FTS_CJK_TRIGGERS,
).fetchall()
]
if live:
# Self-heal: this process cannot tokenize, so every
# message INSERT would die inside the cjk trigger.
# Breadcrumb FIRST (crash between the two statements is
# merely conservative), then drop.
logger.warning(
"messages_fts_cjk triggers present but the "
"cjk_unicode61 tokenizer is unavailable (%s) — "
"dropping the cjk triggers so message writes keep "
"working. CJK search falls back to trigram/LIKE; "
"run `hermes sessions optimize-storage` on a host "
"with the extension to rebuild.",
fts5_cjk_so_path(),
)
cursor.execute(
"INSERT INTO state_meta (key, value) VALUES (?, '1') "
"ON CONFLICT(key) DO UPDATE SET value = '1'",
(FTS_CJK_STALE_KEY,),
)
for trig in live:
cursor.execute(f"DROP TRIGGER IF EXISTS {trig}")
self._fts_cjk_available = False
return
try:
cursor.executescript(FTS_CJK_TABLE_SQL)
if not cjk_present:
# Freshly created. An empty DB's index is complete by
# construction (triggers will cover every future row); a
# populated DB (e.g. a v23 install predating the cjk index)
# gets the dedicated marker pair so the id-gated triggers
# keep NEW rows indexed while old rows await the
# `optimize-storage` backfill. Either way any old stale
# breadcrumb refers to a table that no longer exists.
cursor.execute(
"DELETE FROM state_meta WHERE key = ?",
(FTS_CJK_STALE_KEY,),
)
n_msgs = cursor.execute(
"SELECT COUNT(*) FROM messages WHERE role <> 'tool'"
).fetchone()[0]
if n_msgs > 0:
hw = cursor.execute(
"SELECT COALESCE(MAX(id), 0) FROM messages"
).fetchone()[0]
for k, v in (
("fts_cjk_rebuild_high_water", str(hw)),
("fts_cjk_rebuild_progress", "0"),
):
cursor.execute(
"INSERT INTO state_meta (key, value) VALUES (?, ?) "
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
(k, v),
)
stale = cursor.execute(
"SELECT 1 FROM state_meta WHERE key = ?",
(FTS_CJK_STALE_KEY,),
).fetchone()
if stale:
# A tokenizer-less process dropped the triggers at some
# unknown point — the index has a gap of unknown extent.
# Do NOT reinstall triggers (an external-content 'delete'
# for an unindexed rowid corrupts the index); the next
# `optimize-storage` run rebuilds from scratch.
self._fts_cjk_available = False
return
cursor.executescript(FTS_CJK_TRIGGER_SQL)
backfill_pending = cursor.execute(
"SELECT 1 FROM state_meta "
"WHERE key = 'fts_cjk_rebuild_high_water' LIMIT 1"
).fetchone()
self._fts_cjk_available = not backfill_pending
except sqlite3.OperationalError:
# Includes "no such tokenizer: cjk_unicode61" if the extension
# loaded but registration failed — degrade to trigram/LIKE.
logger.warning(
"messages_fts_cjk ensure failed; CJK search stays on "
"trigram/LIKE", exc_info=True,
)
self._fts_cjk_available = False
@staticmethod
def _drop_fts_triggers(cursor: sqlite3.Cursor) -> None:
for trigger in _FTS_TRIGGERS:
@@ -1968,6 +2271,136 @@ class SessionDB:
return False
return bool(more)
# ── CJK-bigram index backfill (dedicated marker pair) ──
#
# Same chunk engine as the main deferred rebuild, but on the
# ``fts_cjk_rebuild_*`` markers so a cjk-only backfill (the common case:
# an already-optimized v23 DB gaining the cjk index) never gates the
# complete ``messages_fts`` / trigram triggers.
def fts_cjk_rebuild_status(self) -> Optional[Dict[str, Any]]:
"""CJK-index backfill progress, or None when none is pending."""
high_water = self.get_meta("fts_cjk_rebuild_high_water")
if high_water is None:
return None
progress = int(self.get_meta("fts_cjk_rebuild_progress") or 0)
total = int(high_water)
if total <= 0:
return None
pct = min(100, int(100 * progress / total))
return {"pending": True, "total": total, "indexed": progress, "percent": pct}
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
high_water_raw = self.get_meta("fts_cjk_rebuild_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):
row = conn.execute(
"SELECT value FROM state_meta "
"WHERE key = 'fts_cjk_rebuild_progress'"
).fetchone()
if row is None:
return False # finished (or cleared) by another process
progress = int(row[0])
if progress >= high_water:
return False
upper = min(progress + chunk, high_water)
conn.execute(
"INSERT INTO messages_fts_cjk(rowid, content, tool_name, tool_calls) "
"SELECT id, content, tool_name, tool_calls FROM messages "
"WHERE id > ? AND id <= ? AND role <> 'tool'",
(progress, upper),
)
conn.execute(
"UPDATE state_meta SET value = ? "
"WHERE key = 'fts_cjk_rebuild_progress'",
(str(upper),),
)
return upper < high_water
try:
more = self._execute_write(_do)
except sqlite3.OperationalError as exc:
logger.debug("CJK FTS rebuild chunk failed (will retry): %s", exc)
return True
if more is False:
status = self.fts_cjk_rebuild_status()
if status is not None and status["indexed"] >= status["total"]:
self._fts_cjk_rebuild_finish()
return False
return bool(more)
def _fts_cjk_rebuild_finish(self) -> None:
"""Boundary sweep + clear the cjk markers; index becomes servable."""
def _do(conn):
hw_row = conn.execute(
"SELECT value FROM state_meta "
"WHERE key = 'fts_cjk_rebuild_high_water'"
).fetchone()
if hw_row is not None:
hw = int(hw_row[0])
lo, hi = hw - 1000, hw + 1000
conn.execute(
"INSERT INTO messages_fts_cjk(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 <= ? AND m.role <> 'tool' "
"AND NOT EXISTS (SELECT 1 FROM messages_fts_cjk_docsize d WHERE d.id = m.id)",
(lo, hi),
)
conn.execute(
"DELETE FROM state_meta WHERE key IN "
"('fts_cjk_rebuild_high_water', 'fts_cjk_rebuild_progress')"
)
self._execute_write(_do)
self._fts_cjk_available = True
logger.info("CJK FTS index backfill complete — serving CJK search.")
def _fts_cjk_reset_if_stale(self) -> None:
"""Rebuild path for a stale cjk index (triggers were dropped).
The gap's extent is unknown, so the only safe recovery is a from-
scratch rebuild: drop the table + triggers, clear the breadcrumb,
recreate via ``_ensure_fts_cjk_schema`` (which sets fresh backfill
markers on a populated DB). Called from ``optimize_fts_storage`` on
a tokenizer-capable host; no-op when not stale.
"""
if not self._fts_cjk_loaded:
return
def _do(conn):
stale = conn.execute(
"SELECT 1 FROM state_meta WHERE key = ?",
(FTS_CJK_STALE_KEY,),
).fetchone()
if not stale:
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")
conn.execute(
"DELETE FROM state_meta WHERE key IN "
f"('{FTS_CJK_STALE_KEY}', 'fts_cjk_rebuild_high_water', "
"'fts_cjk_rebuild_progress')"
)
return True
was_stale = self._execute_write(_do)
if was_stale:
# Recreate outside the write transaction — _ensure_fts_cjk_schema
# uses executescript(), which implicitly commits any pending
# transaction and must not run inside _execute_write's BEGIN
# IMMEDIATE. Sets fresh backfill markers on a populated DB.
with self._lock:
self._ensure_fts_cjk_schema(self._conn)
self._conn.commit()
# ── Opt-in v23 FTS storage optimization (`hermes sessions optimize-storage`) ──
#
# This is the ONLY path that migrates an existing legacy (v22 inline) DB
@@ -1983,8 +2416,10 @@ class SessionDB:
is a legacy inline-FTS install that can be optimized to the v23
external-content schema, or a previous optimize run was interrupted
(legacy vtables already demoted, but backfill markers and/or trash
tables remain) and re-running would resume it. False for fresh and
fully-optimized installs (and when FTS5 is unavailable)."""
tables remain) and re-running would resume it, or the CJK-bigram
index needs a backfill/rebuild on this tokenizer-capable host.
False for fresh and fully-optimized installs (and when FTS5 is
unavailable)."""
if not self._fts_enabled or self.read_only:
return False
with self._lock:
@@ -2000,6 +2435,14 @@ class SessionDB:
"WHERE key = 'fts_rebuild_high_water' LIMIT 1"
).fetchone():
return True
# CJK-bigram index work — only offerable when THIS process can
# tokenize: a pending backfill (markers set at creation on a
# populated DB) or a stale index awaiting a from-scratch rebuild.
if self._fts_cjk_loaded and self._conn.execute(
"SELECT 1 FROM state_meta WHERE key IN "
f"('fts_cjk_rebuild_high_water', '{FTS_CJK_STALE_KEY}') LIMIT 1"
).fetchone():
return True
return self._has_fts_trash(self._conn)
def _has_fts_trash(self, conn) -> bool:
@@ -2089,10 +2532,24 @@ class SessionDB:
if legacy and not pending:
self._demote_legacy_fts_to_trash()
# A stale CJK index (triggers dropped by a tokenizer-less process)
# can only be recovered from scratch — reset it now so the cjk
# backfill phase below rebuilds it. No-op without the tokenizer.
self._fts_cjk_reset_if_stale()
# An optimized v23 DB gaining the cjk index for the first time (no
# legacy work left, tokenizer newly installed): ensure the table +
# markers exist so the backfill phase has work to claim.
if self._fts_cjk_loaded:
with self._lock:
self._ensure_fts_cjk_schema(self._conn)
self._conn.commit()
def _emit(phase: str) -> None:
if progress_cb is None:
return
st = self.fts_rebuild_status()
if st is None:
st = self.fts_cjk_rebuild_status()
progress_cb({
"phase": phase,
"percent": st["percent"] if st else 100,
@@ -2125,6 +2582,15 @@ class SessionDB:
_pause(time.monotonic() - _t0)
_emit("backfill")
# Phase 1b: backfill the CJK-bigram index (its own marker pair; a
# no-op when the tokenizer isn't loadable or nothing is pending).
while True:
_t0 = time.monotonic()
if not self.fts_cjk_rebuild_step():
break
_emit("backfill")
_pause(time.monotonic() - _t0)
# Phase 2: tear down the demoted legacy shadow tables in chunks.
_emit("teardown")
while True:
@@ -2687,6 +3153,9 @@ class SessionDB:
cursor,
include_trigram=trigram_enabled,
)
# CJK-bigram index (cjk_unicode61). Strictly additive to
# the surfaces above and gated on the loadable tokenizer:
self._ensure_fts_cjk_schema(cursor)
self._conn.commit()
@@ -6290,6 +6759,24 @@ class SessionDB:
"""Count CJK characters in text."""
return sum(1 for ch in text if cls._is_cjk_codepoint(ord(ch)))
@classmethod
def _has_lone_cjk_run(cls, query: str) -> bool:
"""True when any maximal CJK run in the query is a single char.
The cjk-bigram index stores bigrams for runs >=2 chars and unigrams
only for isolated chars, so a 1-char CJK term can't match inside
longer runs there — those queries keep the LIKE substring route.
"""
run = 0
for ch in query:
if cls._is_cjk_codepoint(ord(ch)):
run += 1
else:
if run == 1:
return True
run = 0
return run == 1
def search_messages(
self,
query: str,
@@ -6344,11 +6831,11 @@ class SessionDB:
sanitized = self._sanitize_fts5_query(query or "")
if not sanitized:
return "empty"
if self._fts_v2_query_allowed(sanitized):
return "fts_v2"
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"
tokens = [
t for t in raw.split()
if t.upper() not in {"AND", "OR", "NOT"} and self._contains_cjk(t)
@@ -6507,8 +6994,104 @@ class SessionDB:
# query explicitly filtering on role='tool' must therefore use
# the LIKE fallback, which scans the base table directly.
_wants_tool_rows = bool(role_filter) and "tool" in role_filter
# ── 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 (
cjk_count >= 3
self._fts_cjk_available
and not _wants_tool_rows
and not self._has_lone_cjk_run(raw_query)
):
tokens = raw_query.split()
parts = []
for tok in tokens:
if tok.upper() in {"AND", "OR", "NOT"}:
parts.append(tok)
else:
parts.append('"' + tok.replace('"', '""') + '"')
cjk_query = " ".join(parts)
cjk_where = ["messages_fts_cjk MATCH ?"]
cjk_params: list = [cjk_query]
if not include_inactive:
cjk_where.append("(m.active = 1 OR m.compacted = 1)")
if source_filter is not None:
cjk_where.append(f"s.source IN ({','.join('?' for _ in source_filter)})")
cjk_params.extend(source_filter)
if exclude_sources is not None:
cjk_where.append(f"s.source NOT IN ({','.join('?' for _ in exclude_sources)})")
cjk_params.extend(exclude_sources)
if role_filter:
cjk_where.append(f"m.role IN ({','.join('?' for _ in role_filter)})")
cjk_params.extend(role_filter)
cjk_sql = f"""
SELECT
m.id,
m.session_id,
m.role,
snippet(messages_fts_cjk, -1, '>>>', '<<<', '...', 40) AS snippet,
m.content,
m.timestamp,
m.tool_name,
s.source,
s.model,
s.started_at AS session_started
FROM messages_fts_cjk
JOIN messages m ON m.id = messages_fts_cjk.rowid
JOIN sessions s ON s.id = m.session_id
WHERE {' AND '.join(cjk_where)}
{order_by_sql}
LIMIT ? OFFSET ?
"""
cjk_params.extend([limit, offset])
try:
with self._lock:
cjk_cursor = self._conn.execute(cjk_sql, cjk_params)
matches = [dict(row) for row in cjk_cursor.fetchall()]
_trigram_succeeded = True
except sqlite3.OperationalError:
# Tokenizer missing on this connection / query syntax —
# the trigram + LIKE routes below still answer.
logger.debug(
"messages_fts_cjk query failed; falling back to "
"trigram/LIKE", exc_info=True,
)
except sqlite3.DatabaseError as exc:
# Same corruption class as the other FTS reads: rebuild
# in place once and retry; on refusal/failure fall back.
if self._try_runtime_fts_rebuild(exc):
try:
with self._lock:
cjk_cursor = self._conn.execute(
cjk_sql, cjk_params
)
matches = [
dict(row) for row in cjk_cursor.fetchall()
]
_trigram_succeeded = True
except sqlite3.DatabaseError:
logger.warning(
"CJK-bigram FTS search still failing after "
"in-place rebuild; falling back to "
"trigram/LIKE."
)
else:
logger.warning(
"CJK-bigram FTS search hit a corruption error "
"(%s) and no in-place rebuild was possible; "
"falling back to trigram/LIKE.", exc,
)
if (
not _trigram_succeeded
and cjk_count >= 3
and not _any_short_cjk
and self._trigram_available
and not _wants_tool_rows
@@ -8604,9 +9187,10 @@ class SessionDB:
# ── Space reclamation ──
# FTS5 virtual tables whose b-tree segments we merge on optimize. The
# trigram table is created lazily / may be disabled, so we probe before
# touching it (see optimize_fts).
_FTS_TABLES = ("messages_fts", "messages_fts_trigram")
# trigram table is created lazily / may be disabled, and the cjk-bigram
# table only exists (and is only queryable) when the loadable tokenizer
# is present — so we probe each before touching it (see optimize_fts).
_FTS_TABLES = ("messages_fts", "messages_fts_trigram", "messages_fts_cjk")
def _fts_table_exists(self, name: str) -> bool:
"""True if an FTS5 virtual table is queryable in this DB."""
+17 -5
View File
@@ -1,12 +1,24 @@
# fts5_cjk — cjk_unicode61 FTS5 tokenizer
unicode61 + CJK character bigrams (Lucene CJKAnalyzer semantics). Fixes
2-char Korean/CJK terms falling through to LIKE full-table scans.
1-2 char Korean/Chinese/Japanese terms falling through to LIKE full-table
scans in session search.
Build & install to ~/.hermes/lib/:
Build & install to `~/.hermes/lib/`:
./build.sh
Then run `scripts/fts_v2_migrate.py` to create + backfill messages_fts_v2,
and set `HERMES_FTS_V2_READ=1` in ~/.hermes/.env to cut reads over.
Override the .so location with `HERMES_FTS5_CJK_SO`.
Uses the system `sqlite3ext.h` when available, else the vendored copy in
`vendor/` — no libsqlite3-dev required.
Once the extension is installed, the next `SessionDB` open creates the
`messages_fts_cjk` index (external-content, tool rows excluded — same v23
storage discipline as the other indexes). On a populated database, run
hermes sessions optimize-storage
to backfill it; new messages are indexed live either way. Set
`sessions.cjk_fts: false` in `~/.hermes/config.yaml` to disable. Override
the .so location with `HERMES_FTS5_CJK_SO`.
Contributed by Soju06 (PR #65544).
+11 -1
View File
@@ -1,8 +1,18 @@
#!/bin/bash
# Build libfts5_cjk.so and install to ~/.hermes/lib/ (or $1).
#
# Uses the system sqlite3ext.h when present, else the vendored copy in
# vendor/ (public-domain SQLite amalgamation headers) so the build works
# without libsqlite3-dev installed.
set -euo pipefail
cd "$(dirname "$0")"
gcc -shared -fPIC -O2 -Wall -Wextra fts5_cjk.c -o libfts5_cjk.so
CFLAGS_EXTRA=""
if ! echo '#include <sqlite3ext.h>' | gcc -E -xc - >/dev/null 2>&1; then
CFLAGS_EXTRA="-Ivendor"
fi
gcc -shared -fPIC -O2 -Wall -Wextra $CFLAGS_EXTRA fts5_cjk.c -o libfts5_cjk.so
dest="${1:-$HOME/.hermes/lib}"
mkdir -p "$dest"
install -m 0644 libfts5_cjk.so "$dest/libfts5_cjk.so"
+13775
View File
File diff suppressed because it is too large Load Diff
+723
View File
@@ -0,0 +1,723 @@
/*
** 2006 June 7
**
** The author disclaims copyright to this source code. In place of
** a legal notice, here is a blessing:
**
** May you do good and not evil.
** May you find forgiveness for yourself and forgive others.
** May you share freely, never taking more than you give.
**
*************************************************************************
** This header file defines the SQLite interface for use by
** shared libraries that want to be imported as extensions into
** an SQLite instance. Shared libraries that intend to be loaded
** as extensions by SQLite should #include this file instead of
** sqlite3.h.
*/
#ifndef SQLITE3EXT_H
#define SQLITE3EXT_H
#include "sqlite3.h"
/*
** The following structure holds pointers to all of the SQLite API
** routines.
**
** WARNING: In order to maintain backwards compatibility, add new
** interfaces to the end of this structure only. If you insert new
** interfaces in the middle of this structure, then older different
** versions of SQLite will not be able to load each other's shared
** libraries!
*/
struct sqlite3_api_routines {
void * (*aggregate_context)(sqlite3_context*,int nBytes);
int (*aggregate_count)(sqlite3_context*);
int (*bind_blob)(sqlite3_stmt*,int,const void*,int n,void(*)(void*));
int (*bind_double)(sqlite3_stmt*,int,double);
int (*bind_int)(sqlite3_stmt*,int,int);
int (*bind_int64)(sqlite3_stmt*,int,sqlite_int64);
int (*bind_null)(sqlite3_stmt*,int);
int (*bind_parameter_count)(sqlite3_stmt*);
int (*bind_parameter_index)(sqlite3_stmt*,const char*zName);
const char * (*bind_parameter_name)(sqlite3_stmt*,int);
int (*bind_text)(sqlite3_stmt*,int,const char*,int n,void(*)(void*));
int (*bind_text16)(sqlite3_stmt*,int,const void*,int,void(*)(void*));
int (*bind_value)(sqlite3_stmt*,int,const sqlite3_value*);
int (*busy_handler)(sqlite3*,int(*)(void*,int),void*);
int (*busy_timeout)(sqlite3*,int ms);
int (*changes)(sqlite3*);
int (*close)(sqlite3*);
int (*collation_needed)(sqlite3*,void*,void(*)(void*,sqlite3*,
int eTextRep,const char*));
int (*collation_needed16)(sqlite3*,void*,void(*)(void*,sqlite3*,
int eTextRep,const void*));
const void * (*column_blob)(sqlite3_stmt*,int iCol);
int (*column_bytes)(sqlite3_stmt*,int iCol);
int (*column_bytes16)(sqlite3_stmt*,int iCol);
int (*column_count)(sqlite3_stmt*pStmt);
const char * (*column_database_name)(sqlite3_stmt*,int);
const void * (*column_database_name16)(sqlite3_stmt*,int);
const char * (*column_decltype)(sqlite3_stmt*,int i);
const void * (*column_decltype16)(sqlite3_stmt*,int);
double (*column_double)(sqlite3_stmt*,int iCol);
int (*column_int)(sqlite3_stmt*,int iCol);
sqlite_int64 (*column_int64)(sqlite3_stmt*,int iCol);
const char * (*column_name)(sqlite3_stmt*,int);
const void * (*column_name16)(sqlite3_stmt*,int);
const char * (*column_origin_name)(sqlite3_stmt*,int);
const void * (*column_origin_name16)(sqlite3_stmt*,int);
const char * (*column_table_name)(sqlite3_stmt*,int);
const void * (*column_table_name16)(sqlite3_stmt*,int);
const unsigned char * (*column_text)(sqlite3_stmt*,int iCol);
const void * (*column_text16)(sqlite3_stmt*,int iCol);
int (*column_type)(sqlite3_stmt*,int iCol);
sqlite3_value* (*column_value)(sqlite3_stmt*,int iCol);
void * (*commit_hook)(sqlite3*,int(*)(void*),void*);
int (*complete)(const char*sql);
int (*complete16)(const void*sql);
int (*create_collation)(sqlite3*,const char*,int,void*,
int(*)(void*,int,const void*,int,const void*));
int (*create_collation16)(sqlite3*,const void*,int,void*,
int(*)(void*,int,const void*,int,const void*));
int (*create_function)(sqlite3*,const char*,int,int,void*,
void (*xFunc)(sqlite3_context*,int,sqlite3_value**),
void (*xStep)(sqlite3_context*,int,sqlite3_value**),
void (*xFinal)(sqlite3_context*));
int (*create_function16)(sqlite3*,const void*,int,int,void*,
void (*xFunc)(sqlite3_context*,int,sqlite3_value**),
void (*xStep)(sqlite3_context*,int,sqlite3_value**),
void (*xFinal)(sqlite3_context*));
int (*create_module)(sqlite3*,const char*,const sqlite3_module*,void*);
int (*data_count)(sqlite3_stmt*pStmt);
sqlite3 * (*db_handle)(sqlite3_stmt*);
int (*declare_vtab)(sqlite3*,const char*);
int (*enable_shared_cache)(int);
int (*errcode)(sqlite3*db);
const char * (*errmsg)(sqlite3*);
const void * (*errmsg16)(sqlite3*);
int (*exec)(sqlite3*,const char*,sqlite3_callback,void*,char**);
int (*expired)(sqlite3_stmt*);
int (*finalize)(sqlite3_stmt*pStmt);
void (*free)(void*);
void (*free_table)(char**result);
int (*get_autocommit)(sqlite3*);
void * (*get_auxdata)(sqlite3_context*,int);
int (*get_table)(sqlite3*,const char*,char***,int*,int*,char**);
int (*global_recover)(void);
void (*interruptx)(sqlite3*);
sqlite_int64 (*last_insert_rowid)(sqlite3*);
const char * (*libversion)(void);
int (*libversion_number)(void);
void *(*malloc)(int);
char * (*mprintf)(const char*,...);
int (*open)(const char*,sqlite3**);
int (*open16)(const void*,sqlite3**);
int (*prepare)(sqlite3*,const char*,int,sqlite3_stmt**,const char**);
int (*prepare16)(sqlite3*,const void*,int,sqlite3_stmt**,const void**);
void * (*profile)(sqlite3*,void(*)(void*,const char*,sqlite_uint64),void*);
void (*progress_handler)(sqlite3*,int,int(*)(void*),void*);
void *(*realloc)(void*,int);
int (*reset)(sqlite3_stmt*pStmt);
void (*result_blob)(sqlite3_context*,const void*,int,void(*)(void*));
void (*result_double)(sqlite3_context*,double);
void (*result_error)(sqlite3_context*,const char*,int);
void (*result_error16)(sqlite3_context*,const void*,int);
void (*result_int)(sqlite3_context*,int);
void (*result_int64)(sqlite3_context*,sqlite_int64);
void (*result_null)(sqlite3_context*);
void (*result_text)(sqlite3_context*,const char*,int,void(*)(void*));
void (*result_text16)(sqlite3_context*,const void*,int,void(*)(void*));
void (*result_text16be)(sqlite3_context*,const void*,int,void(*)(void*));
void (*result_text16le)(sqlite3_context*,const void*,int,void(*)(void*));
void (*result_value)(sqlite3_context*,sqlite3_value*);
void * (*rollback_hook)(sqlite3*,void(*)(void*),void*);
int (*set_authorizer)(sqlite3*,int(*)(void*,int,const char*,const char*,
const char*,const char*),void*);
void (*set_auxdata)(sqlite3_context*,int,void*,void (*)(void*));
char * (*xsnprintf)(int,char*,const char*,...);
int (*step)(sqlite3_stmt*);
int (*table_column_metadata)(sqlite3*,const char*,const char*,const char*,
char const**,char const**,int*,int*,int*);
void (*thread_cleanup)(void);
int (*total_changes)(sqlite3*);
void * (*trace)(sqlite3*,void(*xTrace)(void*,const char*),void*);
int (*transfer_bindings)(sqlite3_stmt*,sqlite3_stmt*);
void * (*update_hook)(sqlite3*,void(*)(void*,int ,char const*,char const*,
sqlite_int64),void*);
void * (*user_data)(sqlite3_context*);
const void * (*value_blob)(sqlite3_value*);
int (*value_bytes)(sqlite3_value*);
int (*value_bytes16)(sqlite3_value*);
double (*value_double)(sqlite3_value*);
int (*value_int)(sqlite3_value*);
sqlite_int64 (*value_int64)(sqlite3_value*);
int (*value_numeric_type)(sqlite3_value*);
const unsigned char * (*value_text)(sqlite3_value*);
const void * (*value_text16)(sqlite3_value*);
const void * (*value_text16be)(sqlite3_value*);
const void * (*value_text16le)(sqlite3_value*);
int (*value_type)(sqlite3_value*);
char *(*vmprintf)(const char*,va_list);
/* Added ??? */
int (*overload_function)(sqlite3*, const char *zFuncName, int nArg);
/* Added by 3.3.13 */
int (*prepare_v2)(sqlite3*,const char*,int,sqlite3_stmt**,const char**);
int (*prepare16_v2)(sqlite3*,const void*,int,sqlite3_stmt**,const void**);
int (*clear_bindings)(sqlite3_stmt*);
/* Added by 3.4.1 */
int (*create_module_v2)(sqlite3*,const char*,const sqlite3_module*,void*,
void (*xDestroy)(void *));
/* Added by 3.5.0 */
int (*bind_zeroblob)(sqlite3_stmt*,int,int);
int (*blob_bytes)(sqlite3_blob*);
int (*blob_close)(sqlite3_blob*);
int (*blob_open)(sqlite3*,const char*,const char*,const char*,sqlite3_int64,
int,sqlite3_blob**);
int (*blob_read)(sqlite3_blob*,void*,int,int);
int (*blob_write)(sqlite3_blob*,const void*,int,int);
int (*create_collation_v2)(sqlite3*,const char*,int,void*,
int(*)(void*,int,const void*,int,const void*),
void(*)(void*));
int (*file_control)(sqlite3*,const char*,int,void*);
sqlite3_int64 (*memory_highwater)(int);
sqlite3_int64 (*memory_used)(void);
sqlite3_mutex *(*mutex_alloc)(int);
void (*mutex_enter)(sqlite3_mutex*);
void (*mutex_free)(sqlite3_mutex*);
void (*mutex_leave)(sqlite3_mutex*);
int (*mutex_try)(sqlite3_mutex*);
int (*open_v2)(const char*,sqlite3**,int,const char*);
int (*release_memory)(int);
void (*result_error_nomem)(sqlite3_context*);
void (*result_error_toobig)(sqlite3_context*);
int (*sleep)(int);
void (*soft_heap_limit)(int);
sqlite3_vfs *(*vfs_find)(const char*);
int (*vfs_register)(sqlite3_vfs*,int);
int (*vfs_unregister)(sqlite3_vfs*);
int (*xthreadsafe)(void);
void (*result_zeroblob)(sqlite3_context*,int);
void (*result_error_code)(sqlite3_context*,int);
int (*test_control)(int, ...);
void (*randomness)(int,void*);
sqlite3 *(*context_db_handle)(sqlite3_context*);
int (*extended_result_codes)(sqlite3*,int);
int (*limit)(sqlite3*,int,int);
sqlite3_stmt *(*next_stmt)(sqlite3*,sqlite3_stmt*);
const char *(*sql)(sqlite3_stmt*);
int (*status)(int,int*,int*,int);
int (*backup_finish)(sqlite3_backup*);
sqlite3_backup *(*backup_init)(sqlite3*,const char*,sqlite3*,const char*);
int (*backup_pagecount)(sqlite3_backup*);
int (*backup_remaining)(sqlite3_backup*);
int (*backup_step)(sqlite3_backup*,int);
const char *(*compileoption_get)(int);
int (*compileoption_used)(const char*);
int (*create_function_v2)(sqlite3*,const char*,int,int,void*,
void (*xFunc)(sqlite3_context*,int,sqlite3_value**),
void (*xStep)(sqlite3_context*,int,sqlite3_value**),
void (*xFinal)(sqlite3_context*),
void(*xDestroy)(void*));
int (*db_config)(sqlite3*,int,...);
sqlite3_mutex *(*db_mutex)(sqlite3*);
int (*db_status)(sqlite3*,int,int*,int*,int);
int (*extended_errcode)(sqlite3*);
void (*log)(int,const char*,...);
sqlite3_int64 (*soft_heap_limit64)(sqlite3_int64);
const char *(*sourceid)(void);
int (*stmt_status)(sqlite3_stmt*,int,int);
int (*strnicmp)(const char*,const char*,int);
int (*unlock_notify)(sqlite3*,void(*)(void**,int),void*);
int (*wal_autocheckpoint)(sqlite3*,int);
int (*wal_checkpoint)(sqlite3*,const char*);
void *(*wal_hook)(sqlite3*,int(*)(void*,sqlite3*,const char*,int),void*);
int (*blob_reopen)(sqlite3_blob*,sqlite3_int64);
int (*vtab_config)(sqlite3*,int op,...);
int (*vtab_on_conflict)(sqlite3*);
/* Version 3.7.16 and later */
int (*close_v2)(sqlite3*);
const char *(*db_filename)(sqlite3*,const char*);
int (*db_readonly)(sqlite3*,const char*);
int (*db_release_memory)(sqlite3*);
const char *(*errstr)(int);
int (*stmt_busy)(sqlite3_stmt*);
int (*stmt_readonly)(sqlite3_stmt*);
int (*stricmp)(const char*,const char*);
int (*uri_boolean)(const char*,const char*,int);
sqlite3_int64 (*uri_int64)(const char*,const char*,sqlite3_int64);
const char *(*uri_parameter)(const char*,const char*);
char *(*xvsnprintf)(int,char*,const char*,va_list);
int (*wal_checkpoint_v2)(sqlite3*,const char*,int,int*,int*);
/* Version 3.8.7 and later */
int (*auto_extension)(void(*)(void));
int (*bind_blob64)(sqlite3_stmt*,int,const void*,sqlite3_uint64,
void(*)(void*));
int (*bind_text64)(sqlite3_stmt*,int,const char*,sqlite3_uint64,
void(*)(void*),unsigned char);
int (*cancel_auto_extension)(void(*)(void));
int (*load_extension)(sqlite3*,const char*,const char*,char**);
void *(*malloc64)(sqlite3_uint64);
sqlite3_uint64 (*msize)(void*);
void *(*realloc64)(void*,sqlite3_uint64);
void (*reset_auto_extension)(void);
void (*result_blob64)(sqlite3_context*,const void*,sqlite3_uint64,
void(*)(void*));
void (*result_text64)(sqlite3_context*,const char*,sqlite3_uint64,
void(*)(void*), unsigned char);
int (*strglob)(const char*,const char*);
/* Version 3.8.11 and later */
sqlite3_value *(*value_dup)(const sqlite3_value*);
void (*value_free)(sqlite3_value*);
int (*result_zeroblob64)(sqlite3_context*,sqlite3_uint64);
int (*bind_zeroblob64)(sqlite3_stmt*, int, sqlite3_uint64);
/* Version 3.9.0 and later */
unsigned int (*value_subtype)(sqlite3_value*);
void (*result_subtype)(sqlite3_context*,unsigned int);
/* Version 3.10.0 and later */
int (*status64)(int,sqlite3_int64*,sqlite3_int64*,int);
int (*strlike)(const char*,const char*,unsigned int);
int (*db_cacheflush)(sqlite3*);
/* Version 3.12.0 and later */
int (*system_errno)(sqlite3*);
/* Version 3.14.0 and later */
int (*trace_v2)(sqlite3*,unsigned,int(*)(unsigned,void*,void*,void*),void*);
char *(*expanded_sql)(sqlite3_stmt*);
/* Version 3.18.0 and later */
void (*set_last_insert_rowid)(sqlite3*,sqlite3_int64);
/* Version 3.20.0 and later */
int (*prepare_v3)(sqlite3*,const char*,int,unsigned int,
sqlite3_stmt**,const char**);
int (*prepare16_v3)(sqlite3*,const void*,int,unsigned int,
sqlite3_stmt**,const void**);
int (*bind_pointer)(sqlite3_stmt*,int,void*,const char*,void(*)(void*));
void (*result_pointer)(sqlite3_context*,void*,const char*,void(*)(void*));
void *(*value_pointer)(sqlite3_value*,const char*);
int (*vtab_nochange)(sqlite3_context*);
int (*value_nochange)(sqlite3_value*);
const char *(*vtab_collation)(sqlite3_index_info*,int);
/* Version 3.24.0 and later */
int (*keyword_count)(void);
int (*keyword_name)(int,const char**,int*);
int (*keyword_check)(const char*,int);
sqlite3_str *(*str_new)(sqlite3*);
char *(*str_finish)(sqlite3_str*);
void (*str_appendf)(sqlite3_str*, const char *zFormat, ...);
void (*str_vappendf)(sqlite3_str*, const char *zFormat, va_list);
void (*str_append)(sqlite3_str*, const char *zIn, int N);
void (*str_appendall)(sqlite3_str*, const char *zIn);
void (*str_appendchar)(sqlite3_str*, int N, char C);
void (*str_reset)(sqlite3_str*);
int (*str_errcode)(sqlite3_str*);
int (*str_length)(sqlite3_str*);
char *(*str_value)(sqlite3_str*);
/* Version 3.25.0 and later */
int (*create_window_function)(sqlite3*,const char*,int,int,void*,
void (*xStep)(sqlite3_context*,int,sqlite3_value**),
void (*xFinal)(sqlite3_context*),
void (*xValue)(sqlite3_context*),
void (*xInv)(sqlite3_context*,int,sqlite3_value**),
void(*xDestroy)(void*));
/* Version 3.26.0 and later */
const char *(*normalized_sql)(sqlite3_stmt*);
/* Version 3.28.0 and later */
int (*stmt_isexplain)(sqlite3_stmt*);
int (*value_frombind)(sqlite3_value*);
/* Version 3.30.0 and later */
int (*drop_modules)(sqlite3*,const char**);
/* Version 3.31.0 and later */
sqlite3_int64 (*hard_heap_limit64)(sqlite3_int64);
const char *(*uri_key)(const char*,int);
const char *(*filename_database)(const char*);
const char *(*filename_journal)(const char*);
const char *(*filename_wal)(const char*);
/* Version 3.32.0 and later */
const char *(*create_filename)(const char*,const char*,const char*,
int,const char**);
void (*free_filename)(const char*);
sqlite3_file *(*database_file_object)(const char*);
/* Version 3.34.0 and later */
int (*txn_state)(sqlite3*,const char*);
/* Version 3.36.1 and later */
sqlite3_int64 (*changes64)(sqlite3*);
sqlite3_int64 (*total_changes64)(sqlite3*);
/* Version 3.37.0 and later */
int (*autovacuum_pages)(sqlite3*,
unsigned int(*)(void*,const char*,unsigned int,unsigned int,unsigned int),
void*, void(*)(void*));
/* Version 3.38.0 and later */
int (*error_offset)(sqlite3*);
int (*vtab_rhs_value)(sqlite3_index_info*,int,sqlite3_value**);
int (*vtab_distinct)(sqlite3_index_info*);
int (*vtab_in)(sqlite3_index_info*,int,int);
int (*vtab_in_first)(sqlite3_value*,sqlite3_value**);
int (*vtab_in_next)(sqlite3_value*,sqlite3_value**);
/* Version 3.39.0 and later */
int (*deserialize)(sqlite3*,const char*,unsigned char*,
sqlite3_int64,sqlite3_int64,unsigned);
unsigned char *(*serialize)(sqlite3*,const char *,sqlite3_int64*,
unsigned int);
const char *(*db_name)(sqlite3*,int);
/* Version 3.40.0 and later */
int (*value_encoding)(sqlite3_value*);
/* Version 3.41.0 and later */
int (*is_interrupted)(sqlite3*);
/* Version 3.43.0 and later */
int (*stmt_explain)(sqlite3_stmt*,int);
/* Version 3.44.0 and later */
void *(*get_clientdata)(sqlite3*,const char*);
int (*set_clientdata)(sqlite3*, const char*, void*, void(*)(void*));
/* Version 3.50.0 and later */
int (*setlk_timeout)(sqlite3*,int,int);
};
/*
** This is the function signature used for all extension entry points. It
** is also defined in the file "loadext.c".
*/
typedef int (*sqlite3_loadext_entry)(
sqlite3 *db, /* Handle to the database. */
char **pzErrMsg, /* Used to set error string on failure. */
const sqlite3_api_routines *pThunk /* Extension API function pointers. */
);
/*
** The following macros redefine the API routines so that they are
** redirected through the global sqlite3_api structure.
**
** This header file is also used by the loadext.c source file
** (part of the main SQLite library - not an extension) so that
** it can get access to the sqlite3_api_routines structure
** definition. But the main library does not want to redefine
** the API. So the redefinition macros are only valid if the
** SQLITE_CORE macros is undefined.
*/
#if !defined(SQLITE_CORE) && !defined(SQLITE_OMIT_LOAD_EXTENSION)
#define sqlite3_aggregate_context sqlite3_api->aggregate_context
#ifndef SQLITE_OMIT_DEPRECATED
#define sqlite3_aggregate_count sqlite3_api->aggregate_count
#endif
#define sqlite3_bind_blob sqlite3_api->bind_blob
#define sqlite3_bind_double sqlite3_api->bind_double
#define sqlite3_bind_int sqlite3_api->bind_int
#define sqlite3_bind_int64 sqlite3_api->bind_int64
#define sqlite3_bind_null sqlite3_api->bind_null
#define sqlite3_bind_parameter_count sqlite3_api->bind_parameter_count
#define sqlite3_bind_parameter_index sqlite3_api->bind_parameter_index
#define sqlite3_bind_parameter_name sqlite3_api->bind_parameter_name
#define sqlite3_bind_text sqlite3_api->bind_text
#define sqlite3_bind_text16 sqlite3_api->bind_text16
#define sqlite3_bind_value sqlite3_api->bind_value
#define sqlite3_busy_handler sqlite3_api->busy_handler
#define sqlite3_busy_timeout sqlite3_api->busy_timeout
#define sqlite3_changes sqlite3_api->changes
#define sqlite3_close sqlite3_api->close
#define sqlite3_collation_needed sqlite3_api->collation_needed
#define sqlite3_collation_needed16 sqlite3_api->collation_needed16
#define sqlite3_column_blob sqlite3_api->column_blob
#define sqlite3_column_bytes sqlite3_api->column_bytes
#define sqlite3_column_bytes16 sqlite3_api->column_bytes16
#define sqlite3_column_count sqlite3_api->column_count
#define sqlite3_column_database_name sqlite3_api->column_database_name
#define sqlite3_column_database_name16 sqlite3_api->column_database_name16
#define sqlite3_column_decltype sqlite3_api->column_decltype
#define sqlite3_column_decltype16 sqlite3_api->column_decltype16
#define sqlite3_column_double sqlite3_api->column_double
#define sqlite3_column_int sqlite3_api->column_int
#define sqlite3_column_int64 sqlite3_api->column_int64
#define sqlite3_column_name sqlite3_api->column_name
#define sqlite3_column_name16 sqlite3_api->column_name16
#define sqlite3_column_origin_name sqlite3_api->column_origin_name
#define sqlite3_column_origin_name16 sqlite3_api->column_origin_name16
#define sqlite3_column_table_name sqlite3_api->column_table_name
#define sqlite3_column_table_name16 sqlite3_api->column_table_name16
#define sqlite3_column_text sqlite3_api->column_text
#define sqlite3_column_text16 sqlite3_api->column_text16
#define sqlite3_column_type sqlite3_api->column_type
#define sqlite3_column_value sqlite3_api->column_value
#define sqlite3_commit_hook sqlite3_api->commit_hook
#define sqlite3_complete sqlite3_api->complete
#define sqlite3_complete16 sqlite3_api->complete16
#define sqlite3_create_collation sqlite3_api->create_collation
#define sqlite3_create_collation16 sqlite3_api->create_collation16
#define sqlite3_create_function sqlite3_api->create_function
#define sqlite3_create_function16 sqlite3_api->create_function16
#define sqlite3_create_module sqlite3_api->create_module
#define sqlite3_create_module_v2 sqlite3_api->create_module_v2
#define sqlite3_data_count sqlite3_api->data_count
#define sqlite3_db_handle sqlite3_api->db_handle
#define sqlite3_declare_vtab sqlite3_api->declare_vtab
#define sqlite3_enable_shared_cache sqlite3_api->enable_shared_cache
#define sqlite3_errcode sqlite3_api->errcode
#define sqlite3_errmsg sqlite3_api->errmsg
#define sqlite3_errmsg16 sqlite3_api->errmsg16
#define sqlite3_exec sqlite3_api->exec
#ifndef SQLITE_OMIT_DEPRECATED
#define sqlite3_expired sqlite3_api->expired
#endif
#define sqlite3_finalize sqlite3_api->finalize
#define sqlite3_free sqlite3_api->free
#define sqlite3_free_table sqlite3_api->free_table
#define sqlite3_get_autocommit sqlite3_api->get_autocommit
#define sqlite3_get_auxdata sqlite3_api->get_auxdata
#define sqlite3_get_table sqlite3_api->get_table
#ifndef SQLITE_OMIT_DEPRECATED
#define sqlite3_global_recover sqlite3_api->global_recover
#endif
#define sqlite3_interrupt sqlite3_api->interruptx
#define sqlite3_last_insert_rowid sqlite3_api->last_insert_rowid
#define sqlite3_libversion sqlite3_api->libversion
#define sqlite3_libversion_number sqlite3_api->libversion_number
#define sqlite3_malloc sqlite3_api->malloc
#define sqlite3_mprintf sqlite3_api->mprintf
#define sqlite3_open sqlite3_api->open
#define sqlite3_open16 sqlite3_api->open16
#define sqlite3_prepare sqlite3_api->prepare
#define sqlite3_prepare16 sqlite3_api->prepare16
#define sqlite3_prepare_v2 sqlite3_api->prepare_v2
#define sqlite3_prepare16_v2 sqlite3_api->prepare16_v2
#define sqlite3_profile sqlite3_api->profile
#define sqlite3_progress_handler sqlite3_api->progress_handler
#define sqlite3_realloc sqlite3_api->realloc
#define sqlite3_reset sqlite3_api->reset
#define sqlite3_result_blob sqlite3_api->result_blob
#define sqlite3_result_double sqlite3_api->result_double
#define sqlite3_result_error sqlite3_api->result_error
#define sqlite3_result_error16 sqlite3_api->result_error16
#define sqlite3_result_int sqlite3_api->result_int
#define sqlite3_result_int64 sqlite3_api->result_int64
#define sqlite3_result_null sqlite3_api->result_null
#define sqlite3_result_text sqlite3_api->result_text
#define sqlite3_result_text16 sqlite3_api->result_text16
#define sqlite3_result_text16be sqlite3_api->result_text16be
#define sqlite3_result_text16le sqlite3_api->result_text16le
#define sqlite3_result_value sqlite3_api->result_value
#define sqlite3_rollback_hook sqlite3_api->rollback_hook
#define sqlite3_set_authorizer sqlite3_api->set_authorizer
#define sqlite3_set_auxdata sqlite3_api->set_auxdata
#define sqlite3_snprintf sqlite3_api->xsnprintf
#define sqlite3_step sqlite3_api->step
#define sqlite3_table_column_metadata sqlite3_api->table_column_metadata
#define sqlite3_thread_cleanup sqlite3_api->thread_cleanup
#define sqlite3_total_changes sqlite3_api->total_changes
#define sqlite3_trace sqlite3_api->trace
#ifndef SQLITE_OMIT_DEPRECATED
#define sqlite3_transfer_bindings sqlite3_api->transfer_bindings
#endif
#define sqlite3_update_hook sqlite3_api->update_hook
#define sqlite3_user_data sqlite3_api->user_data
#define sqlite3_value_blob sqlite3_api->value_blob
#define sqlite3_value_bytes sqlite3_api->value_bytes
#define sqlite3_value_bytes16 sqlite3_api->value_bytes16
#define sqlite3_value_double sqlite3_api->value_double
#define sqlite3_value_int sqlite3_api->value_int
#define sqlite3_value_int64 sqlite3_api->value_int64
#define sqlite3_value_numeric_type sqlite3_api->value_numeric_type
#define sqlite3_value_text sqlite3_api->value_text
#define sqlite3_value_text16 sqlite3_api->value_text16
#define sqlite3_value_text16be sqlite3_api->value_text16be
#define sqlite3_value_text16le sqlite3_api->value_text16le
#define sqlite3_value_type sqlite3_api->value_type
#define sqlite3_vmprintf sqlite3_api->vmprintf
#define sqlite3_vsnprintf sqlite3_api->xvsnprintf
#define sqlite3_overload_function sqlite3_api->overload_function
#define sqlite3_prepare_v2 sqlite3_api->prepare_v2
#define sqlite3_prepare16_v2 sqlite3_api->prepare16_v2
#define sqlite3_clear_bindings sqlite3_api->clear_bindings
#define sqlite3_bind_zeroblob sqlite3_api->bind_zeroblob
#define sqlite3_blob_bytes sqlite3_api->blob_bytes
#define sqlite3_blob_close sqlite3_api->blob_close
#define sqlite3_blob_open sqlite3_api->blob_open
#define sqlite3_blob_read sqlite3_api->blob_read
#define sqlite3_blob_write sqlite3_api->blob_write
#define sqlite3_create_collation_v2 sqlite3_api->create_collation_v2
#define sqlite3_file_control sqlite3_api->file_control
#define sqlite3_memory_highwater sqlite3_api->memory_highwater
#define sqlite3_memory_used sqlite3_api->memory_used
#define sqlite3_mutex_alloc sqlite3_api->mutex_alloc
#define sqlite3_mutex_enter sqlite3_api->mutex_enter
#define sqlite3_mutex_free sqlite3_api->mutex_free
#define sqlite3_mutex_leave sqlite3_api->mutex_leave
#define sqlite3_mutex_try sqlite3_api->mutex_try
#define sqlite3_open_v2 sqlite3_api->open_v2
#define sqlite3_release_memory sqlite3_api->release_memory
#define sqlite3_result_error_nomem sqlite3_api->result_error_nomem
#define sqlite3_result_error_toobig sqlite3_api->result_error_toobig
#define sqlite3_sleep sqlite3_api->sleep
#define sqlite3_soft_heap_limit sqlite3_api->soft_heap_limit
#define sqlite3_vfs_find sqlite3_api->vfs_find
#define sqlite3_vfs_register sqlite3_api->vfs_register
#define sqlite3_vfs_unregister sqlite3_api->vfs_unregister
#define sqlite3_threadsafe sqlite3_api->xthreadsafe
#define sqlite3_result_zeroblob sqlite3_api->result_zeroblob
#define sqlite3_result_error_code sqlite3_api->result_error_code
#define sqlite3_test_control sqlite3_api->test_control
#define sqlite3_randomness sqlite3_api->randomness
#define sqlite3_context_db_handle sqlite3_api->context_db_handle
#define sqlite3_extended_result_codes sqlite3_api->extended_result_codes
#define sqlite3_limit sqlite3_api->limit
#define sqlite3_next_stmt sqlite3_api->next_stmt
#define sqlite3_sql sqlite3_api->sql
#define sqlite3_status sqlite3_api->status
#define sqlite3_backup_finish sqlite3_api->backup_finish
#define sqlite3_backup_init sqlite3_api->backup_init
#define sqlite3_backup_pagecount sqlite3_api->backup_pagecount
#define sqlite3_backup_remaining sqlite3_api->backup_remaining
#define sqlite3_backup_step sqlite3_api->backup_step
#define sqlite3_compileoption_get sqlite3_api->compileoption_get
#define sqlite3_compileoption_used sqlite3_api->compileoption_used
#define sqlite3_create_function_v2 sqlite3_api->create_function_v2
#define sqlite3_db_config sqlite3_api->db_config
#define sqlite3_db_mutex sqlite3_api->db_mutex
#define sqlite3_db_status sqlite3_api->db_status
#define sqlite3_extended_errcode sqlite3_api->extended_errcode
#define sqlite3_log sqlite3_api->log
#define sqlite3_soft_heap_limit64 sqlite3_api->soft_heap_limit64
#define sqlite3_sourceid sqlite3_api->sourceid
#define sqlite3_stmt_status sqlite3_api->stmt_status
#define sqlite3_strnicmp sqlite3_api->strnicmp
#define sqlite3_unlock_notify sqlite3_api->unlock_notify
#define sqlite3_wal_autocheckpoint sqlite3_api->wal_autocheckpoint
#define sqlite3_wal_checkpoint sqlite3_api->wal_checkpoint
#define sqlite3_wal_hook sqlite3_api->wal_hook
#define sqlite3_blob_reopen sqlite3_api->blob_reopen
#define sqlite3_vtab_config sqlite3_api->vtab_config
#define sqlite3_vtab_on_conflict sqlite3_api->vtab_on_conflict
/* Version 3.7.16 and later */
#define sqlite3_close_v2 sqlite3_api->close_v2
#define sqlite3_db_filename sqlite3_api->db_filename
#define sqlite3_db_readonly sqlite3_api->db_readonly
#define sqlite3_db_release_memory sqlite3_api->db_release_memory
#define sqlite3_errstr sqlite3_api->errstr
#define sqlite3_stmt_busy sqlite3_api->stmt_busy
#define sqlite3_stmt_readonly sqlite3_api->stmt_readonly
#define sqlite3_stricmp sqlite3_api->stricmp
#define sqlite3_uri_boolean sqlite3_api->uri_boolean
#define sqlite3_uri_int64 sqlite3_api->uri_int64
#define sqlite3_uri_parameter sqlite3_api->uri_parameter
#define sqlite3_uri_vsnprintf sqlite3_api->xvsnprintf
#define sqlite3_wal_checkpoint_v2 sqlite3_api->wal_checkpoint_v2
/* Version 3.8.7 and later */
#define sqlite3_auto_extension sqlite3_api->auto_extension
#define sqlite3_bind_blob64 sqlite3_api->bind_blob64
#define sqlite3_bind_text64 sqlite3_api->bind_text64
#define sqlite3_cancel_auto_extension sqlite3_api->cancel_auto_extension
#define sqlite3_load_extension sqlite3_api->load_extension
#define sqlite3_malloc64 sqlite3_api->malloc64
#define sqlite3_msize sqlite3_api->msize
#define sqlite3_realloc64 sqlite3_api->realloc64
#define sqlite3_reset_auto_extension sqlite3_api->reset_auto_extension
#define sqlite3_result_blob64 sqlite3_api->result_blob64
#define sqlite3_result_text64 sqlite3_api->result_text64
#define sqlite3_strglob sqlite3_api->strglob
/* Version 3.8.11 and later */
#define sqlite3_value_dup sqlite3_api->value_dup
#define sqlite3_value_free sqlite3_api->value_free
#define sqlite3_result_zeroblob64 sqlite3_api->result_zeroblob64
#define sqlite3_bind_zeroblob64 sqlite3_api->bind_zeroblob64
/* Version 3.9.0 and later */
#define sqlite3_value_subtype sqlite3_api->value_subtype
#define sqlite3_result_subtype sqlite3_api->result_subtype
/* Version 3.10.0 and later */
#define sqlite3_status64 sqlite3_api->status64
#define sqlite3_strlike sqlite3_api->strlike
#define sqlite3_db_cacheflush sqlite3_api->db_cacheflush
/* Version 3.12.0 and later */
#define sqlite3_system_errno sqlite3_api->system_errno
/* Version 3.14.0 and later */
#define sqlite3_trace_v2 sqlite3_api->trace_v2
#define sqlite3_expanded_sql sqlite3_api->expanded_sql
/* Version 3.18.0 and later */
#define sqlite3_set_last_insert_rowid sqlite3_api->set_last_insert_rowid
/* Version 3.20.0 and later */
#define sqlite3_prepare_v3 sqlite3_api->prepare_v3
#define sqlite3_prepare16_v3 sqlite3_api->prepare16_v3
#define sqlite3_bind_pointer sqlite3_api->bind_pointer
#define sqlite3_result_pointer sqlite3_api->result_pointer
#define sqlite3_value_pointer sqlite3_api->value_pointer
/* Version 3.22.0 and later */
#define sqlite3_vtab_nochange sqlite3_api->vtab_nochange
#define sqlite3_value_nochange sqlite3_api->value_nochange
#define sqlite3_vtab_collation sqlite3_api->vtab_collation
/* Version 3.24.0 and later */
#define sqlite3_keyword_count sqlite3_api->keyword_count
#define sqlite3_keyword_name sqlite3_api->keyword_name
#define sqlite3_keyword_check sqlite3_api->keyword_check
#define sqlite3_str_new sqlite3_api->str_new
#define sqlite3_str_finish sqlite3_api->str_finish
#define sqlite3_str_appendf sqlite3_api->str_appendf
#define sqlite3_str_vappendf sqlite3_api->str_vappendf
#define sqlite3_str_append sqlite3_api->str_append
#define sqlite3_str_appendall sqlite3_api->str_appendall
#define sqlite3_str_appendchar sqlite3_api->str_appendchar
#define sqlite3_str_reset sqlite3_api->str_reset
#define sqlite3_str_errcode sqlite3_api->str_errcode
#define sqlite3_str_length sqlite3_api->str_length
#define sqlite3_str_value sqlite3_api->str_value
/* Version 3.25.0 and later */
#define sqlite3_create_window_function sqlite3_api->create_window_function
/* Version 3.26.0 and later */
#define sqlite3_normalized_sql sqlite3_api->normalized_sql
/* Version 3.28.0 and later */
#define sqlite3_stmt_isexplain sqlite3_api->stmt_isexplain
#define sqlite3_value_frombind sqlite3_api->value_frombind
/* Version 3.30.0 and later */
#define sqlite3_drop_modules sqlite3_api->drop_modules
/* Version 3.31.0 and later */
#define sqlite3_hard_heap_limit64 sqlite3_api->hard_heap_limit64
#define sqlite3_uri_key sqlite3_api->uri_key
#define sqlite3_filename_database sqlite3_api->filename_database
#define sqlite3_filename_journal sqlite3_api->filename_journal
#define sqlite3_filename_wal sqlite3_api->filename_wal
/* Version 3.32.0 and later */
#define sqlite3_create_filename sqlite3_api->create_filename
#define sqlite3_free_filename sqlite3_api->free_filename
#define sqlite3_database_file_object sqlite3_api->database_file_object
/* Version 3.34.0 and later */
#define sqlite3_txn_state sqlite3_api->txn_state
/* Version 3.36.1 and later */
#define sqlite3_changes64 sqlite3_api->changes64
#define sqlite3_total_changes64 sqlite3_api->total_changes64
/* Version 3.37.0 and later */
#define sqlite3_autovacuum_pages sqlite3_api->autovacuum_pages
/* Version 3.38.0 and later */
#define sqlite3_error_offset sqlite3_api->error_offset
#define sqlite3_vtab_rhs_value sqlite3_api->vtab_rhs_value
#define sqlite3_vtab_distinct sqlite3_api->vtab_distinct
#define sqlite3_vtab_in sqlite3_api->vtab_in
#define sqlite3_vtab_in_first sqlite3_api->vtab_in_first
#define sqlite3_vtab_in_next sqlite3_api->vtab_in_next
/* Version 3.39.0 and later */
#ifndef SQLITE_OMIT_DESERIALIZE
#define sqlite3_deserialize sqlite3_api->deserialize
#define sqlite3_serialize sqlite3_api->serialize
#endif
#define sqlite3_db_name sqlite3_api->db_name
/* Version 3.40.0 and later */
#define sqlite3_value_encoding sqlite3_api->value_encoding
/* Version 3.41.0 and later */
#define sqlite3_is_interrupted sqlite3_api->is_interrupted
/* Version 3.43.0 and later */
#define sqlite3_stmt_explain sqlite3_api->stmt_explain
/* Version 3.44.0 and later */
#define sqlite3_get_clientdata sqlite3_api->get_clientdata
#define sqlite3_set_clientdata sqlite3_api->set_clientdata
/* Version 3.50.0 and later */
#define sqlite3_setlk_timeout sqlite3_api->setlk_timeout
#endif /* !defined(SQLITE_CORE) && !defined(SQLITE_OMIT_LOAD_EXTENSION) */
#if !defined(SQLITE_CORE) && !defined(SQLITE_OMIT_LOAD_EXTENSION)
/* This case when the file really is being compiled as a loadable
** extension */
# define SQLITE_EXTENSION_INIT1 const sqlite3_api_routines *sqlite3_api=0;
# define SQLITE_EXTENSION_INIT2(v) sqlite3_api=v;
# define SQLITE_EXTENSION_INIT3 \
extern const sqlite3_api_routines *sqlite3_api;
#else
/* This case when the file is being statically linked into the
** application */
# define SQLITE_EXTENSION_INIT1 /*no-op*/
# define SQLITE_EXTENSION_INIT2(v) (void)v; /* unused parameter */
# define SQLITE_EXTENSION_INIT3 /*no-op*/
#endif
#endif /* SQLITE3EXT_H */
@@ -0,0 +1,70 @@
"""config.yaml sessions.* bridges for the search-index knobs (config-authoritative).
Salvaged from PR #65544 (adapted: agent.fts_v2_read → sessions.cjk_fts).
"""
from __future__ import annotations
import os
from pathlib import Path
import yaml
import gateway.run as gateway_run
def _write_home(tmp_path: Path, sessions_cfg: dict, env_text: str = "") -> Path:
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
(hermes_home / "config.yaml").write_text(
yaml.safe_dump({"sessions": sessions_cfg}), encoding="utf-8"
)
(hermes_home / ".env").write_text(env_text, encoding="utf-8")
return hermes_home
def test_cjk_fts_bridged_from_config(tmp_path, monkeypatch):
home = _write_home(tmp_path, {"cjk_fts": False})
monkeypatch.setattr(gateway_run, "_hermes_home", home)
monkeypatch.setenv("HERMES_CJK_FTS", "1")
gateway_run._reload_runtime_env_preserving_config_authority()
assert os.environ["HERMES_CJK_FTS"] == "False"
def test_search_slow_ms_bridged_from_config(tmp_path, monkeypatch):
home = _write_home(tmp_path, {"search_slow_ms": 250})
monkeypatch.setattr(gateway_run, "_hermes_home", home)
monkeypatch.delenv("HERMES_SEARCH_SLOW_MS", raising=False)
gateway_run._reload_runtime_env_preserving_config_authority()
assert os.environ["HERMES_SEARCH_SLOW_MS"] == "250"
def test_env_survives_when_config_omits_search_knobs(tmp_path, monkeypatch):
home = _write_home(tmp_path, {"auto_prune": False})
monkeypatch.setattr(gateway_run, "_hermes_home", home)
monkeypatch.setenv("HERMES_CJK_FTS", "0")
monkeypatch.setenv("HERMES_SEARCH_SLOW_MS", "700")
gateway_run._reload_runtime_env_preserving_config_authority()
assert os.environ["HERMES_CJK_FTS"] == "0"
assert os.environ["HERMES_SEARCH_SLOW_MS"] == "700"
def test_search_knobs_have_documented_defaults():
"""The advertised config surface must exist in DEFAULT_CONFIG (no
user-facing env switch): cjk index default ON, slow-search log at 1s."""
from hermes_cli.config import DEFAULT_CONFIG
assert DEFAULT_CONFIG["sessions"]["cjk_fts"] is True
assert DEFAULT_CONFIG["sessions"]["search_slow_ms"] == 1000
def test_config_false_disables_cjk_semantics(tmp_path, monkeypatch):
"""The bridged 'False' string must parse as OFF in hermes_state."""
from hermes_state import _cjk_fts_config_enabled
monkeypatch.setenv("HERMES_CJK_FTS", "False")
assert not _cjk_fts_config_enabled()
monkeypatch.setenv("HERMES_CJK_FTS", "True")
assert _cjk_fts_config_enabled()
monkeypatch.delenv("HERMES_CJK_FTS", raising=False)
assert _cjk_fts_config_enabled() # default on
+325
View File
@@ -0,0 +1,325 @@
"""Tests for the messages_fts_cjk CJK-bigram index (salvaged from PR #65544).
Builds the loadable tokenizer from native/fts5_cjk/fts5_cjk.c on the fly;
skips when no C toolchain / extension loading is available.
"""
import shutil
import sqlite3
import subprocess
from pathlib import Path
import pytest
from hermes_state import FTS_CJK_STALE_KEY, SessionDB
REPO = Path(__file__).resolve().parent.parent
SRC = REPO / "native" / "fts5_cjk" / "fts5_cjk.c"
VENDOR = REPO / "native" / "fts5_cjk" / "vendor"
@pytest.fixture(scope="session")
def cjk_so(tmp_path_factory):
if shutil.which("gcc") is None or not SRC.exists():
pytest.skip("no C toolchain / tokenizer source")
out = tmp_path_factory.mktemp("fts5cjk") / "libfts5_cjk.so"
try:
subprocess.run(
["gcc", "-shared", "-fPIC", "-O2", f"-I{VENDOR}", str(SRC),
"-o", str(out)],
check=True, capture_output=True, text=True,
)
except subprocess.CalledProcessError as e:
pytest.skip(f"tokenizer build failed: {e.stderr[:200]}")
# Loadability probe (extension loading may be disabled in this build).
probe = sqlite3.connect(":memory:")
try:
probe.enable_load_extension(True)
probe.load_extension(str(out))
except Exception as e:
pytest.skip(f"extension loading unavailable: {e}")
finally:
probe.close()
return out
@pytest.fixture()
def db(cjk_so, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_FTS5_CJK_SO", str(cjk_so))
d = SessionDB(db_path=tmp_path / "state.db")
assert d._fts_cjk_loaded, "tokenizer must load on the writer connection"
assert d._fts_cjk_available, "fresh DB must be born with the cjk index"
d.create_session(session_id="s1", source="cli", model="m")
d.append_message("s1", role="user", content="웅기가 shared default 프로필을 요청했다")
d.append_message("s1", role="assistant", content="일본 MCP 후보 우선순위 정리했습니다")
d.append_message("s1", role="user", content="graphiti daemon looks healthy")
d.append_message("s1", role="tool", content="일본 tool output blob", tool_name="terminal")
yield d
d.close()
def test_two_char_korean_hits_cjk_index(db):
rows = db.search_messages("웅기", limit=10)
assert rows and "웅기" in rows[0]["snippet"]
rows = db.search_messages("일본", limit=10)
assert rows
def test_mixed_and_ascii_queries(db):
assert db.search_messages("graphiti", limit=10)
assert db.search_messages('"shared default" AND 웅기', limit=10)
assert db.search_messages("우선순위", limit=10)
def test_no_false_positive_across_words(db):
# 기가/했다 exist inside runs; a bigram crossing a word boundary must not.
assert db.search_messages("다프로", limit=10) == []
def test_lone_single_cjk_char_routes_like(db):
# 1-char CJK terms keep LIKE substring semantics (bigram index only
# holds unigrams for isolated chars). "가" appears inside 웅기가.
assert db._describe_search_path("가") == "like_scan"
rows = db.search_messages("가", limit=10)
assert rows, "LIKE fallback must still find substring matches"
def test_tool_role_filter_routes_like(db):
# Tool rows are excluded from the cjk index; role_filter=['tool'] CJK
# queries take the LIKE route and still find tool output.
rows = db.search_messages("일본", role_filter=["tool"], limit=10)
assert rows and all(r["role"] == "tool" for r in rows)
def test_triggers_mirror_updates_and_deletes(db):
db.append_message("s1", role="user", content="자바스크립트 리팩토링")
assert db.search_messages("리팩토링", limit=10)
with db._lock:
db._conn.execute(
"UPDATE messages SET content = '파이썬 리라이트' WHERE content LIKE '%리팩토링%'"
)
db._conn.commit()
assert db.search_messages("리팩토링", limit=10) == []
assert db.search_messages("리라이트", limit=10)
with db._lock:
db._conn.execute("DELETE FROM messages WHERE content = '파이썬 리라이트'")
db._conn.commit()
assert db.search_messages("리라이트", limit=10) == []
def test_rewound_rows_hidden_from_cjk_search(db):
db.append_message("s1", role="user", content="되돌리기 대상 메시지")
assert db.search_messages("되돌리기", limit=10)
with db._lock:
db._conn.execute(
"UPDATE messages SET active = 0, compacted = 0 "
"WHERE content LIKE '%되돌리기%'"
)
db._conn.commit()
assert db.search_messages("되돌리기", limit=10) == []
assert db.search_messages("되돌리기", include_inactive=True, limit=10)
def test_config_toggle_disables_cjk(cjk_so, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_FTS5_CJK_SO", str(cjk_so))
monkeypatch.setenv("HERMES_CJK_FTS", "0")
d = SessionDB(db_path=tmp_path / "state.db")
try:
assert not d._fts_cjk_loaded
assert not d._fts_cjk_available
# No cjk objects created at all.
with d._lock:
row = d._conn.execute(
"SELECT 1 FROM sqlite_master WHERE name = 'messages_fts_cjk'"
).fetchone()
assert row is None
finally:
d.close()
def test_no_extension_no_cjk_objects(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_FTS5_CJK_SO", str(tmp_path / "nonexistent.so"))
d = SessionDB(db_path=tmp_path / "state.db")
try:
assert not d._fts_cjk_loaded
assert not d._fts_cjk_available
d.create_session(session_id="s1", source="cli", model="m")
d.append_message("s1", role="user", content="일본 MCP 정리")
# Trigram/LIKE routing still answers.
assert d.search_messages("일본", limit=10)
finally:
d.close()
def test_tokenizer_loss_self_heals_and_optimize_rebuilds(cjk_so, tmp_path, monkeypatch):
"""Full stale lifecycle: capable open → tokenizer-less open (drops
triggers, breadcrumbs) → rows written in the gap → capable open again
(index NOT served) → optimize-storage rebuilds → search complete."""
monkeypatch.setenv("HERMES_FTS5_CJK_SO", str(cjk_so))
db_path = tmp_path / "state.db"
d1 = SessionDB(db_path=db_path)
assert d1._fts_cjk_available
d1.create_session(session_id="s1", source="cli", model="m")
d1.append_message("s1", role="user", content="첫번째 메시지")
d1.close()
# Tokenizer-less open: triggers dropped, breadcrumb set, writes fine.
monkeypatch.setenv("HERMES_FTS5_CJK_SO", str(tmp_path / "gone.so"))
d2 = SessionDB(db_path=db_path)
assert not d2._fts_cjk_loaded
assert not d2._fts_cjk_available
d2.append_message("s1", role="user", content="틈새에 쓰인 메시지")
assert d2.get_meta(FTS_CJK_STALE_KEY) == "1"
with d2._lock:
trigs = d2._conn.execute(
"SELECT COUNT(*) FROM sqlite_master WHERE type='trigger' "
"AND name LIKE 'messages_fts_cjk%'"
).fetchone()[0]
assert trigs == 0
# Search still answers via trigram/LIKE.
assert d2.search_messages("틈새", limit=10)
d2.close()
# Capable open again: stale index must NOT be served.
monkeypatch.setenv("HERMES_FTS5_CJK_SO", str(cjk_so))
d3 = SessionDB(db_path=db_path)
assert d3._fts_cjk_loaded
assert not d3._fts_cjk_available, "stale index must not serve reads"
assert d3.fts_optimize_available(), "optimize must offer the rebuild"
# Search still complete through the legacy routes meanwhile.
assert d3.search_messages("틈새", limit=10)
result = d3.optimize_fts_storage(vacuum=False)
assert result["ok"]
assert d3._fts_cjk_available
assert d3.get_meta(FTS_CJK_STALE_KEY) is None
assert d3.fts_cjk_rebuild_status() is None
# Both the pre-gap and in-gap rows are now searchable via the index.
assert d3._describe_search_path("틈새") == "fts_cjk"
assert d3.search_messages("첫번째", limit=10)
assert d3.search_messages("틈새", limit=10)
d3.close()
def test_existing_v23_db_gains_cjk_via_optimize(cjk_so, tmp_path, monkeypatch):
"""A v23 DB created BEFORE the extension existed: next capable open
creates the index with backfill markers; optimize-storage backfills."""
monkeypatch.setenv("HERMES_FTS5_CJK_SO", str(tmp_path / "absent.so"))
db_path = tmp_path / "state.db"
d1 = SessionDB(db_path=db_path)
d1.create_session(session_id="s1", source="cli", model="m")
for i in range(10):
d1.append_message("s1", role="user", content=f"기존 메시지 {i}")
d1.close()
monkeypatch.setenv("HERMES_FTS5_CJK_SO", str(cjk_so))
d2 = SessionDB(db_path=db_path)
assert d2._fts_cjk_loaded
# Backfill pending — index not served yet, old rows not indexed.
assert not d2._fts_cjk_available
st = d2.fts_cjk_rebuild_status()
assert st is not None and st["pending"]
assert d2.fts_optimize_available()
# NEW rows are indexed live by the id-gated triggers even mid-backfill.
d2.append_message("s1", role="user", content="새로운 메시지")
# Search answers via legacy routes meanwhile.
assert d2.search_messages("기존", limit=10)
result = d2.optimize_fts_storage(vacuum=False)
assert result["ok"]
assert d2._fts_cjk_available
assert d2.fts_cjk_rebuild_status() is None
assert d2._describe_search_path("기존") == "fts_cjk"
rows = d2.search_messages("기존", limit=20)
assert len(rows) == 10
assert d2.search_messages("새로운", limit=10)
d2.close()
def test_legacy_v22_optimize_lands_on_cjk(cjk_so, tmp_path, monkeypatch):
"""A legacy inline-FTS (pre-v23) DB optimized on a tokenizer-capable
host comes out with BOTH the v23 external-content layout AND a complete
cjk index in the same run."""
import time as _time
from hermes_state import SCHEMA_SQL
monkeypatch.setenv("HERMES_FTS5_CJK_SO", str(cjk_so))
db_path = tmp_path / "state.db"
# Hand-build a genuine legacy inline DB (single-column messages_fts).
conn = sqlite3.connect(str(db_path))
conn.executescript(SCHEMA_SQL)
conn.executescript("""
DROP TABLE IF EXISTS messages_fts;
DROP TABLE IF EXISTS messages_fts_trigram;
DROP VIEW IF EXISTS messages_fts_trigram_src;
CREATE VIRTUAL TABLE messages_fts USING fts5(content);
CREATE TRIGGER messages_fts_insert AFTER INSERT ON messages BEGIN
INSERT INTO messages_fts(rowid, content) VALUES (new.id, COALESCE(new.content,''));
END;
""")
conn.execute("DELETE FROM schema_version")
conn.execute("INSERT INTO schema_version (version) VALUES (10)")
conn.execute(
"INSERT INTO sessions (id, source, started_at) VALUES ('s1', 'cli', ?)",
(_time.time(),),
)
for role, content in (
("user", "레거시 일본 메시지"),
("assistant", "legacy english reply"),
("tool", "레거시 tool output"),
):
conn.execute(
"INSERT INTO messages (session_id, timestamp, role, content) "
"VALUES ('s1', ?, ?, ?)",
(_time.time(), role, content),
)
conn.commit()
conn.close()
d = SessionDB(db_path=db_path)
try:
assert d.fts_optimize_available()
# Legacy DB: cjk index deliberately not created at open (the legacy
# branch of _init_schema doesn't touch v23 surfaces).
assert not d._fts_cjk_available
result = d.optimize_fts_storage(vacuum=False)
assert result["ok"]
assert d._fts_cjk_available
assert d.fts_cjk_rebuild_status() is None
assert d._describe_search_path("일본") == "fts_cjk"
assert d.search_messages("일본", limit=10)
assert d.search_messages("legacy english", limit=10)
with d._lock:
idx = d._conn.execute(
"SELECT COUNT(*) FROM messages_fts_cjk"
).fetchone()[0]
non_tool = d._conn.execute(
"SELECT COUNT(*) FROM messages WHERE role <> 'tool'"
).fetchone()[0]
assert idx == non_tool
finally:
d.close()
def test_fresh_db_index_counts_exclude_tool_rows(db):
with db._lock:
idx = db._conn.execute(
"SELECT COUNT(*) FROM messages_fts_cjk"
).fetchone()[0]
non_tool = db._conn.execute(
"SELECT COUNT(*) FROM messages WHERE role <> 'tool'"
).fetchone()[0]
assert idx == non_tool
def test_integrity_after_lifecycle(db):
db.append_message("s1", role="user", content="무결성 검사")
with db._lock:
db._conn.execute(
"INSERT INTO messages_fts_cjk(messages_fts_cjk) "
"VALUES('integrity-check')"
)
+16 -3
View File
@@ -1,4 +1,4 @@
"""Tests for the session-search slow-query log (patch search-slow-query-log)."""
"""Tests for the session-search slow-query log (salvaged from PR #65544)."""
import logging
@@ -34,13 +34,26 @@ def test_no_log_under_threshold(db, monkeypatch, caplog):
assert not [r for r in caplog.records if "slow session search" in r.getMessage()]
def test_path_attribution(db, monkeypatch):
monkeypatch.setenv("HERMES_FTS_V2_READ", "0")
def test_path_attribution(db):
# Without the cjk tokenizer loaded, routing matches the pre-cjk shape.
assert db._describe_search_path("graphiti OR neo4j") == "fts5"
assert db._describe_search_path("우선순위 캘린더") == "trigram"
assert db._describe_search_path("일본 MCP") == "like_scan"
def test_path_attribution_cjk_available(db):
# With the bigram index available, CJK queries (including 2-char terms)
# route to fts_cjk; lone 1-char CJK runs keep the LIKE route.
db._fts_cjk_available = True
try:
assert db._describe_search_path("일본 MCP") == "fts_cjk"
assert db._describe_search_path("우선순위 캘린더") == "fts_cjk"
assert db._describe_search_path("가 alone") == "like_scan"
assert db._describe_search_path("graphiti OR neo4j") == "fts5"
finally:
db._fts_cjk_available = False
def test_results_unchanged_by_wrapper(db, monkeypatch):
monkeypatch.setenv("HERMES_SEARCH_SLOW_MS", "0")
rows = db.search_messages("graphiti", limit=5)