Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -164,3 +164,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
6 changes: 6 additions & 0 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -785,6 +785,12 @@ def load_cli_config() -> Dict[str, Any]:
if redact is not None:
os.environ["HERMES_REDACT_SECRETS"] = str(redact).lower()

# Session-search CJK index setting (hermes_state reads the env carrier).
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"])

return defaults

# Load configuration at module startup
Expand Down
12 changes: 12 additions & 0 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -1511,6 +1511,12 @@ 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 CJK session-search setting (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"])


def _current_max_iterations() -> int:
Expand Down Expand Up @@ -1793,6 +1799,12 @@ def _platform_has_bot_credential(platform: "Platform", platform_config: "Platfor
os.environ["HERMES_AUTO_CONTINUE_FRESHNESS"] = str(
_agent_cfg["gateway_auto_continue_freshness"]
)
# Config-authoritative CJK session-search setting; 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"])
_display_cfg = _cfg.get("display", {})
if _display_cfg and isinstance(_display_cfg, dict):
if "busy_input_mode" in _display_cfg:
Expand Down
25 changes: 25 additions & 0 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -3272,6 +3272,31 @@ def _ensure_hermes_home_managed(home: Path):
# GBs of disk on heavy users. Opt in only if you have an external
# tool that consumes the JSON files directly.
"write_json_snapshots": False,
# Search-index (FTS) storage optimization — the compact v23 layout
# that drops duplicate content copies and stops trigram-indexing tool
# output (typically reclaims ~60%+ of state.db on heavy users). It is
# OPT-IN: existing databases keep their working legacy index until the
# user runs `hermes sessions optimize-storage`, because the rebuild is
# disk-heavy and long on large DBs (see that command's disk preflight).
#
# "advise" (default): `hermes update` prints a one-line notice with
# the reclaimable size and the command, when a legacy index is
# detected. Nothing is changed automatically.
# "require": the notice is shown as a REQUIRED upgrade (firmer copy),
# and future tooling may gate on it. Flip this default in a future
# release when we're ready to make the v23 layout mandatory — the
# command, progress bar, and resumability are already in place, so
# 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,
},

# Contextual first-touch onboarding hints (see agent/onboarding.py).
Expand Down
274 changes: 273 additions & 1 deletion hermes_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -6228,6 +6228,120 @@ def _print_curator_first_run_notice() -> None:
)


def _print_fts_optimize_available_notice() -> None:
"""Advertise the opt-in v23 search-index optimization after `hermes update`.

Only fires when the current profile's state.db is still on the legacy
(pre-v23) inline FTS layout. Leads with the reclaimable-space figure and
points at the exact command. Honors ``sessions.fts_optimize_notice``:
``advise`` (default) prints an advisory notice, ``require`` prints a
firmer required-upgrade notice, ``off`` suppresses it. Silent for
fresh/already-optimized installs.
"""
mode = "advise"
try:
from hermes_cli.config import load_config

mode = str(
((load_config() or {}).get("sessions") or {}).get(
"fts_optimize_notice", "advise"
)
).strip().lower()
except Exception:
mode = "advise"
if mode == "off":
return

try:
from hermes_constants import get_hermes_home
from hermes_state import SessionDB
except Exception:
return
db_path = get_hermes_home() / "state.db"
if not db_path.exists():
return
try:
size_gb = db_path.stat().st_size / (1024 ** 3)
except OSError:
return
# Skip the notice for trivially small DBs — the win isn't worth the nag.
if size_gb < 0.5:
return
db = None
interrupted = False
try:
db = SessionDB(db_path=db_path, read_only=True)
# read_only opens skip schema init, so probe the layout directly.
row = db._conn.execute(
"SELECT sql FROM sqlite_master "
"WHERE type = 'table' AND name = 'messages_fts'"
).fetchone()
# An interrupted `optimize-storage` run: the table is already the
# v23 shape, but backfill markers / demoted trash tables remain.
# Offer the command again — re-running resumes and finishes it.
interrupted = bool(
db._conn.execute(
"SELECT 1 FROM state_meta "
"WHERE key = 'fts_rebuild_high_water' LIMIT 1"
).fetchone()
or db._conn.execute(
"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
finally:
if db is not None:
try:
db.close()
except Exception:
pass
sql = (row[0] if row else "") or ""
if not sql or ("tool_name" in sql and not interrupted):
# v23 layout already present (fresh/optimized) — nothing to offer.
return

if interrupted:
print()
print("◆ Session database optimization incomplete")
print(
" A previous `hermes sessions optimize-storage` run was "
"interrupted. Search still works; re-run the command to resume "
"and finish reclaiming disk:"
)
print(" hermes sessions optimize-storage")
return

# Concrete size framing — lead with the savings the user cares about.
est_reclaim = size_gb * 0.6
print()
if mode == "require":
print("◆ Session database upgrade required")
print(
f" Your search index uses the OLD storage layout and should be "
f"upgraded. The new layout typically frees ~60% of state.db "
f"(≈{est_reclaim:.1f} GB of your current {size_gb:.1f} GB) and is "
f"required for continued optimal operation."
)
else:
print("◆ Reclaim ~60% of your session database disk")
print(
f" Your search index uses the old storage layout. Upgrading it "
f"typically frees ~60% of state.db — about {est_reclaim:.1f} GB "
f"of your current {size_gb:.1f} GB."
)
print(" Run when convenient: hermes sessions optimize-storage")
print(
" It runs in the foreground with a progress bar, is safe to "
"interrupt/re-run, and never changes your conversations."
)


def _print_curator_recent_run_notice() -> None:
"""Print the most recent curator run summary, exactly once.

Expand Down Expand Up @@ -9106,6 +9220,19 @@ def _ensure_fhs_path_guard() -> None:
print(" (reload your shell or run 'source ~/.bashrc' to pick it up)")


def _size_delta_label(saved_mb: float) -> str:
"""Human label for a before/after database size delta, in MB.

A negative delta means the file GREW — concurrent session writes during a
long optimize can outweigh what the rebuild freed. Printing
"reclaimed -163.0 MB" for that reads as data loss, so say "grew by"
instead.
"""
if saved_mb >= 0:
return f"reclaimed {saved_mb:.1f} MB"
return f"grew by {-saved_mb:.1f} MB"


_PRE_UPDATE_SNAPSHOT_KEEP = 1

# Per-file size cap for the pre-update quick snapshot. Anything larger is
Expand Down Expand Up @@ -10778,6 +10905,17 @@ def _print_items(items, label, key, fallback_key=None):
else:
print("✓ Update complete!")

# Search-index optimization notice (v23). Existing installs keep their
# working search index untouched on update; the compact v23 layout —
# which reclaims a large fraction of state.db on heavy users — is
# opt-in. Surface it here (the moment the user is already thinking
# about their install) with the exact command and the concrete size
# win. Show-once-ish: only when a legacy index is actually present.
try:
_print_fts_optimize_available_notice()
except Exception as e:
logger.debug("FTS optimize notice failed: %s", e)

# Curator first-run heads-up. Only prints when curator is enabled AND
# has never run — i.e. the window where the ticker would otherwise
# have fired against a fresh skill library. Kept silent on steady
Expand Down Expand Up @@ -14303,6 +14441,33 @@ def _add_session_filter_args(p, default_older_help):
help="Reclaim disk space: merge FTS5 segments + VACUUM (no data change)",
)

sessions_optimize_storage = sessions_subparsers.add_parser(
"optimize-storage",
help="Migrate the search index to the compact v23 layout (reclaims disk on large DBs)",
description=(
"Rebuild the full-text search index in the compact v23 "
"external-content layout. On large databases this reclaims a "
"large fraction of state.db (the old layout stored duplicate "
"copies of every message and indexed tool output). Runs "
"foreground with a progress bar, throttles so a running gateway "
"stays responsive, and VACUUMs at the end. Safe to interrupt and "
"re-run — it resumes where it left off. No conversation data is "
"changed; only the search index is rebuilt."
),
)
sessions_optimize_storage.add_argument(
"--no-vacuum",
action="store_true",
default=False,
help="Skip the final VACUUM (index is rebuilt but freed pages aren't returned to the OS until a later VACUUM)",
)
sessions_optimize_storage.add_argument(
"--yes", "-y",
action="store_true",
default=False,
help="Skip the disk-space confirmation prompt",
)

sessions_repair = sessions_subparsers.add_parser(
"repair",
help="Repair a malformed state.db schema so hidden sessions reappear",
Expand Down Expand Up @@ -15078,12 +15243,119 @@ def _export_one(session_id: str):
if db_path.exists()
else 0.0
)
# Same WAL caveat as optimize-storage: after a VACUUM the main file
# on disk lags until the WAL is checkpointed back (refused while a
# live gateway holds a read-mark), so stat() understates the win and
# can go negative. SQLite's page accounting is correct immediately.
logical_after = db.logical_size_bytes()
if logical_after is not None:
after_mb = logical_after / (1024 * 1024)
saved = before_mb - after_mb
print(f"Optimized {n} FTS index(es).")
print(
f"Database size: {before_mb:.1f} MB -> {after_mb:.1f} MB "
f"(reclaimed {saved:.1f} MB)"
f"({_size_delta_label(saved)})"
)

elif action == "optimize-storage":
db_path = db.db_path
if not db.fts_optimize_available():
print("Search index is already on the compact layout — nothing to do.")
db.close()
return

before_bytes = os.path.getsize(db_path) if db_path.exists() else 0
before_mb = before_bytes / (1024 * 1024)

# Disk preflight: the rebuild adds the new index before the old is
# torn down, and the final VACUUM needs a full second copy of the
# file. Require headroom ≈ current file size to finish cleanly.
do_vacuum = not getattr(args, "no_vacuum", False)
try:
import shutil as _shutil
free_bytes = _shutil.disk_usage(db_path.parent).free
except Exception:
free_bytes = None
need_bytes = before_bytes if do_vacuum else int(before_bytes * 0.3)
print(f"Search-index optimization for {db_path}")
print(f" Current database size: {before_mb:.1f} MB")
if free_bytes is not None:
print(f" Free disk: {free_bytes / (1024*1024):.0f} MB "
f"(need ~{need_bytes / (1024*1024):.0f} MB to complete"
f"{' incl. VACUUM' if do_vacuum else ''})")
if free_bytes < need_bytes:
print()
print("⚠ Not enough free disk to complete safely. Free up "
"space, or run with --no-vacuum (rebuilds the index "
"but doesn't reclaim space until a later VACUUM).")
db.close()
return
if before_mb > 500:
print(" This may take a while on a large database. It runs in "
"the foreground with progress below; safe to Ctrl-C and "
"re-run (it resumes).")
if not getattr(args, "yes", False):
try:
resp = input("Proceed? [y/N] ").strip().lower()
except EOFError:
resp = ""
if resp not in ("y", "yes"):
print("Cancelled.")
db.close()
return

_last = {"phase": None}

def _progress(info):
phase = info.get("phase")
pct = info.get("percent", 0)
if phase == "backfill":
print(f"\r Rebuilding index: {pct:3d}% "
f"({info.get('indexed',0):,}/{info.get('total',0):,})",
end="", flush=True)
elif phase != _last["phase"]:
label = {"teardown": "Reclaiming old index",
"vacuum": "Compacting database (VACUUM)",
"done": "Done"}.get(phase, phase)
print(f"\n {label}…", flush=True)
_last["phase"] = phase

print("Optimizing search-index storage…")
try:
result = db.optimize_fts_storage(
progress_cb=_progress, vacuum=do_vacuum
)
except Exception as e:
print(f"\nError: optimization failed: {e}")
print("No data was lost. Re-run to resume.")
db.close()
return
if not result.get("ok"):
print(f"\nCould not optimize: {result.get('reason', 'unknown')}")
db.close()
return
after_mb = (
os.path.getsize(db_path) / (1024 * 1024) if db_path.exists() else 0.0
)
# Prefer SQLite's own page accounting over stat(). In WAL mode a
# VACUUM's rewrite sits in the -wal file until a checkpoint folds it
# back, and that checkpoint is refused while another connection (a
# live gateway) holds a read-mark — so the main file on disk still
# reads at its pre-VACUUM size and keeps growing. stat()ing it here
# reported "reclaimed -3820.1 MB" on a DB that had actually shrunk
# 60%. page_count * page_size is correct immediately.
logical_after = db.logical_size_bytes()
if logical_after is not None:
after_mb = logical_after / (1024 * 1024)
saved = before_mb - after_mb
print(f"\n✓ Search index optimized.")
print(
f" Database size: {before_mb:.1f} MB -> {after_mb:.1f} MB "
f"({_size_delta_label(saved)})"
)
if result.get("vacuumed") is False:
print(" (VACUUM was skipped or failed — run "
"`hermes sessions optimize` later to reclaim freed space.)")

elif action == "stats":
total = db.session_count()
Expand Down
Loading