Resolve user-facing versions from SemVer release tags and available local
history while retaining raw package metadata for API identity. Stamp Desktop
provenance at build time, and expose generic version details for every
Desktop install method.
list_recent_user_messages and the in-memory /retry + session.undo walkers
treated every role=user row as a real user turn. Timeline bookkeeping
(model_switch, async_delegation_complete, auto_continue, hidden) is stored
that way, so /undo soft-deleted from a marker and /retry re-sent opaque
bookkeeping text. Exclude display_kind the same way CLI resume counting and
the prompt.submit ordinal path do.
When an operator changes the global model/provider config, warn that
unpinned cron jobs with stored snapshots will fail-closed on their next
run. Adds a cron.model_drift_guard config opt-out (default true) for
fleets that should deliberately track changing global defaults.
Addresses #59031. Original PR #59177 by @doncazper.
Saying EXACTLY a configured stop phrase (default: 'stop') and nothing
else now ends the voice conversation instead of being sent to the agent
as a prompt. Match is deliberately strict — whole utterance,
case-insensitive, surrounding punctuation stripped — so 'stop doing
that and try X' still reaches the agent.
- tools/voice_mode.py: is_voice_stop_phrase() + voice.stop_phrases
config loader (default ['stop'], [] disables, malformed config falls
back safely).
- hermes_cli/voice.py: shared continuous loop (TUI + desktop) halts on
a stop phrase exactly like the silent-cycle limit (fires
on_silent_limit so every UI turns voice off); stop_continuous
force-transcribe path swallows the phrase without counting a silent
cycle.
- cli.py: classic CLI push-to-talk/continuous path and barge-in
utterance path disable voice mode on a stop phrase.
- Config default voice.stop_phrases: ['stop']; docs updated.
Tests: tests/tools/test_voice_stop_phrase.py (27 tests,
sabotage-verified: loop test fails when detection is disabled);
voice suites green (110 + 44 passed).
Widen the cherry-picked /diff base (#4839 by @SHL0MS) into one
cross-surface implementation, folding in the review feedback and the
best ideas from the two sibling PRs (#22703, #53527):
- tools/working_diff.py: shared git collection layer — unstaged
(default), staged, and all (vs HEAD) modes; untracked files folded in
via `git diff --no-index` so new files appear as additions (Codex
/diff parity); shlex-split arguments preserve quoted paths.
- CLI: handler moved to hermes_cli/cli_commands_mixin.py per the
current god-file decomposition (dispatch stays in cli.py), renders
through the rich console with a 400-line terminal-flood guard.
- Gateway: _handle_diff_command in gateway/slash_commands.py + dispatch
in gateway/run.py; fenced ```diff output truncated to 60 lines /
3000 chars before the platform senders apply their own per-platform
message clamps (tool-progress-style layered truncation). Localized
strings in all 17 locale catalogs.
- /diff session (from #53527): cumulative checkpoint-baseline diff of
everything Hermes changed, via new CheckpointManager.session_diff();
docstring records the retained-baseline approximation caveat from
review. Works on both surfaces; degrades with an actionable message
when checkpoints are off.
- Slack: /diff routed via /hermes diff (50-slash cap; keeps
telegram-parity test green and /version native).
- Registry: cross-surface CommandDef with staged|all|session
subcommands; docs: slash-commands reference (CLI + gateway tables +
both-surfaces list) and hermes-agent skill reference.
- Tests: tests/tools/test_working_diff.py (real git repos),
tests/hermes_cli/test_diff_command.py (real git + stubbed checkpoint
manager), tests/gateway/test_diff_command.py (end-to-end handler,
real checkpoint store), TestSessionDiff in
tests/tools/test_checkpoint_manager.py.
Salvaged from the /diff PR cluster #4839 + #22703 + #53527.
Co-authored-by: Ninso112 <ninso112@proton.me>
Co-authored-by: Harshkamdar67 <harshkamdar67@gmail.com>
Shows staged and unstaged changes in the current working directory.
/diff shows stat summary + full diff, /diff --stat shows summary only.
Uses git diff directly — no checkpoint system required. Works in any
git repository.
Closes#4250
Extends the cherry-picked /context command (PR #52184) and prompt-size
attribution helpers (PR #66656) into one visual context view across
surfaces, and absorbs the per-component budget-visibility goal of the
/tokens proposal (PR #48470):
- agent/context_breakdown.py: pure renderers over the existing payload —
a 5x20 glyph block grid (1 cell ~= 1% of the model window), an
'Estimated usage by category' table with free space, and expanded
per-skill / per-toolset listings via compute_context_details(), which
reuses the prompt-size attribution mechanism (skills index-line bytes +
registry tool->toolset map) converted to the same chars/4 heuristic.
- cli.py: /context [all] renders grid + category table (+ expanded
listings) from the live agent and in-memory conversation history.
- gateway/slash_commands.py: /context appends the plain-text category
table (no grid — monospace not guaranteed on messaging platforms);
/context all adds the expanded listings. Fail-open: breakdown errors
never break the gauge.
- hermes_cli/commands.py: /context gains the 'all' subcommand; /version
demoted to /hermes version on Slack to keep the 50-slash cap.
- tests: renderer unit tests against synthetic payloads, registry test,
gateway /context + /context all + failure-degradation handler tests.
- docs: slash-commands reference + CLI guide entries.
Read-only and locally computed: no provider calls, no prompt-cache impact.
Co-authored-by: RemyFevry <29257684+RemyFevry@users.noreply.github.com>
Co-authored-by: joelbrilliant <joelbrilliant1@gmail.com>
Co-authored-by: CharlesMcquade <6466275+CharlesMcquade@users.noreply.github.com>
Append a "⊙ goal 3/20" segment (turns used / turn budget) to the CLI
status bar whenever a standing /goal is active. Mirrors the desktop
composer goal indicator: active-goal-only — paused/done goals stay out
of the bar since they already print their own glyph lines in-thread.
- Snapshot: goal_active / goal_turns_used / goal_max_turns from the
cached GoalManager (in-memory attribute read, no DB hit per repaint).
- Rendered in all three width tiers of both _build_status_bar_text and
_get_status_bar_fragments, and it respects the /statusbar toggle for
free (the toggle gates _get_status_bar_fragments as a whole).
- Tests: segment composition, active-only contract, all width tiers.
Status-bar goal indicator concept from #43020.
Co-authored-by: Akshan Krithick <akshankrithick305@gmail.com>
Assisted-by: Claude Fable 5 via Hermes Agent
The default max_iterations/agent.max_turns budget was set when long
agentic runs were rare; complex tasks now routinely exceed 90 tool
calls. Raise the default to 500 across every surface that hardcodes
the fallback: AIAgent constructor, DEFAULT_CONFIG, CLI resolution
chain, gateway env bridge, cron scheduler, and TUI gateway. Explicit
user config values are unaffected (deep-merge preserves them; no
_config_version bump needed).
Docs (en + zh-Hans), CLI help text, tips, and pinned tests updated
to match.
CI slices 2 and 7 caught three tests broken by always-defer:
- /tools (CLI show_tools + TUI gateway tools.show) now passes
skip_tool_search_assembly=True — it's a discovery/inspection surface,
so users verifying an MCP installed must see deferred tools, not a
collapsed bridge row. This also fixes
test_slash_worker_mcp_discovery (profile MCP tool visible in /tools).
- test_plugins.py::test_plugin_tools_in_definitions: 'visible' becomes
'reachable' — direct schema OR listed in the bridge description;
scope-exclusion assertions unchanged (not direct AND not listed).
- test_discord_tool.py dynamic-schema-rebuild test reads the
pre-assembly list (the rebuilt schema is what tool_describe serves).
Banner/status tool counts intentionally keep the post-assembly view —
they reflect what the model actually sees.
`_prune_stale_worktrees` runs synchronously before the banner on every
`hermes -w` launch and shells out to git several times per candidate
worktree. On a repo with dozens of accumulated worktrees this dominated
startup: measured 18.5s of a 20.6s cold start, 11.6s on warm repeats.
The `git cherry` patch-equivalence probe was the single largest cost
(9.4s of 11.5s across 24 trees). It is also pure waste on repeat runs: a
tree preserved because it holds unpushed work is re-diff-hashed on every
launch, forever, always reaching the same verdict. On this repo 19 of 24
aged trees were unreapable, so ~11s per launch bought zero reaps.
Two changes, both verdict-preserving:
- Split the loop into a stat-only age filter, a parallel read-only
classification phase (thread pool, bounded to min(8, cpu_count)), and
a serial mutation phase. Only reads are concurrent; unlock/remove/
branch -D stay ordered, so log output and removal order are unchanged.
A pool failure falls back to serial rather than blocking startup.
- Memoize `git cherry` verdicts to
$HERMES_HOME/cache/worktree_merge_verdicts.json, keyed on the exact
`(base_sha, head_sha, max_ahead)` range the verdict was computed from.
Because that key is the complete input to the git call, a cache hit is
identical to recomputation by construction: if either ref moves, the
key changes and real git runs again. Bounded to 1000 entries, written
atomically, and a corrupt/hand-edited cache degrades to recomputation.
Measured on a 44-worktree repo (24 aged candidates):
_prune_stale_worktrees before 11.5s after 2.05s cold / 0.42s warm
hermes -w to banner before 13.9s after 1.79s
Work-preservation is unchanged: all 44 worktrees survived, and every
dirty/unpushed/live-locked guard still fires. Verified the 24 real-tree
verdicts are byte-identical across serial, cold-cache, and warm-cache
runs.
Tests: 75/75 in tests/cli/test_worktree.py (67 existing + 8 new). The
new cache tests were sabotage-verified — swapping the exact-sha key for a
naive path-only key makes two of them fail by deleting a worktree that
had gained unmerged work, which is the data-loss case the key prevents.
_setup_worktree read both files with the locale default encoding. On a
cp1251/GBK Windows machine a UTF-8 include list either decodes to
mojibake paths (non-ASCII entries silently not copied) or raises
UnicodeDecodeError, which the enclosing handler logs at DEBUG and
swallows — no include is copied at all, so the worktree starts without
.env/keys and the agent breaks invisibly. A Notepad BOM likewise glues
to the first include entry on every platform, and to the first
.gitignore line, defeating the '.worktrees/' membership check and
appending a duplicate entry on each run.
Read both files with utf-8-sig + errors=replace, matching the canonical
.env readers in hermes_cli/config.py (utf-8-sig because Notepad adds a
BOM) and the UTF-8 append this same block already performs on
.gitignore.
Regression tests exercise the real cli._setup_worktree: the two BOM
tests fail without the fix on any platform, the non-ASCII include test
additionally reproduces the Windows locale failure.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Builds on this PR's diagnosis by @Frowtek: a missing workdir is
ambiguous (deleted project vs. an unmounted external volume / network
share / VPN not yet up), so it's not safe evidence for a destructive
GC sweep — especially one that runs unattended at startup.
- cli.py / gateway/run.py: the startup auto-maintenance sweep now
always passes delete_orphans=False to maybe_auto_prune_checkpoints().
It still prunes by retention_days, size cap, and legacy archives —
none of which require guessing whether a project was deleted or is
just temporarily unreachable.
- hermes_cli/config.py: drop the now-unused delete_orphans default.
- hermes_cli/checkpoints.py: `hermes checkpoints prune` (the explicit,
human-invoked path) now previews the orphan project list and asks
for confirmation before deleting, unless -f/--force is passed.
- Docs updated (EN + zh-Hans) to match.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
AST-driven pass over every subprocess.run/Popen/check_output/check_call/call
with text=True (or universal_newlines=True) and no explicit encoding=:
append encoding='utf-8', errors='replace' at the kwarg site. 136 call
sites across 28 files (cli.py, hermes_cli/main.py, tools_config.py,
environments, computer_use, gateway, scripts, skills helpers, agent/*).
Together with the salvaged #55339/#60741 commits this closes out issue
#53428's bug class; the salvaged #60751 linter rule in
check-windows-footguns.py now enforces it repo-wide (verified: 807 files
scanned, zero findings).
New sessions.auto_archive / auto_archive_days config: soft-hide (never
delete) sessions with no activity for N days, aging on last activity
rather than creation so an old-but-active chat is spared. Sweeps are
throttled through state_meta and fire from CLI startup, gateway startup
+ hourly housekeeping, and the serve/dashboard backend (opportunistic
on session list + an hourly lifespan ticker), so every surface honours
one setting.
A new pinned column (declaratively migrated) exempts sessions from the
sweep; PATCH /api/sessions/{id} accepts pinned and flips the whole
compression lineage as a unit, mirroring set_session_archived.
Follow-up fixes on top of the salvaged #22566 mechanism:
- N-collector now counts only REAL actionable user turns via
_is_actionable_user_turn + _is_synthetic_compression_user_turn —
the same filter pair _find_last_user_message_idx uses post-#69291.
The contributor's bare role=='user' + _is_context_summary_content
check let blank platform echoes and continuation/todo rows consume
N slots, silently degrading the guarantee.
- Default flipped 3 -> 1 (behavior-preserving): a default of 3 was
measured to change the tail cut on transcripts whose budget covers
only the last turn. min_tail_user_messages=1 delegates to the
existing single-user anchor; N>1 is opt-in, and the call site is
gated so the default path is byte-identical to main.
- Hardened config parse in agent_init (bool rejected, fractional
floats rejected, floor 1) matching the max_attempts parser shape.
- Wired the recurring external-PR config gaps: hermes_cli/config.py
DEFAULT_CONFIG + cli-config.yaml.example (PR only had cli.py).
- Regression tests: blank echoes / synthetic rows don't count toward
N; tool-call/result pairs never split by the N-boundary (no-orphan
both directions); N-guarantee wins over tail_token_budget and the
_MAX_TAIL_MESSAGE_FLOOR (floor is a minimum, not a cap); default
parity pin; DEFAULT_CONFIG pin.
Add _ensure_last_n_user_messages_in_tail to guarantee the last N user
messages survive compression in the uncompressed tail, with surrounding
assistant/tool context preserved.
- Add min_tail_user_messages parameter (default 3) to ContextCompressor
- New _ensure_last_n_user_messages_in_tail method generalizes single-user protection
- Skip context-summary handoff banners when counting user messages
- User messages are clean boundaries — skip _align_boundary_backward
- Wire through cli.py, agent_init.py, and gateway cache busting keys
Config:
compression:
min_tail_user_messages: 3
Co-Authored-By: Claude <noreply@anthropic.com>
hermes chat -Q -m moa:strategy failed with 'model moa:strategy is not
supported' (HTTP 401/400): the raw model string was passed straight to
the real provider. The MoA virtual provider only got wired up through the
interactive /moa command and the model picker, never through the -Q
one-shot startup path.
resolve_runtime_provider already handles requested_provider == 'moa', and
agent_init builds the MoAClient off provider == 'moa' (surface-agnostic).
The only gap was mapping the moa:<preset> model string to that provider.
Add _normalize_moa_model() and apply it in HermesCLI.__init__ before
provider resolution: a moa:<preset> model sets requested_provider='moa'
and model=<preset>, so the existing MoA path runs in non-interactive mode
too. The moa: prefix wins over an explicit --provider (previously
--provider deepseek -m moa:strategy silently dropped MoA).
Fixes#56828
The bare truthiness test on _compression_skipped_due_to_lock is fooled
by MagicMock auto-attributes on test-double agents (skill pitfall:
MagicMock defeats hasattr/truthiness duck-typing) — the type-ahead CLI
test's MagicMock agent took the lock-skip branch and skipped the
transcript commit. Real values are None/True/holder-string; pin the
check to 'is True or isinstance(str)' at all three consumer sites.
Follow-up to the salvaged #57634 commits:
- agent/manual_compression_feedback.py: new describe_compression_lock_skip()
— single source of truth for lock-skip wording. A descriptive holder
string means another compressor CONFIRMED holds the lock ('already in
progress (holder: ...)'); True/None means acquisition failed without a
confirmed holder (hermes_state.try_acquire_compression_lock catches
sqlite3.Error internally and returns False), so the message says
'could not acquire ... the lock check failed' instead of falsely
claiming a concurrent compression is running.
- cli.py, gateway/slash_commands.py, tui_gateway/server.py (all three
in-process consumers: session.compress RPC, command.dispatch compress
branch, slash.exec mirror) now route through the shared helper.
- tui_gateway/server.py command.dispatch compress branch: catch
CompressionLockHeld explicitly — it previously fell into the generic
'compress failed' error handler.
- Deferred-notify contract (#69324): lock-skip discards the pending
context-engine notification (committed=False) in _compress_session_history
and the CLI path before returning.
- tests: lock-skip wording pins per surface, VISIBLE_COMPRESSION_MESSAGES
noise-filter carve-outs for both wordings, MagicMock signal opt-outs for
sibling tests added on main after the original PR.
Six spawn sites reachable from the desktop GUI / TUI gateway lacked CREATE_NO_WINDOW, so a windowless parent (pythonw/Electron) flashed a conhost per spawn: cli.exec RPC, quick-commands exec dispatch, and shell.exec RPC in tui_gateway/server.py; the CLI REPL quick-commands exec in cli.py; and the per-session provider transports in agent/copilot_acp_client.py and agent/transports/codex_app_server.py (Popen, hide-only so PIPE stdio stays intact).
All use hermes_cli._subprocess_compat.windows_hide_flags() (no-op on POSIX), matching the pattern already used at three other sites in tui_gateway/server.py. Deliberately hide-only — no detach flags, no Electron changes (per the #54220 revert history).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The startup pruner only considered directories named hermes-* (the
hermes -w scratch trees), so salvage/review/port lanes created with raw
'git worktree add' accumulated forever — a real checkout reached 117
directories / 26 GB with trees dating back months. Two further leaks:
squash-merged branches' local commits stay unreachable from
refs/remotes/* forever, so the unpushed-commits guard preserved fully
merged scratch trees indefinitely; and preserved trees rotted silently
with no visibility.
- Pruner now covers every directory under .worktrees/ except kanban
task trees (t_<hex>, owned by 'hermes kanban gc'). Named (non
hermes-*) trees get a 3x timeline (72h soft / 9d hard) since they
were created deliberately.
- New _worktree_commits_all_merged_upstream(): git-cherry
patch-equivalence check against origin/HEAD|main|master, bounded at
20 commits ahead, fails safe toward preserve. Lets the pruner reap
trees whose every local-only commit already landed upstream via
squash-merge/cherry-pick.
- Dirty guard now applies at every tier (previously the 24-72h tier
skipped it — it only survived because the unpushed check usually
caught the same trees).
- Trees preserved for unpushed/dirty reasons older than 7 days are
listed in a single WARNING so in-flight work can't rot silently.
- tips.py text updated; 13 new behavior-contract tests.
* test(clarify-gateway): cover signature, timeout fallback, and notify paths for 100% coverage
Fixes#36531
(cherry picked from commit 5265dfe2f5)
* fix(clarify): one canonical timeout across CLI, TUI/desktop, and gateway
The clarify wait timeout was resolved three different (wrong) ways:
- CLI (`cli.py`, `hermes_cli/callbacks.py`) read a non-existent top-level
`clarify.timeout`, so it always fell through to a hardcoded 120s instead of
the canonical `agent.clarify_timeout` (default 3600) the gateway uses (#42969).
- The TUI/desktop bridge called `_block("clarify.request", …)` with no timeout,
so it used the hardcoded 300s `_block` default and ignored config (#51960).
- There was no way to disable the auto-skip: a user who wanted the agent to wait
indefinitely while they think couldn't get it.
Collapse all of this onto a single resolver:
- `tools.clarify_gateway.resolve_clarify_timeout(config)` is the one source of
truth. Order: explicit legacy `clarify.timeout` (back-compat) → canonical
`agent.clarify_timeout` → 3600. `<= 0` is preserved verbatim as "unlimited".
- CLI, callbacks, and the TUI bridge (`_clarify_timeout_seconds`) all route
through it, so the three surfaces can't drift.
- `<= 0` means unlimited everywhere: `wait_for_response` and `_block` drop the
deadline (heartbeat still fires), and the CLI hides its countdown.
Tests: resolver order / default / non-numeric / unlimited-sentinel; an
unlimited `wait_for_response` blocks until resolved rather than auto-skipping;
the TUI clarify bridge passes the configured timeout to `_block`.
Supersedes #42974 (CLI key), #51993 (TUI honors config), and #68986 (unlimited
wait); folds in #52031 (clarify_gateway coverage).
Co-authored-by: liuhao1024 <liuhao1024@users.noreply.github.com>
Co-authored-by: lkevincc0 <lkevincc0@users.noreply.github.com>
Co-authored-by: theone139344 <theone139344@users.noreply.github.com>
Co-authored-by: baauzi <baauzi@users.noreply.github.com>
---------
Co-authored-by: Christopher-Schulze <210261288+Christopher-Schulze@users.noreply.github.com>
Co-authored-by: liuhao1024 <liuhao1024@users.noreply.github.com>
Co-authored-by: lkevincc0 <lkevincc0@users.noreply.github.com>
Co-authored-by: theone139344 <theone139344@users.noreply.github.com>
Co-authored-by: baauzi <baauzi@users.noreply.github.com>
Classic /model confirmation already threaded custom_providers into the
context display, but the shrink-warning path did not. Probe-down then
matched the hardcoded "qwen" catalog (131072) and falsely warned that a
1M custom endpoint had shrunk — while the status bar still showed 1M.
Pass the same fresh inventory list used by switch_model/TUI (with
agent-snapshot fallback), and fall back to agent._custom_providers inside
merge_preflight_compression_warning when the kwarg is omitted.
compression.enabled: false is documented (agent/conversation_loop.py
overflow path) as disabling *automatic* compaction only — the terminal
context-overflow error explicitly tells users to run /compress manually,
and the gateway handler has never gated on the flag. But the classic CLI
(_manual_compress) and the ACP adapter (/compact) refused with
'Compression is disabled', leaving users at a full context with no
manual escape hatch on those surfaces.
Remove the stale gates (they predate the overflow-path design; the CLI
gate came from the original /compress commit's boilerplate) and unify
force=True across all manual-compaction call sites: ACP /compact and the
TUI's _compress_session_history (manual-only helper) now bypass the
summary-failure cooldown exactly like the CLI and gateway already did.
Also reword the ACP /context status line so a disabled-compression agent
no longer implies /compact is unavailable.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Pin a provider-agnostic "Out of credits" panel after a billing-classified turn so
the one recovery action (Nous → /topup, other providers → their billing page)
stays visible instead of scrolling away as prose.
CLI: the VAD monitor's playback cut and the record-key interrupt both
mark the latch; the chat path prepends the note via the existing
_prepend_note_to_message channel. TUI: _tts_stream_stop() grows a
user_barge flag (False for /voice off — a mode change isn't an
interruption; no-op when speech already finished), the VAD monitor
marks on cut, prompt.submit accepts interrupted:true from clients, and
_run_prompt_submit pops the latch into the run message.
The streaming gate broadens from ElevenLabs-only to any provider that
passes check_tts_requirements(). In continuous voice mode a mic monitor
runs during playback: talking over the agent cuts TTS at detection
while the monitor keeps recording, then the captured interruption is
transcribed and queued as the next turn (process_loop's auto-restart
stands down while the capture owns the mic). New voice.barge_in config
key, default true.
The default `busy_input_mode: interrupt` now redirects the live turn instead
of hard-stopping it and re-queuing a fresh turn, wired consistently across
every first-party surface via the shared core primitive.
- CLI, gateway (busy + PRIORITY paths), TUI (`_handle_busy_submit`), desktop
(`session.redirect` RPC), and ACP call `redirect()` when the agent advertises
`_supports_active_turn_redirect`, and fall back to the proven interrupt +
next-turn queue for older runtimes.
- Redirect is gated to plain text with no attachments: captioned or
attachment-bearing events (including adapters that classify unknown media as
`TEXT`) stay queued so media is never dropped.
- ACP `cancel()` records the interrupted prompt, sets its cancel event, and
hard-stops the agent while holding `runtime_lock`, closing the
cancel-then-correct ordering gap; connection I/O happens after the lock is
released.
- Desktop appends the correction as a real user transcript message so the live
view matches the durable history after reload.
- `/busy` help, onboarding hints, and the new `session.redirect` RPC describe
the redirect behavior; `/stop` remains the hard stop.
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.
Salvage follow-up for PR #68899:
- Restore .rstrip('/') on base_url in _swap_credential (both anthropic
and OpenAI paths) to match every other assignment site. The route
identity comparison still uses normalize_route_base_url which handles
trailing slash correctly.
- Extract should_clear_context_pin() into hermes_cli/route_identity.py,
consolidating 7 copy-pasted call sites across cli.py, gateway/run.py,
gateway/slash_commands.py, and hermes_cli/model_switch.py into a
single fail-closed helper.
C1 (anthropic path TLS re-application): pre-existing gap — the Anthropic
adapter (build_anthropic_client) has no TLS customization support at
all, so this is out of scope for this salvage.