hermes-agent/hermes_state.py
Teknium 9acc4b47f5
perf(state): external-content FTS + tool-row-free trigram index (schema v23) (#65798)
* fix(desktop): refresh repo status on session switch with unchanged cwd (#68208)

fix(desktop): refresh repo status on session switch with unchanged cwd

* fix(checkpoints): honor gateway config and task cwd (#68195)

* fix(gateway): wire checkpoint config into agents

* fix(checkpoints): resolve gateway file paths by task cwd

* ci: live-updating PR review comment with structured job statuses

Replace the static comment-pending + comment-results two-job pattern
with a live-updating comment system that polls the GitHub Actions API
every 15s, re-assembles the review comment from whatever results are
available, and upserts it via the <!-- hermes-ci-review-bot --> marker.
The comment updates in real time as each job finishes — no waiting for
the full pipeline.

Every CI job that wants to appear in the review comment emits a
review_status output — a JSON array of objects, each with a source
and a results array:

    [
      {
        "source": "review-label-gate",
        "results": [
          {"kind": "action_required", "title": "...", "summary": "...",
           "how_to_fix": "..."},
          {"kind": "info", "title": "...", "summary": "..."}
        ]
      },
      {
        "source": "ci timing",
        "results": [
          {"kind": "warning", "title": "CI timings", "summary": "...",
           "detail": "...", "link": "..."}
        ]
      }
    ]

One job can emit multiple results of different kinds. The source field
is used to exclude the corresponding job from the synthesized error
list (case-insensitive, hyphen-normalized matching against GitHub
Actions job display names).

| job                        | source                   | kind (on failure)         | section              |
|----------------------------|--------------------------|---------------------------|----------------------|
| review-labels              | review label gate        | action_required / info    | Action required      |
| lockfile-diff              | lockfile-diff            | action_required           | Action required      |
| ci-timings                 | ci timing                | warning / info            | Warnings             |
| supply-chain scan          | supply chain             | error / (none)            | Job failures         |
| supply-chain dep-bounds    | supply chain             | action_required / (none)  | Action required      |
| osv-scanner                | osv scan                 | warning / (none)          | Warnings             |
| uv-lockfile-check          | uv.lock check            | action_required / (none)  | Action required      |
| history-check              | unrelated histories      | action_required           | Action required      |
| contributor-check          | contributor attribution  | action_required           | Action required      |

Jobs that find nothing emit [] (empty array) — no noise info items.

A single comment-live job polls the GitHub Actions API every 15s,
classifies jobs into (completed, pending), assembles the comment, and
upserts it. Merges review_status outputs from all needs jobs via
toJSON(needs.*.outputs.review_status), and downloads the ci-timings
artifact when it becomes available. Shows commit SHA + message below
the header.

The assembler has ZERO job-specific knowledge. It just:
1. collect_from_statuses() — flattens all nested status objects into ReviewItems
2. collect_failed_jobs() — synthesizes errors for failed jobs with no declared status
3. _attach_job_urls() — fills in per-job log links for ALL items
4. render_comment() — groups by severity, renders with group headers

Each item shows links inline next to the title: View report (job-emitted
URL) and View job (auto-attached logs link). Each info item is its own
collapsible <details> block.

    # ૮ >ﻌ< ა ci review

    running on abc1234 — commit message first line

    ##  Job failures
    ### {title} · [View job](url)
    {summary}

    ## ⚠️ Action required
    ### {title} · [View job](url)
    {summary}
    **How to fix:**
    {how_to_fix}

    ## ⚠️ Warnings
    ### {title} · [View report](url) · [View job](url)
    {summary}
    {detail}

    <details><summary>{title}</summary>
    {content}
    </details>

    Still running 3 jobs: ci-timings, docker

- test_assemble_review_comment.py (48 tests): collect_from_statuses,
  collect_failed_jobs with exclude_sources, _attach_job_urls,
  render_comment (group headers, inline links, commit info, per-item
  details, pending footer), assemble integration
- test_live_comment.py (16 tests): classify_jobs pure function
- test_timings_report.py (10 tests): generate_review_status nested format
- test_lockfile_diff.py (6 tests)
- test_classify_changes.py (32 tests, pre-existing)

* ci: migrate AUTOFIX_BOT_PAT to GitHub App token

Replace the long-lived fine-grained PAT (AUTOFIX_BOT_PAT) with short-lived
(1-hour) installation access tokens minted via a new get-app-token composite
action wrapping actions/create-github-app-token@v3.2.0.

The PAT was used in 13 spots across 8 workflow files for gh CLI / GitHub API
calls. The per-repo GITHUB_TOKEN (1,000 req/hr) was getting rate-limited when
multiple workflows fire concurrently (deploy-site, skills-index, ci-timings,
supply-chain-audit, js-autofix). App installation tokens get 5,000 req/hr
per installation and are scoped to the App's permissions, not a user account.

New composite action: .github/actions/get-app-token/
  - Wraps actions/create-github-app-token@bcd2ba49 (v3.2.0, SHA-pinned)
  - Reads APP_ID + APP_PRIVATE_KEY repo secrets
  - Outputs a 1hr installation token via steps.app-token.outputs.token

Requires two new repo secrets (set after creating the GitHub App):
  - APP_ID: the App's numeric ID
  - APP_PRIVATE_KEY: the PEM private key

App installation permissions needed:
  contents: write    (js-autofix push, pypi release upload)
  pull-requests: write (js-autofix PR create/merge, supply-chain comment)
  issues: write       (skills-index-freshness issue creation)
  actions: write     (skills-index workflow trigger)
  workflows: write   (skills-index triggers deploy-site.yml)

The AUTOFIX_BOT_PAT secret can be deleted once CI passes on this PR.
The comment in js-autofix.yml noting that PAT pushes trigger downstream
workflows is updated — App tokens have the same property (they are not
GITHUB_TOKEN), so the concurrency-cancel loop logic is unchanged.

* style(desktop): satisfy merged eslint/prettier config

The SSH modules predate the stricter lint config that landed on main (curly, no-empty, perfectionist sorting, prettier). Mechanical lint:fix + fmt pass, empty catch blocks filled with the codebase's void-0 convention, and inline no-control-regex disables on the three deliberate control-char patterns (same pattern as lib/ansi.ts).

* fix(ci): pass App secrets as inputs to composite action

Composite actions cannot access the secrets context — the runner's
template engine rejects secrets.* references at load time with
'Unrecognized named-value: secrets'.

Move APP_ID and APP_PRIVATE_KEY from direct secrets.* references inside
the composite action to inputs passed by each calling workflow. The
fallback logic (GITHUB_TOKEN when APP_ID is empty, for fork PRs) stays
in the composite action's check step.

* fix(ci): add detect to all-checks-pass needs so its failure blocks merge

If detect fails, all downstream sub-workflows get SKIPPED (they have
needs: detect). all-checks-pass used if: always() and only checked the
sub-workflows — which all showed as 'skipped' (= success) — so it passed
even though the root cause (detect) failed. This made the PR mergeable
despite a broken CI pipeline.

Add detect to all-checks-pass needs so its failure propagates to the
gate job and blocks the merge.

* fix(desktop): bump skills test timeout to fix cold-start flake (#68235)

Test 1 in skills/index.test.tsx pays the full cold-start cost (jsdom env
init + module transform + the @/hermes/@/store/profile import graph),
which pushed past vitest's 5000ms default under load — caught at 8871ms
on one run, 6.6s pure test time on another. Tests 2-4 are ~30-130ms
each because all that setup is already cached, so only test 1 was at
risk of timing out.

Bump the describe-level timeout to 15s. Verified with 10 consecutive
runs, 4 of which took 5.5-6.6s of test time and would have hard-failed
under the old 5s default.

* feat(desktop): open multiple full app windows (electron)

Add createInstanceWindow() — a full-chrome peer of the primary that
renders the complete app (sidebar, routing, its own draft) against the
shared backend, so several GUI windows can run at once. Mirrors the
primary's window options + chatWindowWebPreferences (backgroundThrottling
stays off so a streamed answer never stalls when blurred) but never
overwrites the mainWindow global and doesn't respawn the backend — the
renderer's getConnection() joins the running one. New windows cascade off
their source via the pure, tested instanceWindowBounds().

Exposed via the hermes🪟openInstance IPC and a "New Window" File
menu item. Per-window fullscreen state now targets the window itself, and
titlebar/native-theme repaints reach every open chat window instead of
only the primary.

Retires the now-orphaned compact new-session pop-out (its only caller was
⌘⇧N, repointed in the follow-up commit): drops createNewSessionWindow,
the hermes🪟openNewSession handler, and the newSession/new=1 URL
flag.

* feat(desktop): wire New Window to ⌘⇧N + command palette

Repoint session.newWindow (⌘⇧N) from the compact new-session pop-out to
openNewWindow(), which opens a full peer instance via the new openWindow
bridge, and add a "New Window" entry to the ⌘K palette (shown with its
hotkey hint, gated on canOpenNewWindow()). Relabel the action "New window".

Drops the retired openNewSessionWindow bridge and the vestigial
isNewSessionWindow()/new=1 flag; renames the shared opener helper.

* fix(desktop): de-dupe cross-window cues so peers don't spam

With multiple full windows, each renderer independently reacts to the
same backend event, so one-shot cues fired N times: OS notifications
(the per-renderer throttle can't see other windows), the turn-end sound
(playCompletionSound runs on every message.complete, ungated by focus),
and auto-spoken replies (double voice when a chat is open in two windows).

Add a single race-free owner in the main process (electron/event-dedupe.ts):
main handles IPC serially, so the first window to claim a key within a
short window wins and peers stay quiet. Notifications collapse at the
hermes:notify choke point; the sound and spoken replies claim via a new
hermes:ambient:claim IPC (keyed by session / reply id). Off Electron the
claim falls back to "emit", preserving single-window behavior.

The sound's mute check runs before the claim so a muted window can't win
the cue and silence an audible peer.

* refactor(desktop): tidy the cross-window deduper

Drop the unused DEDUPE_WINDOW_MS export and rename its interval so
"window" isn't overloaded against BrowserWindow in a multi-window
feature (windowMs → intervalMs). DRY the completion-sound play path.
No behavior change.

* nix: add cage to devDeps

* fix(desktop): avoid false remote gateway reauthentication (#68250)

* fix(desktop): avoid false remote gateway reauthentication

Co-authored-by: Rod-fernandez <rodrigo@nxtlevelsaas.com>
Co-authored-by: David Andrews (LexGenius.ai) <david@lexgenius.ai>

* fix(desktop): harden remote revalidation state

---------

Co-authored-by: Rod-fernandez <rodrigo@nxtlevelsaas.com>
Co-authored-by: David Andrews (LexGenius.ai) <david@lexgenius.ai>

* fix(desktop): keep composer draft across compression tip rotation (#68079)

* fix(desktop): keep composer draft across compression tip rotation

Auto-compression swaps the live stored session id while the user may still
be typing. Scope the composer/queue key on the lineage root and migrate any
tip-keyed draft/queue entries onto that durable key when the tip rotates so
the in-progress prompt does not vanish when the response lands.

* test(desktop): cover draft survival across compression tip rotation

Add regression coverage for migrateSessionDraft, lineage-scoped composer
keys, and the rotation path that previously wiped an in-progress draft.

* fmt(js): `npm run fix` on merge (#68305)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* fix(desktop): Stop parks the queue instead of firing the next queued prompt

Interrupting a busy turn with the Stop button (or Esc) settles the
session to idle, and the edge-independent auto-drain immediately submits
the head of the composer queue. The user pressed Stop to halt the agent,
but it looks like Stop skipped the current turn and kept going — and the
queued text is hard to find, since its only surface is the collapsed
'N queued' pill above the composer.

The old userInterruptedRef latch (a23728dcc) fixed this but was removed
in #40221 because it also suppressed the drain that send-now-while-busy
depends on. This reintroduces the halt with source awareness instead of
a blanket latch:

- Explicit halts (Stop button, composer Esc, chat-focus Esc, the
  streaming message's hover Stop, runtime cancel) park the session's
  queue before interrupting. Parked queues are skipped by both
  auto-drain paths (mounted ChatBar + background drainer).
- Interrupts that exist to advance the queue (send-now-while-busy)
  unpark first, so the settle drain they rely on still flows.
- The park lifts on any renewed intent: resume, a manual drain (Enter
  on empty composer or the per-row send arrow), queueing a new prompt,
  or emptying the queue. It migrates with entries on a runtime re-key
  and is deliberately not persisted (a fresh process starts unparked).
- The queue panel expands on park, switches to 'N Queued — paused' with
  a pause icon, and grows a Resume action, so the held prompts are
  visible instead of reading as vanished.

Store contract, hook wiring, and background-drain coverage included;
docs updated.

* fix(cli,tui): recall real paste content on up-arrow

Large pastes collapse to a placeholder in the composer, but input history
stored the placeholder — so up-arrow recall showed a truncated reference
(CLI) or lost the content entirely (TUI, where the `[[…]]` label has no
backing snip after submit).

Store the expanded content in history instead:
- CLI: `_inline_pastes()` expands `[Pasted text #N -> file]` into the buffer
  before `reset(append_to_history=True)`; also reused by the external editor
  (dedup). History nav suppresses re-collapse of recalled content.
- TUI: `dispatchSubmission` pushes `expandSnips(pasteSnips)(full)`; idempotent
  on label-free text so re-submitting a recalled entry stays stable.

* fix(cli): suppress CPR on POSIX local TTYs under load

Delayed ESC[6n replies leak as ^[[row;colR into the classic CLI on
SSH/slow PTYs (#13870) and on local POSIX TTYs under heavy subagent
load. Suppress CPR on non-Windows platforms (layout hint only); keep
native Windows on prompt_toolkit's default pending native coverage.
Wire selection through _select_classic_cli_pt_output.

* test(cli): prove local CPR leak and Application CPR-disabled wiring

Add a delayed-CPR PTY harness (no SSH) plus selection/Application
assertions for POSIX local and Windows preserve-default. Update the
gating unit test to the new contract.

* refactor: drop platform kwarg, fix PTY test cleanup

- Remove redundant platform= test seam from _terminal_may_leak_cpr();
  use monkeypatch.setattr(sys, 'platform', ...) consistently in both
  test files.
- Wrap PTY tests in try/finally for fd cleanup on assertion failure.
- Guard select.select() in terminal thread against OSError after fd
  close (fixes PytestUnhandledThreadExceptionWarning).
- Trim PR-number reference from test module docstring.

* docs(portal): remove retired Nous Chat references

* fix(web/ddgs): isolate DuckDuckGo search in a disposable process

ThreadPoolExecutor timeouts cannot fire when primp holds the GIL in
native code (#68096). Run each search in a child process the parent can
terminate/kill, and honor tools.interrupt between polls.

* test(web/ddgs): cover GIL-hold timeout, interrupt, and worker reap

Regression tests for #68096: native GIL-hold and sleep hooks must time
out or interrupt promptly with no orphaned search workers.

* fix: sanitize subprocess env for DDGS worker

os.environ.copy() passes all Hermes secrets (gateway tokens, API keys,
dashboard session tokens) into the DDGS child process. Use
_sanitize_subprocess_env() to strip Hermes-managed secrets before
spawning the worker.

* fix(agent): pass persisted-prefix boundary when rotation flushes on cold resume (#68196)

The legacy rotation branch in agent/conversation_compression.py flushes the
current turn to the OLD session before ending it (#47202) via
_flush_messages_to_session_db(messages) with no conversation_history boundary.

On the first turn after a cold Desktop resume, the restored transcript rows
live in the message list as plain dicts that have not yet been stamped with
_DB_PERSISTED_MARKER — the normal turn flush that stamps them runs after
preflight compression. With no boundary, _flush_messages_to_session_db builds
an empty history_ids set and treats every restored row as new, durably
re-appending the whole transcript to the parent session. Repeated
restart/resume + threshold compression keeps growing the parent transcript.

Pass messages[:_persist_user_message_idx] (the already-durable prefix that
turn_context anchors before preflight runs, guarded for int/bounds) as
conversation_history so the flush skips the persisted rows by identity and
writes only the current turn's new messages.

Adds a regression test that pre-populates SQLite, cold-loads the transcript,
appends one current user row, and forces rotating compression: it fails before
this change (parent grows to 5 rows) and passes after (parent holds the two
originals plus the single new turn).

* fix(desktop): prevent contentEditable composer input from visually collapsing to near-zero height

Fix #68095

The composer input box (contentEditable div) randomly shrank to a tiny/pixelated
size when typing character-by-character (paste worked fine). Root cause: during
per-keystroke input, the normalizeComposerEditorDom cleanup could briefly leave
the contentEditable with zero child nodes, and without intrinsic content the
browser collapsed it visually despite the CSS min-height.

Two-pronged fix:
1. Add min-h-[1.625rem] bracket syntax alongside the CSS variable min-height
   to ensure the minimum height is enforced even if the CSS variable resolution
   is delayed or overridden by browser defaults.
2. In normalizeComposerEditorDom, ensure the contentEditable always has at
   least one <br> child when empty, giving it intrinsic height that the browser
   cannot collapse. This is a belt-and-suspenders approach with the CSS min-height.

Closes #68095

* fix(agent): circuit-break AttributeError from commit-splice and detect code skew

Fix #68178

The git-install auto-updater rewrites source while the desktop backend
is live. Because agent/conversation_loop.py is imported lazily on the
first API call, a process can end up running two different commits
spliced together — one commit's AIAgent against another commit's
conversation_loop. When the interface differs, every turn fails
permanently with an AttributeError, and the loop retries indefinitely,
burning provider API calls (576 failures, 149 wasted API calls observed).

Three-prong fix:

1. Circuit-break AttributeError on agent objects: the outer-loop error
   classifier now detects AttributeError targeting agent/run_agent
   modules and breaks immediately instead of continuing the retry loop.

2. Code skew detection for desktop/serve backend: run_agent.py now
   snapshots the checkout revision at import time and exposes a cheap
   per-iteration check that the conversation loop uses to refuse new
   work with a clear 'restart required' message before the lazy import
   can crash.

3. Informative error message: when code skew is detected, the user
   gets a clear explanation of the mismatch (boot revision vs current
   revision) and actionable guidance to restart the application.

* fix(telegram): preserve fatal recovery handoff

Release the current polling-recovery task's ownership before invoking
the fatal-error handler. The runner bounds adapter cleanup in a child
task; disconnect() cancels the tracked polling-recovery task, so
retaining the current notifier in _polling_error_task would cancel the
fatal callback before the runner can finish its reconnect-queue or
shutdown decision.

The new _handoff_polling_fatal_error() helper clears
_polling_error_task only when it is the current notifier. Other
recovery tasks remain tracked and are still cancelled and awaited
during teardown.

Covers both network retry exhaustion and polling-conflict exhaustion.
Replaces the misleading "Restarting gateway" message with "Escalating
to gateway recovery".

Fixes #68406.

* fix(telegram): widen fatal handoff to heartbeat watchdog path

The wedged-recovery heartbeat watchdog (line 2526) calls
_notify_fatal_error() directly from the heartbeat task. disconnect()
cancels _polling_heartbeat_task unconditionally (no current_task guard,
unlike _polling_error_task). Same bug class as #68406: the child
disconnect cancels the heartbeat parent before the runner can queue
reconnect.

Widen _handoff_polling_fatal_error() to also clear
_polling_heartbeat_task when it is the current task, and route the
heartbeat watchdog call site through the handoff helper.

Co-authored-by: Imgaojp <6065749+Imgaojp@users.noreply.github.com>

* fix(tests): make the live-system-guard canary fail closed

tests/test_live_system_guard_self_test.py executes real kill primitives
(os.kill(-1, SIGTERM), os.killpg, pkill -f python) and depends entirely on
the autouse _live_system_guard fixture in tests/conftest.py to intercept
them. That makes the canary fail-OPEN: in any collection context where the
file is present but its home conftest is not — a published sdist that ships
tests/ but not tests/conftest.py, a tree assembled by copying test*.py (that
glob does not match conftest.py), pytest --noconftest, or a foreign rootdir —
the primitives fire for real, and os.kill(-1, SIGTERM) SIGTERMs every process
the invoking user owns (a full desktop-session kill was reported in the field).

Add an autouse fixture that refuses to run any canary test unless the guard is
provably active. The one thing the canary can detect about its own safety is
that the guard monkeypatches os.kill with a plain Python function, whereas the
unguarded primitive is a C builtin — so the probe keys off that. Tests marked
@pytest.mark.live_system_guard_bypass still opt out, matching the guard's own
bypass contract (e.g. test_bypass_marker_disables_guard). With the guard loaded
every canary test behaves exactly as before; without it each test refuses at
setup with zero side effects.

Fixes #68311

* fix(billing): rename user-facing "terminal billing" copy to Remote Spending (#68355)

* fix(billing): rename user-facing "terminal billing" copy to Remote Spending

The capability was renamed Remote Spending on the portal (consent CTA:
"Allow Remote Spending"; per-terminal states Granted/Stopped), but the
terminal, desktop, and docs still said "terminal billing" everywhere.

- Feature name: Remote Spending in titles/labels, lowercase mid-sentence.
- Step-up action verb is now "allow", matching the portal consent CTA.
- Kill-switch-off recovery copy points at the actual control ("a billing
  admin can turn it on from the portal's Hermes Agent page") instead of
  the dead-end "manage it on the portal".
- Per-terminal revoke copy uses the portal vocabulary ("stopped").
- Wire identifiers (cli_billing_enabled, cli_billing_disabled, ...) are
  unchanged; copy, comments, docs, and test expectations only.

* fix(billing): correct the post-step-up denial diagnosis + finish the desktop rename

Adversarial review findings: (1) a repeated insufficient_scope after a
successful step-up is a per-terminal authorization failure, but the copy
blamed the org kill-switch and pointed at the wrong recovery control —
now: "Remote Spending still isn't active for this terminal — the
authorization didn't take. Retry, or make this change on the portal."
(2) the desktop step-up flow started in Remote Spending vocabulary but
finished in "billing management access" — renamed both end states.
(3) prettier formatting on the touched files (matches the post-merge
fmt bot).

* feat(tui): show the plan catalog in /subscription on Free (#68357)

* feat(tui): show the plan catalog in /subscription on Free

The server returns the tier list even with no subscription, but the
overlay hid the picker behind can_change_plan && !isFree, so a Free
account got only "Start a subscription" with no idea what the plans
cost. Now:

- Overview on Free offers "Choose a plan" whenever the catalog has
  enabled paid tiers.
- The picker on Free lists each plan as name · price · monthly credits
  (no upgrade/downgrade hints — there is nothing to move from), and
  picking one opens the portal, where starting a subscription actually
  happens (card capture + checkout live there; the upgrade RPC requires
  an existing subscription).
- Paid-plan behavior (preview → confirm → apply) is unchanged.

* refactor(tui): compute the picker row suffix once

Review feedback: the isFree fork duplicated the label template and run
handler; only the suffix differs.

* fix(tui): arm the busy guard before the Free portal handoff

Adversarial review: the Free branch returned before setting busyRef, so
a double-Enter could open the portal twice; and the picker narrated a
handoff that openManageLink already narrates (duplicate on success,
contradictory on failure). Guard first, let the helper do the talking.

* fix(tui): monthly credits are dollars — label them as such

The Free picker showed "1000 credits/mo" for what is $1,000 of monthly
credit — render "$1,000 credits/mo" (grouped, dollar-signed).

* feat(tui): render the Free-plan catalog inline in the /subscription overview

Sid ruling: the upsell belongs where the user already is — no
intermediate "Choose a plan" hop. On Free the overview lists each paid
plan (name · $/mo · $credits/mo) as a pickable row; picking opens the
portal (openManageLink narrates). The generic "Start a subscription"
row survives only when the catalog is empty. The picker reverts to its
original change-only form (Free never reaches it).

* feat(desktop): tier catalog chips on the Subscription row

Desktop parity with the TUI inline catalog (Sid ruling): accounts that
can act see the plans where they already are — Free gets the upsell
list (every chip opens the portal), a subscriber sees all tiers with
the current one marked inert. Members and team contexts see no chips.
Chips learn an optional url (portal handoff) in the shared row model.

* chore(tui): fixture harness mirrors the live tier catalog

The dev screenshot fixtures showed invented plans ($50 Super / $99
Ultra, "1,000 credits"); align with the real catalog ($20/$100/$200
with $22/$110/$220 monthly credits) so fixture renders cannot be
mistaken for product truth. The overlay itself always reads tiers from
the subscription API.

* chore: trim narration comments

* fmt(js): `npm run fix` on merge (#68462)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* fix(relay): attach metadata.user_id on guild replies for egress fallback (#68320)

The relay adapter re-attaches an egress discriminator on outbound replies
so the connector can resolve the owning tenant. It captured scope_id for
scoped (guild) messages and user_id for DMs, but as MUTUALLY EXCLUSIVE:
a scoped inbound hit an early return, so the author's user_id was never
recorded, and _with_scope only attached user_id when there was no
scope_id. Guild replies therefore went out with scope_id only.

That's fine while the guild has a provision-time route row. But a MANAGED
Discord agent joins guilds dynamically (the shared bot is added to /
removed from servers at runtime), and GATEWAY_RELAY_ROUTE_KEYS — the only
thing that writes guild route rows — is a self-hosted, static field never
stamped for managed agents. So their guild has no route row, the
connector's guild-route lookup misses, and with no user_id on the frame
there's nothing to fall back to → every guild reply is declined
"discord egress declined: target not routed to an onboarded tenant"
even though INBOUND resolved the same guild fine (via the author-first
SharedSocketRouter.targets() fallback).

Fix: capture the authentic author user_id for EVERY inbound (DM and
scoped alike) and re-attach it on the outbound reply alongside scope_id.
The connector consults it only on a route/scope miss, so carrying both
never overrides routing-table resolution. This is the gateway half of the
paired gateway-gateway change (makeDiscordTenantOf guild-route-miss
author-binding fallback); together they make guild replies resolve the
same observed-author way inbound already does.

Tests (tests/gateway/relay/test_relay_adapter.py): a guild reply now
carries both scope_id AND user_id; a scoped inbound with no author still
yields scope_id only (never invents one). Verified fail-without /
pass-with.

* build: declare pywin32 as a direct win32 dependency

hermes_cli/windows_ssh_runtime.py imports win32security/win32file/etc.
directly but pywin32 only arrived transitively via concurrent-log-handler
-> portalocker. Declare it with a sys_platform gate so the Windows SSH
runtime doesn't depend on the logging dep chain. Review follow-up on
PR #68130.

* fix(desktop): preserve dragging with empty titlebar slots

* Revert "fix(agent): circuit-break AttributeError from commit-splice and detect code skew"

This reverts commit 3a9b9d65d5.

* fix(context): revalidate Codex OAuth context windows

* test(context): document Codex cache persistence coverage

* fix(context): scope Codex catalogue cache by credential

* test(context): cover Codex context rollback

* fix(compression): report live-resolved Codex window in the autoraise notice

The autoraise banner hardcoded '272K' for the gpt-5.4/5.5/5.6 family, but
the Codex /models catalog is authoritative and shifts server-side (gpt-5.6
served 372K during July 9-18, 2026 before OpenAI rolled it back). Pass the
compressor's live-resolved context_length through so the notice reports the
window the session actually got; the static 272K/128K text remains as the
fallback when no resolved value is available.

* fix(codex): send ChatGPT-Account-Id on /models probes

The Codex backend returns the per-account model catalog only when the
ChatGPT-Account-Id header is present. Without it, GET /backend-api/codex/models
responds 200 OK with {"models":[]} and the picker silently degrades to the
hardcoded fallback list — which is stale or wrong for the active plan
(no GPT-5.6 family, wrong context windows).

This was the upstream bug behind slow first responses and HTTP 520/120s SSE
hangs: Hermes was sending invalid slugs because the probe never saw them in
the catalog, and Codex's request builder also depends on the same JWT claim
that's now being threaded through both probe paths.

Fixes the probe-side paths in hermes_cli/codex_models.py and
agent/model_metadata.py by extracting chatgpt_account_id from the OAuth JWT
(mirroring the request-side logic already in auxiliary_client.py) and sending
it as a header.

Verified live:
- _fetch_models_from_api now returns the 10-model catalog (gpt-5.6-sol,
  gpt-5.6-terra, gpt-5.6-luna, gpt-5.5, gpt-5.4, gpt-5.4-mini,
  gpt-5.3-codex-spark, 3x -pro variants) instead of [].
- _fetch_codex_oauth_context_lengths resolves all 8 account models to 272K
  context (matches direct API probes of the same account).
- end-to-end: hermes chat -m gpt-5.6-sol -q 'Reply with one word: pong'
  returns 'pong' cleanly via the openai-codex route.

Same class of bug as PR #64760.

* test(codex): cover ChatGPT-Account-Id header on /models probe

Add regression tests locking in the new behavior: a JWT carrying a
chatgpt_account_id claim causes the probe to send ChatGPT-Account-Id,
while a malformed token omits the header instead of crashing.

* fix(tools): make the tool-search context gate provider-aware (#68589)

_resolve_active_context_length() called get_model_context_length() with the
model id alone, so provider-enforced windows (e.g. Codex OAuth's 272K for
gpt-5.x vs the direct API's 1.05M) never reached the tool-search activation
gate — it sized against generic metadata for the same slug.

Resolve the runtime provider for the configured model and pass provider,
base_url, and api_key through. If credential resolution fails (offline, no
keys), degrade to a provider+base_url-only lookup so the static
provider-aware fallbacks still apply; explicit model.context_length keeps
short-circuiting as before (#46620). Gap flagged during review of #16735.

* feat(skills): bundle docx, xlsx, and pdf office skills; refresh powerpoint (#68595)

Non-technical users asking for Word docs, spreadsheets, or PDF work had
no bundled skill coverage — docx/xlsx creation required discovering and
installing hub skills, and PDF manipulation had no skill at all beyond
OCR extraction and nano-pdf edits.

- skills/productivity/docx: create (docx-js), edit (unzip -> XML -> zip),
  tracked changes, comments, validation. Adapted from anthropics/skills.
- skills/productivity/xlsx: openpyxl creation/editing, mandatory
  LibreOffice recalc gate, formula-compatibility rules, financial-model
  conventions. Points at optional excel-author for finance-grade work.
- skills/productivity/pdf: merge/split/rotate/watermark/encrypt, form
  filling (AcroForm + flat overlay scripts), text/table extraction,
  reportlab creation, forms.md + reference.md companions.
- skills/productivity/powerpoint: synced to current upstream pptx skill —
  richer pptxgenjs corruption footguns, template workflow, validate.py +
  validators + thumbnail.py, font-substitution QA guidance; drops the
  stale pack.py/editing.md/pptxgenjs.md workflow files.
- Cross-linked ocr-and-documents, nano-pdf, excel-author via
  related_skills so each office skill routes to its siblings.
- deliverable-mode docs mention the new skills; regenerated per-skill
  docs pages, catalogs, and sidebar.
- tests/skills/test_office_document_skills.py: frontmatter contracts,
  referenced-script existence, schema-map integrity, cross-link
  resolution, script compilation.

E2E validated: docx create->render->edit->validate, xlsx recalc
(SUM + _xlfn.TEXTJOIN evaluate correctly), pdf create->merge->extract,
pptx generate->validate->thumbnail.

* fix(approval): raise gateway approval timeout to 300s, honest stale-tap UX, offer Always on mixed prompts (#68597)

Three related messaging-approval fixes:

1. approvals.timeout default 60 -> 300. PR #63501 collapsed the gateway
   wait onto the canonical approvals.timeout (previously
   gateway_timeout=300), silently shrinking messaging approval windows
   to 60s. Push-notification approvals routinely arrive later than a
   minute; taps landed after the wait had already failed closed.

2. Stale-tap honesty: adapters resolved the approval AFTER rendering
   '<checkmark> Approved by <user>' (Telegram/Discord/Slack), or ignored a zero
   resolve count (WhatsApp Cloud/Feishu). A tap on an expired prompt
   claimed approval while the command had already been denied. All
   button paths now resolve first and render 'Approval expired -
   command was not run' when nothing was waiting.

3. Mixed-warning prompts (dangerous pattern + tirith finding) now offer
   Always: the persistence layer already permanently allowlists the
   pattern key and downgrades the tirith key to session scope, but the
   UI hid Always whenever ANY tirith warning was present. Pure-tirith
   prompts still withhold Always (content findings are session-max by
   design), and Smart-DENY overrides remain once-only.

* feat(secrets): one-command token rotation + actionable startup errors for all secret sources (#68605)

* feat(secrets): one-command token rotation + actionable startup errors for all secret sources

When a Bitwarden machine-account token expired, users saw a raw Rust
error dump (invalid_client + Location: + backtrace hints) and the only
fix was manually editing .env or re-running the whole setup wizard.

- New `hermes secrets bitwarden token` / `hermes secrets onepassword
  token`: paste a new token (masked prompt or flag), the command probes
  the backend BEFORE persisting — a rejected token changes nothing; a
  good one is written to .env and the fetch caches are cleared.
- New optional SecretSource.remediation(kind, cfg) hook: startup
  warnings now print a '→ Run `hermes secrets <name> token`…' fix-it
  line after any fetch error, for bundled AND plugin sources (generic
  per-ErrorKind defaults in the ABC).
- bws stderr is summarized to its cause line (Location:/backtrace noise
  dropped) and invalid_client/invalid_grant/400 identity rejects are
  now classified AUTH_FAILED (was INTERNAL) with a plain-English
  explanation naming the token env var.
- op whoami probe accepts a candidate token so rotation validates the
  NEW credential, not the ambient one.

Additive hook with defaults — no SECRET_SOURCE_API_VERSION bump.

* docs: fix MDX parse error in secret-source-plugin hook table

Escaped backticks around a <name> placeholder made MDX parse it as an
unclosed JSX tag, breaking the docs-site build.  Use a plain code span
instead.

* feat(desktop): configure repository discovery (supersedes #67630) (#68642)

* feat(desktop): configure repository discovery

* fix(config): preserve additive default migration

* fix(desktop): stabilize session-actions-menu gateway mock for repo-scan subscribe

projects.ts now runs $gateway.subscribe(syncReposScanning) at module load, and
nanostores fires the subscriber synchronously. session-actions-menu.test.ts
reaches projects.ts transitively via the session store but mocked
@/store/gateway without $gateway, crashing the whole desktop vitest suite
("No \ export is defined"). Simply adding $gateway: atom(null) exposed a
second issue: the synchronous subscriber calls the mock's activeGateway()
during the transitive import, before the module-level const initializes (TDZ).

Hoist the mock fns via vi.hoisted() so activeGateway is defined before the
hoisted vi.mock factory runs, and add $gateway: atom(null) to the mock. Mirrors
the self-contained mock pattern already used in projects.test.ts. Also maps the
PR author's commit email for attribution.

Supersedes #67630; incorporates review feedback from that PR.

Co-authored-by: Rudimar Ronsoni <rudimar@outlook.com>

---------

Co-authored-by: Rudimar Ronsoni <rudimar@outlook.com>
Co-authored-by: Austin Pickett <austinpickett@users.noreply.github.com>

* fix(desktop): ⌘W closes visible file tab when preview selection is stale (#68639)

* fix(desktop): make ⌘W close visible file tab on stale preview selection

When the live preview target is gone but $rightRailActiveTabId still points
at preview, file tabs remain on screen while ⌘W fell through to a workspace
no-op. Close the visible file tab instead.

* test(desktop): cover ⌘W close for file tabs and ghost preview selection

Lock the happy path and the stale-preview regression so ⌘W keeps closing
the file tab the rail is actually showing.

* fmt(js): `npm run fix` on merge (#68681)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* feat(billing): plan chips and rows deep-link their tier (#68666)

* fix(desktop): drop the decorative top-up credits bar (#68649)

The bar rendered full-or-empty (value 1|0) because top-ups have no
denominator — the wire carries only the current balance and the pool is
open-ended, so a fill fraction is fiction. Show the amount alone;
subscription credits and the monthly cap keep their bars (real
denominators).

* fix(ci): route critical supply-chain findings through review gate (#68833)

Let the scanner report critical findings without failing. The review-label
gate owns the action-required status and blocking result, allowing the
ci-reviewed label rerun to clear both CI and the PR comment.

* fix: `tool_calls` double-encoding on import (#68856)

* nix: add `cage` to devShell

* test(desktop): add pre-filled sessions support

Exports createSandbox, writeMockProviderConfig, writeEnvFile,
buildAppEnv, findElectron, and launchDesktop from fixtures.ts so
specs can compose their own seeded-backend fixtures without duplicating
the sandbox/config/launch logic.

* test(desktop): auto-fail e2e tests on error banner

Adds a shared test fixture (e2e/test.ts) that wraps @playwright/test's
page with an error-banner guard. When any [role="alert"] element
(error notification toast) appears in the DOM during a test, the test
fails with the error message text.

The guard uses:
- A MutationObserver (injected via addInitScript) that watches for
  [role="alert"] elements appearing at any point during the test
- A final DOM scan in afterEach for alerts still visible at teardown
- Deduplication so the same error text only fires once

All existing e2e specs updated to import { test, expect } from './test'
instead of '@playwright/test'. No per-spec setup needed — the guard is
auto-installed on every page via the extended fixture.

This catches issues like the "resume failed" error banner that can
appear during session loading — previously the test would pass while
an error toast was silently visible on screen.

* fix(state): parse tool_calls JSON string before re-serializing

_insert_message_rows and append_message both do json.dumps(tool_calls)
to serialize the field for SQLite storage. But when tool_calls arrives
as a JSON string (from import_sessions / export_session, which store it
as TEXT), json.dumps double-encodes it — wrapping the already-serialized
string in quotes and escaping the inner quotes.

When _rows_to_conversation later does json.loads(row['tool_calls']),
the double-encoded string parses back to a plain string (not a list).
_history_to_messages then iterates this string character-by-character,
calling tc.get('function', {}) on each char — 'str' object has no
attribute 'get'.

This was a pre-existing bug (on main), but only triggered by the
import_sessions path (the live agent always passes tool_calls as a
Python list). The e2e error-banner guard caught it via the 'Resume
failed' notification toast.

Fix: in both append_message and _insert_message_rows, parse tool_calls
with json.loads first if it's a string, then re-serialize.

* fix(desktop): exempt boot-failure from error guard

- boot-failure: add allowErrorBanners() beforeEach — these tests
  deliberately trigger boot errors, so error toasts are expected
- test.ts: export allowErrorBanners() opt-out + reset flag in afterEach

* feat(status-bar): add /battery toggle for a color-coded battery read-out

Add an opt-in battery indicator to the CLI and TUI status bars, shown as
the first element and colour-coded by charge (green/yellow/orange/red, or
green while charging). Off by default and a no-op on machines without a
battery.

- agent/battery.py: shared psutil-backed reader with a short TTL cache,
  category bucketing, and a compact 🔋/ label. Fails open to
  "unavailable" everywhere.
- CLI: /battery [on|off|status] toggle persisted to display.battery,
  rendered first in every status-bar width tier.
- TUI: /battery slash command, config sync, a system.battery RPC polled
  while enabled, and a pinned first segment in StatusRule.

* fix(approval): restore session approval for Tirith-flagged commands

Adds an allow_session flag to the gateway approval payload so adapters
can render the session tier independently of the permanent tier. Matrix
gains a session reaction (🌀) and a reaction legend; pure-tirith prompts
now offer once/session/deny instead of collapsing to once/deny.

Salvaged from PR #67312, adapted to the allow_permanent semantics that
landed in #68597 (Always offered when any dangerous-pattern warning is
persistable; pure-tirith prompts stay session-max).

* fix(approval): honor allow_session across all button adapters

Widen the allow_session tier from Matrix to every adapter the gateway
notifies: Telegram, Discord, Slack, Feishu, and Teams gate their Session
button on it; WhatsApp Cloud and qqbot accept the kwarg (no session tier
in their button sets). Also thread allow_session through the plugin-
escalation gate, the execute_code guard payload, and the plain-text
fallback so every notify path carries the same capability flags.

* test(approval): cover allow_session tiers in Matrix reaction seeding and gateway payload

Update the Matrix reaction-seeding contract to the four-reaction default
(once/session/always/deny), add tirith-tier (session without always) and
no-session-tier cases, and assert allow_session=True in the tirith
gateway payload.

* fix(desktop): wrap missing sidebar icon-button tooltips (#67500)

* fix(desktop): wrap sidebar icon buttons in Tip tooltips

Several icon-only buttons in the sidebar (header actions, workspace
menu, project menu, session actions, load-more) had aria-label but
no visual tooltip on hover. Wrap them in the existing <Tip> component,
matching the pattern already used elsewhere (e.g. ProfilePill).

No behavioral changes -- purely wraps existing buttons.

Adds vitest coverage asserting the Tip wrapper (data-slot=tooltip-trigger)
for 6 of 7 files; index.tsx is a 1500+ line top-level page component and
was verified manually via screenshots instead.

* fix(desktop): satisfy consistent-type-imports lint rule in project-dialog test

* test(desktop): update session-row mocks for restored sessionColorById

* fix(desktop): compose Tip around the real trigger instead of inside it

Tip was being placed as SessionActionsMenu's/PlatformAvatar's DIRECT child,
which asChild then cloned instead of the actual button/span. Neither Tip nor
PlatformAvatar forwarded the injected onClick/ref, so both silently dropped
the wiring:

- session-actions-menu.tsx: Tip now wraps DropdownMenuTrigger internally
  (new 	ooltip prop) instead of the caller wrapping its children in Tip.
- platform-icon.tsx: PlatformAvatar now forwards ref and spreads rest props
  onto its span so a wrapping Tip's trigger actually attaches.
- session-row.tsx: updated call site to use the new tooltip prop.
- Added session-actions-menu.test.tsx exercising the real DropdownMenu open
  behavior end-to-end (no Tip/Dropdown mocks).
- session-row.test.tsx no longer mocks PlatformAvatar's behavior; it now
  exercises the real (fixed) component for the handoff-avatar tooltip.

* fix(desktop): compose Tip outside PopoverAnchor in ProjectMenu (#67500)

* test(desktop): update session-row test for the tooltip-prop composition (cbbbeb2fd)

* fix(desktop): satisfy consistent-type-imports in session-row.test.tsx mocks

* chore: retrigger CI

* test(desktop): stop mocking PlatformAvatar's behavior (#67500, third pass)

The mock was re-introduced by a prior edit that fixed an unrelated lint
error, silently undoing the earlier fix where this test started exercising
the real (forwardRef) PlatformAvatar. Removed the mock; updated the two
handoff-avatar tests to query the real component's rendered span instead of
text content, since it renders a brand SVG icon for known platforms rather
than the platform name as text.

* fmt(js): `npm run fix` on merge (#68867)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* fix(gateway): detect stale lock when macOS psutil returns valid start_time for recycled PID

On macOS, the lock record's start_time is None (no /proc at creation),
but psutil.Process(recycled_pid).create_time() returns a valid float
for the unrelated process that now owns the PID. The old condition
required both sides to be None before falling back to cmdline checking,
so the recycled PID was never detected as stale.

Change the fallback condition from AND to OR: when either side's
start_time is missing, fall back to cmdline-based gateway detection.

Fixes #53763

* fix(gateway): handle PermissionError on stale root-owned lock file

When the macOS launchd service runs in a Background session, the gateway
process spawns as root and creates a root-owned gateway.lock. On restart
as the normal user, open() on that file raises PermissionError, crashing
the gateway immediately and entering a launchd crash loop.

Catch PermissionError in is_gateway_runtime_lock_active(), remove the
stale lock file, and return False so the new process can start cleanly.

Fixes #42685

* fix(gateway): guard acquire_gateway_runtime_lock against root-owned lock PermissionError

Widen the PermissionError handling from is_gateway_runtime_lock_active
(#42689) to the sibling open() in acquire_gateway_runtime_lock: a stale
root-owned gateway.lock left by a launchd Background session previously
crashed the acquiring process. Unlink the stale file and retry once; if
the unlink or retry fails, return False cleanly instead of raising.

* fix(gateway): make stale scoped-lock removal atomic via tombstone rename

Replace the unlink()+O_EXCL sequence in acquire_scoped_lock with an
atomic os.replace() of the stale lock to a <lock>.stale tombstone
followed by the existing O_EXCL create. With plain unlink(), two racing
starters could both judge the lock stale and the second unlink() would
silently delete the first racer's freshly-created lock — both would then
'win'. os.replace() guarantees exactly one racer claims the stale file;
the loser gets FileNotFoundError and falls through to O_EXCL, which
admits at most one winner. Tombstones are cleaned up immediately;
behavior is otherwise identical.

* fix(gateway): detect stale gateway_state.json in `gateway status` (TTL + PID liveness)

Verified: applies cleanly and the patched module compiles. Tests are
described in the PR body (not bundled in this commit).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(gateway): cover stale gateway_state.json detection (TTL + PID liveness)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(gateway): take over live platform-lock token holders once

When --replace misses a cross-HERMES_HOME Telegram token holder, platform
connect used to retry forever. Terminate a verified gateway holder once
(with the takeover marker) and re-acquire the scoped lock (#65176).

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(contributors): map jaretbottoms@gmail.com -> jbbottoms (PR #65178 salvage)

* fix(gateway): reap the replaced gateway's orphaned children on POSIX

Builds on jbbottoms's #65178 takeover fix (cherry-picked as the previous
commit). Windows --replace already tree-kills via taskkill /T, but the
POSIX paths signalled only the recorded gateway PID — adapter
subprocesses that outlived their parent kept holding scoped token locks
and blocked the replacement gateway.

- gateway/status.py: _snapshot_gateway_children() captures the old
  gateway's descendants (psutil, recursive) while it is still alive;
  reap_gateway_children() SIGTERMs verified orphans after the main PID
  is confirmed dead, waits bounded, SIGKILLs survivors. Identity-aware
  (psutil is_running is PID+create-time), skips zombies and children
  whose ppid still equals the old gateway (parent actually alive), and
  never raises — best-effort with debug/info logging only.
- take_over_scoped_lock_holder() snapshots before terminating and reaps
  only on a confirmed successful handoff.
- gateway/run.py: start_gateway --replace snapshots before SIGTERM and
  reaps after the old PID is confirmed gone, mirroring taskkill /T.
- tests/gateway/test_replace_child_reap.py: reap/skip/never-raise unit
  coverage plus end-to-end --replace ordering (snapshot → terminate →
  reap) and the no---replace path never touching the old process.

* chore(contributors): map emails for PRs #66906, #66420, #63398 salvage

* fix(state): probe FTS5 read path in _db_opens_cleanly so partial index corruption is detected (#66724)

`hermes sessions repair --check-only` opens cleanly on state.db files
with partial FTS5 index corruption — base tables read fine, the rolled-back
write probe from #50502 succeeds, and `PRAGMA integrity_check` returns
"ok". But every session_search / /resume title resolution / feature
backed by MATCH / snippet / rank queries errors out with
`database disk image is malformed` because internal shadow-table segments
are bad. The official repair tool then gives false confidence.

Add a representative FTS5 read probe against both `messages_fts` and
`messages_fts_trigram` (the latter backs title resolution). Empty MATCH
strings are accepted by every FTS5 index without requiring populated
content, so the probe is safe on a freshly-init'd DB; missing-table /
missing-column errors fall through to the existing "not yet a populated
DB" branch, matching the write-probe's behaviour. Any other OperationalError
is surfaced as the check reason, which sends `hermes sessions repair` to
its existing FTS 'rebuild' path (repair_state_db_schema, line 616).

Single-file change in hermes_state.py::_db_opens_cleanly. No public API
change. No new imports. Fixes #66724.

* fix(state): also catch sqlite3.DatabaseError in FTS5 read probe (#66724)

The FTS5 read probe in _db_opens_cleanly() only caught
sqlite3.OperationalError. But the corruption class #66724 actually
wants caught — partial shadow-table damage where MATCH / snippet / rank
queries raise DatabaseError("database disk image is malformed") — is a
DatabaseError, not OperationalError. Without this catch the probe
crashes the caller instead of returning a reason, which is exactly the
silent-fail mode the issue describes.

Move the try/except inside the for-loop so each FTS table is probed
independently (one table corrupted should still surface as a reason),
add a separate except clause for DatabaseError that surfaces the same
reason format, and use continue instead of pass so the loop still walks
both tables when only one is missing on a brand-new DB.

Tested by hand: with a corrupted messages_fts_trigram shadow table the
function now returns 'fts5 read probe failed on messages_fts_trigram:
database disk image is malformed' instead of crashing out. Without this
fix it would still crash.

* fix(state): preserve degraded-runtime read probe + use canonical FTS5 classifier

Two follow-ups on top of f842733 (the FTS5 read probe added in #66906):

1. The original probe query used MATCH '', which FTS5 rejects with
   'fts5: syntax error near '. Empty MATCH syntax is not valid FTS5.
   Switch to MATCH '""' — a quoted empty phrase that parses, scans
   zero rows, and exercises the same shadow-table read path the
   search tools use. The probe previously never reached the shadow
   segments at all on a healthy DB; the read-corruption class was
   only being detected because the existing write probe happens to
   fail first on a DatabaseError.

2. The probe's degraded-runtime branch only checked the substrings
   'no such table' / 'no such column'. On a SQLite build without the
   fts5 module, MATCH against a legacy messages_fts table raises
   'no such module: fts5' (a different OperationalError class). The
   substring check would misclassify that as corruption and trigger
   repair, whose final fallback deletes the messages_fts% schema
   (#66906 review). Use SessionDB._is_fts5_unavailable_error() — the
   canonical classifier already used by the degraded-runtime init
   path — to recognize both 'no such module: fts5' and
   'no such tokenizer: trigram' as capability errors.

Add tests covering:
- Partial shadow-table damage (read-corruption class)
- Repair brings reads back online
- Healthy degraded DB without fts5 module stays healthy (regression
  for the misclassification risk)
- Healthy degraded DB without trigram tokenizer stays healthy

Closes #66906 review feedback
Refs #66724

* fix(state): self-heal FTS corruption on the SessionDB search path too

Complements #66296 (self-heal on the write path): search_messages()'s main
FTS5 MATCH query caught only sqlite3.OperationalError (a query-syntax error →
return empty). A corrupt FTS index raises the malformed / "fts5: corrupt
structure record" class, which is a sqlite3.DatabaseError — the parent of
OperationalError, so it was NOT caught and propagated straight out of
search_messages, crashing session/history search.

The write path now rebuilds and retries on that class, but a read-only
session (cron/CLI history search, or a search issued before any write) never
triggers a write, so its search stayed broken until the next process restart
ran the offline repair.

Catch the DatabaseError corruption class on the search MATCH read too and
route it through the existing one-shot _try_runtime_fts_rebuild(), then retry
the query. The catch is moved outside `with self._lock` so rebuild_fts() can
re-acquire the lock (mirrors _execute_write). The one-shot guard is shared
with the write path, so a single instance never loops on a genuinely
unrecoverable index. OperationalError syntax handling is unchanged (caught
first).

Adds a regression test: with a corrupted messages_fts and no post-corruption
write, search_messages() rebuilds in place and returns the match; without the
fix it raises DatabaseError.

* fix(state): extend search-path FTS self-heal to the CJK/trigram branch

The trigram MATCH branch in search_messages() had the same
OperationalError-only catch that #66420 fixed on the main FTS5 branch: a
corrupt messages_fts_trigram shadow table raises the malformed /
'fts5: corrupt structure record' class (sqlite3.DatabaseError, parent of
OperationalError), which propagated straight out of search_messages and
crashed CJK session/history search for read-only sessions.

Route that class through the shared one-shot _try_runtime_fts_rebuild()
and retry the trigram query (catch moved outside self._lock so
rebuild_fts() can re-acquire it, mirroring the main branch). If the
rebuild is refused (guard consumed / FTS disabled / different error) or
the retry fails, fall through to the existing LIKE substring fallback —
which reads only the canonical messages table — instead of raising, so
CJK search degrades gracefully rather than crashing.

Adds two regression tests: trigram search self-heals in place after
shadow-table corruption (answers from the rebuilt trigram index, not the
LIKE fallback), and degrades to LIKE without raising when the one-shot
rebuild was already consumed.

Follow-up to #66420; refs #66296 #66724

* fix(state): add REINDEX strategy to repair stale B-tree indexes (#63386)

When PRAGMA integrity_check reports 'wrong # of entries in index' for
B-tree indexes (e.g. idx_sessions_handoff_state), the existing repair
strategies (FTS rebuild, sqlite_master dedup, drop-FTS+VACUUM) don't
address the mismatch. Add Strategy 0.5: run REINDEX to rewrite the
index b-tree from canonical table rows before escalating to more
destructive strategies.

* test(state): exercise REINDEX repair against a REAL stale B-tree index

Replace the mocked test for #63398's REINDEX strategy: the original
monkeypatched _db_opens_cleanly to return the corruption string, so the
REINDEX pass itself was never exercised against actual index corruption —
the test would pass even if REINDEX didn't fix anything.

New fixture _corrupt_btree_index() builds genuine on-disk staleness with a
writable_schema hack: rewrite the index definition to a partial index
(WHERE 0), REINDEX so the b-tree is rebuilt empty, then restore the full
definition. integrity_check then reports the real
'wrong # of entries in index idx_messages_session' / 'row N missing from
index' class from #63386 — no mocks anywhere.

The rewritten test asserts end-to-end with real function calls:
- the real _db_opens_cleanly detects the stale index,
- repair_state_db_schema repairs it with strategy 'reindex_btree',
- post-repair the detector and raw PRAGMA integrity_check both report
  healthy, and a query forced through the rebuilt index (INDEXED BY) sees
  every row.

Adds a second test asserting the REINDEX strategy is non-destructive
(all sessions/messages survive, readable via SessionDB).

Follow-up to #63398; refs #63386

* fix(kanban): auto-repair index-only kanban.db corruption via REINDEX

_guard_existing_db_is_healthy previously failed closed on ANY
integrity_check failure, including the index-scoped class ('wrong # of
entries in index <name>' / 'row N missing from index <name>') where the
table b-trees are intact and REINDEX rebuilds the damaged indexes
losslessly. Boards hit by that class were bricked until manual surgery
even though SQLite can fix them in-place.

Now, when integrity_check output consists ONLY of index-scoped errors
(index name parsed generically from the message — no hardcoded list):

  1. quarantine the corrupt bytes FIRST via the existing content-
     addressed _backup_corrupt_db,
  2. under the caller-held cross-process init flock, REINDEX each named
     index (falling back to bare REINDEX if a parsed name doesn't
     resolve),
  3. re-run integrity_check and proceed only if it comes back clean.

Any non-index error class (page corruption, malformed image, freelist
damage) — or a REINDEX whose re-check is still dirty — fails closed
exactly as before: backup + KanbanDbCorruptError, no silent recreation.
Transient OperationalError (locked/busy) still propagates raw with no
quarantine.

Tests build a real board DB and corrupt a live index via the
writable_schema/partial-index REINDEX trick to produce the genuine
'wrong # of entries in index' shape, then assert auto-repair recovers
with data intact, page corruption still raises, and a dirty re-check
fails closed.

* fix(kanban): cap corrupt-backup retention at 10 files per board DB

Content-addressed quarantine backups dedupe identical corrupt bytes,
but corruption that keeps mutating between failures (partial repairs,
further damage across dispatcher retries, multi-profile fleets) mints a
new sha-named backup every round — a user accumulated 124
.corrupt.*.bak files with no bound.

After each NEW backup is created, prune oldest-by-mtime backups beyond
_CORRUPT_BACKUP_RETENTION (module constant, default 10), including the
copied -wal/-shm sidecars. The just-created backup is always exempt
(copy2 preserves the source mtime, which can be older than existing
backups). Pruning is best-effort and never masks the corruption error
about to be raised; dedupe of identical corrupt bytes is unchanged.

* feat(kanban): periodic WAL checkpoint (TRUNCATE) on the dispatcher tick

Kanban connections set wal_autocheckpoint=100, but SQLite's passive
autocheckpoint backs off whenever any reader holds an open snapshot —
on a busy multi-process board the -wal file can grow without bound
between gateway restarts.

After each successful dispatch tick, while still holding the board's
single-writer dispatch flock, run PRAGMA wal_checkpoint(TRUNCATE)
best-effort at a coarse interval (>=5 min since this process last
checkpointed that board; module-level per-path monotonic timestamp, so
multi-board dispatchers checkpoint each board on its own clock).
Success and busy/locked skips are both logged at DEBUG; a failing
checkpoint can never fail the tick.

* feat(kanban): add `hermes kanban repair` CLI verb

Adds kanban_db.repair_db() — a structured, non-raising wrapper around
the same narrow repair policy as the connect-time guard: probe with
PRAGMA integrity_check under the board's cross-process init flock;
quarantine the corrupt bytes FIRST via the content-addressed backup;
REINDEX only when every integrity message is index-scoped; re-check;
report ok / repaired / corrupt / missing. Locked/busy OperationalError
still propagates raw (a locked healthy DB is not corruption and gets
no quarantine), and a repair invalidates the per-process healthy-path
cache so the next connect() re-probes.

The CLI verb reports status human-readably (or --json), exits 0 for
ok/repaired/missing and 1 when the DB is still corrupt (non-index
corruption stays fail-closed with manual-recovery guidance). It
dispatches BEFORE kanban_command's auto-init: init_db() raises
KanbanDbCorruptError on a corrupt board, which previously would have
made a repair verb unreachable on exactly the boards that need it.

CLI tests drive the real argparse surface (build_parser +
kanban_command) against real corrupted SQLite fixtures.

* fix(packaging): graft web_dist in MANIFEST.in and add sdist regression test

Wheels ship hermes_cli/web_dist via pyproject package-data, but the sdist
did not: MANIFEST.in had no graft and .gitignore excludes web_dist, so
source tarballs installed a dashboard-less package. Graft the directory
and add an sdist regression test that builds the tarball and asserts
index.html is inside.

Salvaged from #29661; the PR's [web]-extra 404-message change was dropped
per maintainer review (misleading guidance for source installs).

* fix(dashboard): attempt one recovery build when --skip-build finds no dist

--skip-build with a missing web_dist/index.html previously hard-failed
with sys.exit(1) (issue #59288). The desktop launcher passes
--build-mode skip on every boot, so a wiped or never-populated dist
bricked the dashboard until the user manually rebuilt.

Now the default-dist path logs a clear warning and attempts exactly ONE
recovery build through the existing _build_web_ui path. If the recovery
build also fails to produce index.html, the original fatal behavior is
preserved with a clear message. A custom HERMES_WEB_DIST stays fail-fast:
the build writes to the default dist location and cannot populate a
caller-managed directory.

Closes #59288

* fix(web): guard _serve_index against a missing or unreadable index.html

mount_spa degrades to a JSON 404 catch-all when the dist directory is
fully missing, but _serve_index read index.html unguarded — so a dist
dir that exists while index.html is missing (partial build, wiped dist,
permissions) raised FileNotFoundError on EVERY request instead of
returning a useful error.

Catch OSError around the read and return the same JSON 404 payload the
fully-missing-dist path uses, so clients get a consistent signal. The
route recovers automatically once a rebuild restores the file.

* fix(dashboard): content-hash web UI freshness check

Replace mtime-based web UI dist staleness with a content-hash stamp under HERMES_HOME, matching the desktop build freshness model.

This avoids false stale/fresh decisions when git operations rewrite source mtimes without changing bytes, while preserving the existing stale-dist fallback. Also treats malformed or missing stamp data as needing a rebuild.

* fix(cli): serialize concurrent web-UI builds with an exclusive flock

Concurrent dashboard boots (desktop retry loop) each spawned their own
npm install + vite build over the same tree; parallel builds starved
each other, the dist sentinel never advanced, and every boot re-triggered
the build — cascading into orphan backends, port collisions, and CPU
storms that also knocked out the Telegram gateway's heartbeat (2026-07-12).

One process now builds under flock; the rest serve the existing dist
(stale is acceptable) or wait for the first-ever build to finish.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(cli): run the web-UI staleness walk once, under the build lock

The flock wrapper checked _web_ui_build_needed twice (pre-lock fast path
and post-lock re-check) and _do_build_web_ui checks it again internally,
so a boot that actually built walked the whole web/ source tree three
times. The callee's own check already runs under the lock on every path
through the wrapper, so it alone is sufficient: drop both wrapper checks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(git): ignore the web UI build lock file

The cross-process build flock (.web_ui_build.lock at the repo root) is
an empty coordination file created on first dashboard boot; it must
never be tracked or show up as untracked noise. Also keeps it out of
the content-hash staleness digest, which skips gitignored paths.

* test(cli): cover the web UI build flock contention paths

PR #63455 shipped the flock without tests. Cover the three contention
paths: contended+dist serves stale without a second build, uncontended
builds under the lock (creating the lock file), and contended+no-dist
blocks then skips the rebuild because the staleness re-check runs under
the lock and sees the winner's stamp. Also pin the lock filename into
.gitignore via a regression assertion.

* style(cli): open the build lock file with explicit encoding (ruff PLW1514)

* chore(contributors): map eazye19@users.noreply.github.com -> eazye19 (PR #42387 salvage)

* fix(mcp): reconnect immediately on transport TaskGroup drop instead of backoff/park

Streamable-HTTP / SSE MCP transports run their stream pump inside an anyio
TaskGroup. A transient stream drop (idle timeout, brief backend blip,
server-side TCP close) surfaces as a BaseExceptionGroup escaping the
transport context manager. It reached run()'s error path, which applied
exponential backoff (1s..16s) and eventually parked the server for 300s and
deregistered its tools — turning a sub-second glitch into a multi-minute
tool outage even though the POST path was still healthy.

_run_http now wraps all three transport branches (SSE, new + deprecated
Streamable-HTTP) and routes a BaseExceptionGroup through a new
_reconnect_or_reraise_group() helper that returns 'reconnect' (immediate
rebuild, no backoff/park/deregister). It re-raises instead of masking when
shutdown is in progress, when the group carries a real CancelledError
(cancellation must propagate, cf. #9930), or when no live session was
established this attempt (a connect/handshake failure that should fall
through to run()'s backoff rather than hot-loop).

Fixes #66092

Co-authored-by: 雨哥 <hanyu1212@users.noreply.github.com>

* fix(mcp): charge immediate reconnects against a rapid-drop budget (#62212)

Harden the immediate-reconnect path from #66271:

- _reconnect_or_reraise_group re-raises when the transport TaskGroup
  carries a KeyboardInterrupt / SystemExit leaf — fatal signals must
  propagate to the interpreter, never be converted into a reconnect.

- Rapid-drop budget: a completed handshake alone no longer clears
  _reconnect_retries/backoff on clean transport return. A session is
  UNPROVEN until it demonstrates real health — survived >=1 full
  keepalive interval (keepalive success path) or served >=1 successful
  tool call (_mark_session_proven). Unproven clean returns are charged
  against _MAX_RECONNECT_RETRIES, so a flapping transport that
  handshakes fine and drops moments later still reaches the park
  instead of respawning forever (#62212: 6212 spawns in 63h).
  Proven sessions keep the #57604 behaviour: budget clears, transient
  blips over a long-lived session never accumulate toward parking.

* fix(mcp): unwrap exception groups and park permanent failures immediately (#65673)

- Add module-level _unwrap_exception_group (ported from
  hermes_cli/mcp_config.py, adapted): handles nested groups, prefers
  non-cancellation leaves over the CancelledErrors anyio sprays across
  sibling tasks, and re-raises KeyboardInterrupt/SystemExit leaves.

- Add _classify_mcp_failure(exc) -> 'permanent'|'transient'.
  Permanent: auth 401/403, NonMcpEndpointError, InvalidMcpUrlError,
  FileNotFoundError/ENOENT on the stdio command. Transient: everything
  else (network/EOF/ClosedResource/TaskGroup drops). Permanent failures
  park immediately without burning the retry ladder — every retry
  against a missing binary or a revoked credential hits the same wall.

- Every log site in run() and the keepalive failure log now logs the
  unwrapped root cause as 'TypeName: message', so a dead stdio pipe
  says 'BrokenPipeError' instead of the opaque
  'unhandled errors in a TaskGroup (1 sub-exception)' (and empty
  str(exc) dead-pipe errors are no longer blank).

* fix(mcp): one WARNING per state transition, DEBUG retry chatter, jittered backoff

Log-storm hygiene for the reconnect machinery (#65673, #66092):

- State transitions carry exactly one WARNING each:
  connected → degraded (keepalive failure), degraded → parked
  (budget exhausted / rapid-drop park / permanent error),
  parked → connected (revival proven healthy, via
  _mark_session_proven).
- Per-attempt retry logs (initial-connect attempts, connection-lost
  reconnect attempts, per-cycle rebuild notices, park-wake probes)
  demoted to DEBUG.
- Backoff sleeps get +/-20% uniform jitter (_jittered) so a herd of
  servers that lost the same backend doesn't retry — and log — in
  lockstep.

* fix(mcp): re-register tools during parked revival

* fix(mcp): isolate a single failing stdio server from the bridge (#50394)

* fix(mcp): clear connect-cooldown state on every shutdown path

Builds on trevorgordon981's #50589 (cherry-picked as the previous commit).
The #50394 cooldown reset only ran inside the async _shutdown coroutine,
which is skipped on the empty-_servers fast path — the most common state
when a server failed to connect (failed servers are never recorded in
_servers). It was also skipped when the MCP loop wasn't running.

Clear _server_connect_retry_after/_server_connect_failures on the fast
path and in a final unconditional sweep so a full shutdown/restart always
re-attempts every configured server immediately. Adds regression tests
for both paths.

* chore(contributors): map diffen77 + fazerluga-creator emails

For the #66547 and #66981 salvage cherry-picks in this branch.

* fix(mcp): reconnect message-less closed transports

* test(mcp): isolate resource error breaker state

* fix(mcp): harden nested interruption detection

* fix(mcp): make transport classification cycle safe

* fix(mcp): cycle-guard cause/context traversal in transport classifier

Builds on diffen77's #66547 (cherry-picked as the previous commits).
Extend _is_session_expired_error's iterative traversal to follow
__cause__/__context__ in addition to ExceptionGroup .exceptions — SDK
wrappers often raise a generic RuntimeError *from* the message-less
ClosedResourceError, leaving the transport signal reachable only via
the chain. The identity-visited set guards chain cycles (handlers
re-raising previously seen exceptions), and a bounded node budget
(_EXC_TRAVERSAL_MAX_NODES) caps pathological acyclic graphs.

Adds regression tests: cause/context chain detection, interruption
precedence through chains, cyclic cause/context termination, and
budget-bounded termination.

* fix(mcp): allow background discovery retry after a run that connected nothing

start_background_mcp_discovery() sets _mcp_discovery_started once and never
resets it. If the first background run exits without connecting any MCP
server (startup cancellation, OOM restart, transient network failure), every
later call returns immediately and the process is permanently stuck with
zero MCP tools until a full restart.

Fix: when discovery is marked started but the thread is dead and no server
is connected, reset the flag and spawn a fresh discovery thread. Also log a
WARNING when a discovery run completes with zero connected servers, so the
condition is visible instead of silent.

Caught in production on a long-running gateway fleet where a gateway
restarted under memory pressure and came back with all MCP tools missing.

* fix(tui): centralize stdio TUI MCP discovery on the shared owner

Review follow-up: the stdio hermes --tui path spawned its own one-shot
discovery thread, so the retry-after-zero-connected semantics added to
start_background_mcp_discovery() did not cover it.

Spawn TUI discovery through the shared owner and make the entry-side
wait_for_mcp_discovery() fall through to the shared owner when no local
thread exists (mcp_discovery_in_flight/join_mcp_discovery already consult
both owners). Keeps the cheap no-mcp-servers config guard on the TUI path.

Adds regression tests for the entry-side wait delegation.

* fix(tui): give the stdio TUI discovery a retry-after-zero-connected path

Builds on fazerluga-creator's #66981 (cherry-picked as the previous two
commits). start_background_mcp_discovery()'s retry allowance only fires
when the function is CALLED again, but tui_gateway/entry.py main() calls
it exactly once at startup — so a first discovery run that connected
nothing still latched the stdio TUI MCP-less for the whole session.

Re-invoke the idempotent spawn from wait_for_mcp_discovery() (the
per-agent-build wait) when the process is MCP-enabled, gated on a flag
set in main() so non-MCP sessions never pay the MCP import on the wait
path. Adds regression tests for both the retry re-invocation and the
non-MCP skip.

* fix(tui): gate the shared-owner MCP discovery wait on the stdio TUI flag

The TUI retry-allowance follow-up made tui_gateway.entry.wait_for_mcp_discovery
delegate to hermes_cli.mcp_startup unconditionally when no entry-local thread
exists. But server._make_agent already calls the startup wait directly for
dashboard /api/ws sessions, so every non-stdio agent build paid the bounded
wait twice (caught by test_make_agent_waits_for_shared_mcp_discovery). Gate
the retry-spawn AND the delegated wait on _mcp_discovery_enabled, which only
the stdio TUI arms in main().

* fix(status-bar): address Copilot review on /battery

- TUI /battery matches the CLI surface: adds `status` (live reading via
  system.battery), and the help/usage strings now consistently read
  [on|off|status].
- batteryLabel() renders `--` for an unknown percent so a null can never
  surface as "null%" even without the showBattery guard.
- Move the system.battery RPC out of the config section into
  "Methods: tools & system" where system.* RPCs belong.

* fix(dashboard): cap gateway health probe timeout

* fix(gateway): report runtime source version in /health, not stale dist-info metadata

/health and /health/detailed resolve the version via importlib.metadata
first, falling back to hermes_cli.__version__. On editable/source
checkouts — including the standard git-based install that hermes-setup
performs — hermes_agent-*.dist-info can survive a source update
unchanged, so the health endpoints keep reporting the previous release
even though the running code (CLI, dashboard, release tags) is newer.
Stale metadata does not raise, so the source fallback never fires.

Flip the preference: use hermes_cli.__version__ (the runtime source of
truth shared by the CLI and dashboard) first, and fall back to
distribution metadata only when the source import fails. The
never-raise contract of the version probe is unchanged.

Observed live: CLI, dashboard, and pyproject all reported 0.18.2 while
/health returned 0.18.0 from a stale hermes_agent-0.18.0.dist-info left
behind by a source update.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(api-server): count "stopping" runs as active in readiness work counts

_readiness_work_counts()'s active_api_runs set is {"queued", "running",
"waiting_for_approval"} — it excludes "stopping", the status
_handle_stop_run() sets while a run is being interrupted. Since the stop
is fully cooperative (the run stays "stopping" — doing real
executor-thread work — until the agent actually notices the interrupt and
the task settles to "cancelled", an unbounded window, not a fixed
timeout), /health/detailed's background_queues.active_api_runs
undercounts real active work for that whole duration.

Fix: add "stopping" to the active-status set. background_queues.status
itself is hardcoded "ok" (gateway/readiness.py), so this doesn't change
overall readiness — it only corrects the count value external monitoring
tooling reads from this endpoint.

* chore(contributors): map yingwaizhiying@gmail.com -> tianma-if (supersedes stale msh01 entry)

* feat(dashboard): component-level health rollup on /api/status (#68662)

The dashboard's own liveness surface could report healthy while every
authenticated request 500'd (e.g. wedged state DB) — /api/status carried
gateway-only fields, no storage/dashboard signal, and no middleware
counted unhandled exceptions.

- DashboardHealth state holder: rolling 5-min deque of unhandled-error
  timestamps + last self-test result. last_error_type/last_error_path
  are internal-only diagnostics — snapshot() exports counts/enums/
  timestamps exclusively (PUBLIC_API_PATHS no-secrets contract).
- Outermost @app.middleware('http') (registered last) wraps call_next
  in try/except: records + re-raises unhandled exceptions, and records
  responses with status >= 500.
- /api/status gains 'components' {gateway, storage, dashboard,
  platforms} + top-level 'overall' ok|degraded. storage reuses the
  gateway readiness state_db probe (read-only, 1s-bounded) in an
  executor; platforms derive ok/degraded from existing
  gateway_platforms states.
- Authenticated self-test task started in the lifespan: every 60s an
  in-process httpx ASGITransport GET of /api/sessions?limit=1 with the
  real _SESSION_TOKEN, feeding the dashboard component. Skips cleanly
  when httpx is unavailable and while the OAuth gate is engaged (the
  legacy token is not honoured there).

Tests: middleware increments on raising route and on 5xx, window
expiry, components shape + overall, storage degraded when the state_db
probe fails, dashboard degraded after an error, no secret-bearing
fields in the public payload, self-test pass/fail recording (mocked
client) + a real ASGI round trip.

* fix(status): make gateway_updated_at a stable RFC3339-or-null contract (#68657)

The /api/status gateway_updated_at field and the gateway /health/detailed
updated_at field passed through whatever gateway_state.json contained,
untyped. All current writers emit RFC3339 via _utc_now_iso(), but legacy
gateways wrote unix epoch floats, and a corrupt or hand-edited state file
can inject numbers or arbitrary garbage — while the frontend types
(web/src/lib/api.ts) declare string | null.

Add normalize_updated_at() in gateway/status.py as the single funnel:
- str: accepted iff datetime.fromisoformat parses (trailing Z tolerated);
  naive timestamps coerced to UTC; canonical isoformat returned
- int/float: treated as unix epoch seconds -> UTC ISO string, with a
  plausibility guard (reject < 2000-01-01, > now+1day, non-finite)
- bool: rejected explicitly (int subclass, but never a timestamp)
- anything else: None

Apply it at both emit sites: the dashboard /api/status handler (covers
both the local read_runtime_status() branch and the remote
/health/detailed cross-container fallback branch) and the gateway API
server's /health/detailed response.

Contract tests: parametrized /api/status normalization (epoch float/int,
garbage string, None, bool, dict, absent key), remote-health numeric and
garbage bodies, dashboard shape test round-trip assertion, direct
normalize_updated_at units (range guards, Z suffix, naive coercion,
non-finite floats), and a write_runtime_status -> read_runtime_status
round-trip proving the writer side stays tz-aware parseable.

* feat(desktop): type-to-focus the composer from empty chat chrome

Printable keys and soft `/`/Enter pull focus back to the composer when
nothing else owns the key (dialogs, terminal, buttons on Enter, …).

* feat(ui-tui): widget-grid 2-axis layout engine + overlay primitives (#20379 rebase)

Rebase of the widget-grid PR onto current main, then grow it into a real
2-axis engine: resolveGridTracks (grid-template tracks — fixed cell counts
and weighted fr shares with mins), layoutWidgetGrid (1D auto-packing flow),
and layoutGridAreas (2D absolute placement with row/col spans and implicit
row growth). Overlay/Dialog primitives give zoned viewport-level modals
with an optional scrim. /grid-test (interactive: areas, nesting, zoom,
gap/padding) and /grid-test streams (4x3 mission-control GridAreas board
with promote-to-main) exercise everything end to end.

* refactor(ui-tui): route production surfaces through the widget-grid engine

Banner (responsive tiers: full logo -> compact rule -> text -> hidden),
SessionPanel (fixed hero track + flexible info track, the desktop pane
shell's fixed-vs-flex contract), floating overlays, prompt zone, and the
pickers all render through WidgetGrid instead of hand-rolled flex math.
Pickers gain maxWidth so grid cells can cap them.

* feat(ui-tui): background-aware theme adaptation + paired palettes + /theme pin

OSC-11 asks the terminal for its actual background at startup (env
heuristics are blind on xterm.js hosts) and the theme re-derives against
the answer: desktop-contract adaptation (contrast floors + fill polarity),
a shared list-row selection primitive instead of per-picker panel fills,
paired light_colors/dark_colors skin blocks with a machine audit, and a
/theme auto|light|dark pin (display.tui_theme) for hosts whose probe lies.
E2E coverage for the OSC reply chain + /theme-info diagnostics.

* feat(ui-tui): theme engine — seeds to derived-tone ladder + flash-free boot

The desktop color-mix system, ported: a theme is a handful of identity
SEEDS (text, primary, accent, border, status hues); every secondary tone
(muted, label, surfaces, chips, selection) is a color-mix derivative
against the real terminal background. lib/color.ts consolidates the
primitives (parse/mix/luminance/contrast/retone + xterm.js's multiplicative
liftForContrast). Knobs are grid-search fitted so the math reproduces the
classic hand-tuned literals (contract-tested). The display shim renders
authored palettes RAW and only rescues near-invisible colors. Boot reads
the last resolved theme from $HERMES_HOME/tui-theme-boot.json so the first
frame paints in the right palette (no default-dark flash), and the
placeholder cursor follows the bubbles textinput pattern.

* perf(tui): skin switches stay hot — MCP gating, pooled reload, swap repaint

Four responsiveness/hardening fixes surfaced by rapid /skin switching:
config.get mtime now carries an mcp_rev hash so the TUI reloads MCP only
when MCP-relevant config changed (cosmetic /skin writes cost seconds of
reconnects before); reload.mcp runs on the RPC pool serialized by a lock
(inline it froze the stdio reader for the duration of a flapping server's
retry loop — config.set/complete.slash sat unread and the TUI appeared
dead); theme swaps schedule one full repaint (incremental diffs after a
recolor tear — stale cells keep the old palette, read as "shadows"); and
OSC-11 pure-black answers are distrusted universally (unset-default
fingerprint on xterm.js hosts and tmux). Parent EIO zombie fixed: a dead
PTY made every render write throw once a second forever; exit after 5
consecutive dead-stream errors.

* fix(ui-tui): light mode renders the vivid palette RAW, not WCAG-darkened

Pixel-sampled against the reference screenshots: the beloved classic
light-mode look is the vivid authored golds rendered essentially raw
(#FFD700 shows as bright #F5C242) — transparent terminal profiles apply no
contrast lift of their own, and pre-darkening every foreground to WCAG 4.5
produced the reported mustard mud. Light-mode display floors become
near-invisible rescues only (1.18 display / 1.6 semantic; dark keeps
1.45/2.2); the default skin ships a fills-only light_colors OVERLAY
(polarity-flip the navy menu/status fills, foregrounds inherit the vivid
colors), and themeForSkin merges polarity overlays instead of replacing.
Palette audit reworked: base colors audited fully, overlays for valid keys
and fill polarity.

* fix(ui-tui): eradicate every transparent-terminal black-slab trigger

On terminal.background #00000000 xterm paints "drawn blank" and
attribute-styled cells against an opaque black RGB the user never sees
elsewhere. Verified by PTY byte capture + stateful SGR replay that we emit
no black backgrounds — then removed every trigger: the banner's opaque
space-fills, the scrollbar's non-scrollable space column and SGR-dim
track, the placeholder's SGR inverse cursor and dim fallback, and the bold
full-width banner rule. Chrome styling is now explicit truecolor only:
scrollbar thumb rides primary (accent on hover/drag) over a blended track,
the placeholder cursor is a theme-colored chip (48;2 bg + luminance-picked
ink), hints always carry an explicit 38;2 foreground.

* fix(ui-tui): session-panel hierarchy + polarity-proof placeholder tone

Tool/skill rows rendered labels in muted and member lists in text — which
inverts per skin (muted is the strong family tone on gold skins but the
weak gray on blue ones; slate/poseidon read backwards). Labels now lead in
the theme's label tone, values recede via an explicit fade toward the
surface; audited across all 9 built-ins x both poles. The composer
placeholder lands on muted — the "(and N more toolsets…)" tone — a
mid-luminance family color that reads receded on both poles even when
polarity detection is wrong.

* feat(ui-tui): OSC-10 foreground polarity tiebreaker for transparent terminals

Transparent profiles make OSC-11 useless (xterm reports the unset default,
pure black, regardless of the composited surface) so polarity detection
lagged editor theme flips. OSC-10 reports the theme's REAL foreground on
those hosts — its luminance reveals the pole. hermes-ink grows a foreground
slot (shared reportedColorSlot factory), App.tsx queries both in the
startup batch (background first so a trusted answer wins without churn),
and the app commits an inferred pole only when the background was
distrusted AND the foreground is decisive (bright=dark theme, dark=light;
mid-grays and #000/#fff defaults commit nothing). User pins still outrank.

* fix(ui-tui): session-panel fade anchors on muted, not the surface — readable on every pole

mix(text, surface) was invisible whenever text was already pale (the
light-rendered default: cream blended toward white = nothing) and inherited
wrong-polarity detection through the surface. mix(muted, text, .5) is
pole-proof: muted is mid-luminance by construction, so the midpoint stays
readable everywhere. Audited 9 skins x both poles: 2.0-2.9:1 on white,
5.6-9.3:1 on dark (worst case 1.92 for a light-authored skin on dark).

* style(skins): default light overlay = goldenrod ladder, not neon or mustard

On white, the vivid #FFD700/#FFBF00 read as glare and the WCAG-darkened
mustard reads as mud. The sweet spot is the statusbar's goldenrod family
(hue kept, saturation tamed, mid luminance): title #C8961E, headers
#D89B04, labels #A97E10, muted #B8860B unchanged, warm bronze ink body
#5C4718, deepened semantics + shell blue. Hierarchy on white: ink 8.9:1 >
fade 5.2 > label 3.7 > muted 3.3 > title 2.7 > headers 2.4. Dark mode
renders the vivid block untouched (explicitly approved as-is).

* refactor(ui-tui): DRY the chrome primitives — shared scrollbarColors + hintRgb

scrollbarColors(t, hover, grabbed) in overlayPrimitives is now THE scheme
for both scrollbars (was duplicated formulas); textInput's hex parsing
collapses into one hintRgb helper feeding colorizeHint and hintCursorCell.
Comment bloat trimmed. No behavior change — 1243 tests byte-green.

* fix(ui-tui): completions popover — aligned name track + neutral descriptions

Two-column grid: the name track auto-sizes to the widest visible command so
descriptions align in their own column instead of running under the names.
Descriptions render in the neutral statusFg gray — label and muted are
near-twins on the gold skins, which made command and description read as
one unparseable run.

* refactor(ui-tui): every selectable list rides the shared selection chip — zero inverse left

/agents, /journey, model picker, skills hub, plugins hub, pet picker, and
the approval/confirm prompts all used SGR inverse for the active row —
terminal-interpreted against unknowable defaults (black slab on transparent
profiles) and visually divergent from completions/session-switcher. New
spreadable chipRowProps(t, active) in overlayPrimitives (chip bg + lifted
ink + bold; spread after `color` so chip ink wins) converts each site to
the one selection treatment. rg confirms zero inverse={} remaining in
components/.

* fix(ui-tui): overlay width caps are absolute — clampOverlayWidth (Copilot review)

Every picker/hub forced width >= 24 AFTER applying the caller's maxWidth,
so a grid cell narrower than 24 (or a FloatBox cell under 28 with its 4
cols of chrome) overflowed and clipped at the terminal edge. One shared
clampOverlayWidth(preferred, maxWidth, min=24): the caller's cap is
ABSOLUTE (a cell knows its budget), the usability floor applies only when
the cap allows it, uncapped keeps the old floor semantics. Five call
sites (model/pet pickers, skills/plugins hubs, session switcher) route
through it; the grid-test FloatBox drops its own 24 floor. Contract-tested
including the sub-floor cap case from the review.

* fix(tui): reload.mcp lock scope + coalesced refresh + macOS polarity flip (Bugbot)

Three real findings from the review of the reload.mcp pooling + OSC-10 work:

- Lock released too early: the leader now holds _mcp_reload_lock across
  shutdown+discover AND its own agent refresh — releasing after discover let
  a second reload tear the registry down while the first was still reading it
  to rebuild the session's tool snapshot.
- Coalesced reload skipped the agent refresh: a follower returned
  "reloaded" without rebuilding ITS OWN session's snapshot, so a coalesced
  session kept stale tools. Followers now wait, then refresh their agent
  against the freshly-built registry (under the lock, skipping the redundant
  shutdown/discover). Shared _finish_reload tail for the `always` opt-out.
- macOS AppleInterfaceStyle fallback never set `resolved`, so a late OSC-10
  foreground reply could re-flip the committed inference (visible churn). It
  now marks resolved; a real OSC-11 background measurement still corrects it
  (that listener intentionally doesn't gate on resolved — measurement beats
  inference).

Bugbot's other four findings were diff-truncation false positives (color.ts,
themeBoot.ts, and the mcp_rev/light_colors/dark_colors types all exist;
typecheck + build green). 384 tui_gateway + TS suites pass.

* fix(tui): mcp_rev includes mcp_servers; reload survives leader failure; /theme persists first (Bugbot r2)

- mcp_rev hash now covers `mcp_servers` (the server DEFINITIONS the classic
  CLI watches) alongside `mcp`/`tools` — editing a server previously bumped
  mtime but not mcp_rev, so the TUI skipped reload.mcp and new servers never
  connected until a manual /reload-mcp.
- reload.mcp coalescing survives a failed leader: a completed-generation
  counter (bumped only after a successful shutdown+discover) gates the
  follower. If the leader threw (flapping server) the follower re-runs the
  full reload itself instead of returning a bogus success over an empty
  registry.
- /theme applies AFTER config.set confirms (mirrors /indicator) with a
  guardedErr catch — a failed persist no longer leaves the session showing a
  theme that reverts on restart.

384 tui_gateway + 1246 TS tests, lint, build green.

* fix(ui-tui): first-swap repaint + config-auto respects shell theme pin (Bugbot r3)

- commitTheme compares the first commit against the SEED theme uiStore
  mounted with (boot cache/default), not null — a cold start that resolves
  to a skin differing from the default previously skipped the anti-tearing
  forceRedraw on that first swap.
- applyConfiguredTuiTheme('auto') now clears only a pin CONFIG set (tracked
  via configPinnedTheme), never a HERMES_TUI_THEME the user exported in their
  shell — that env override outranks auto-detection per detectLightMode's
  documented priority, and a config hydrate was wiping it.

(Bugbot's recurring "GatewaySkin missing paired palette fields" is a
diff-truncation false positive — gatewayTypes.ts declares light_colors/
dark_colors, typecheck green.) 81 handler + build/lint green.

* fix: restore package-lock.json @esbuild platform entries (Bugbot r4)

The branch had stripped every node_modules/@esbuild/* optional-platform
entry from the lockfile (442 deletions, 0 additions) — collateral from an
earlier local @esbuild/darwin-arm64 reinstall that rewrote the lockfile to
this machine's platform only. esbuild still declares them as
optionalDependencies, so `npm ci` on Linux CI would skip the platform
binary and break Vitest / ui-tui builds. No dependency was added on this
branch (the only package.json delta is a dev `visual` script), so the
lockfile is restored to match main exactly.

* fix(ui-tui): seed mcp_rev baseline at startup so first cosmetic write doesn't reload MCP (Bugbot r5)

The startup config.get seeded mtimeRef but not mcpRevRef; after a normal
boot mtime is already non-zero so the poller's baseline branch never runs,
leaving mcpRevRef empty. The first /skin (or other cosmetic) write bumped
mtime with an unchanged mcp_rev, and empty !== hash tripped a full
reload.mcp — exactly the reconnect this optimization removes. Seed the
revision alongside mtime at boot.

* fix(ui-tui): record config theme pin even when env already matches (Bugbot r6)

Regression from the r3 shell-pin fix: applyConfiguredTuiTheme('light'|'dark')
returned early when HERMES_TUI_THEME already matched, leaving
configPinnedTheme false — so a later '/theme auto' refused to clear the pin
and the session stayed forced to the old mode while config said auto. Set
configPinnedTheme before the match short-circuit.

Bugbot's other three r6 findings (GatewaySkin paired-palette fields,
ConfigMtimeResponse.mcp_rev, picker maxWidth props) are diff-truncation
false positives — all declared in gatewayTypes.ts / the picker prop
interfaces; typecheck is green.

* feat(ui-tui): shimmer skeleton for the lazy tools section

The lazy-loaded Available Tools section rendered a BLANK gap that popped
when data landed. It now shows animated shimmer rows shaped like the real
content (label block + value run, diagonal band sweep), and the summary
line prints "… tools · … skills" instead of "0 tools · 0 skills" while
counts load.

components/loaders.tsx ships the primitives (shimmerSegments band math,
Shimmer, useShimmerPhase, ShimmerRows) — colors are caller-owned theme
tones, one interval per composition. Cherry-picked from the SDK-shape
branch so the skeleton lands with this PR; the widget-SDK consumers stay
in the follow-up.

* fmt(js): `npm run fix` on merge (#68938)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* fmt(js): `npm run fix` on merge (#68976)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* fmt(js): `npm run fix` on merge (#68977)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* test(mcp): stamp breaker-open time on the monotonic clock, not a literal (#69003)

test_session_expired_retry_waits_for_new_session hardcoded
_server_breaker_opened_at["hindsight"] = 123.0 to simulate a circuit breaker
whose cooldown has already elapsed. But the breaker in tools/mcp_tool.py
compares that stamp against time.monotonic() (age = monotonic() - opened_at,
elapsed when age >= _CIRCUIT_BREAKER_COOLDOWN_SEC). time.monotonic()'s origin
is arbitrary and small on a freshly-booted CI container, so age worked out to
only a few seconds there (< the 60s cooldown) — the breaker stayed open, the
half-open probe never fired, and the retry returned the "unreachable" error
instead of "bank ok". It passed on long-uptime dev boxes (large monotonic)
and failed under CI, with the reported "Auto-retry available in ~Ns" drifting
run to run as the container's monotonic clock varied.

Stamp opened_at relative to the same clock the code reads
(time.monotonic() - _CIRCUIT_BREAKER_COOLDOWN_SEC - 1.0) so the cooldown is
provably elapsed regardless of the monotonic origin, exercising the intended
half-open transition deterministically.

* fix(windows): share one bounded, tree-killing git probe across both call sites (#68997)

subprocess.run(["git", ...], timeout=...) deadlocks on Windows: run()'s
post-timeout cleanup calls an unbounded communicate() after killing git.
Killing the PATH-resolved launcher can leave a suspended descendant git.exe
holding duplicates of the captured stdout/stderr handles, so the pipes never
reach EOF and the reader-thread join blocks forever — leaking a process +
two reader threads per fired timeout (the accumulating git.exe load behind
Windows Defender CPU spikes).

Two fail-open probe call sites had this identical flaw:
  - tui_gateway/git_probe.py::run_git — on the Desktop agent-build path
    (_start_agent_build -> _session_info -> branch() -> run_git), where the
    hang turned an optional branch label into "agent initialization timed
    out" (#68609).
  - agent/coding_context.py::_git — hangs the agent turn inside
    build_coding_workspace_block under an ACP host (#66037).

Consolidate both onto one shared bounded_git_probe() in
hermes_cli/_subprocess_compat.py (both files already import from there, so
no new import surface):
  - explicit communicate(timeout), then on ANY failure a tree-kill —
    proc.kill() AND, on Windows, best-effort taskkill /T /F so the suspended
    descendant that holds the pipe writers dies too — plus a bounded 1s
    post-kill drain; if the pipes are still held they're abandoned (the
    orphaned reader threads are daemonic and cost nothing).
  - fail open to "" on every path: spawn error, timeout, kill() raising
    (access denied / already reaped — a raise inside the except handler
    previously escaped the contract), and non-timeout communicate() failures
    now also terminate the child instead of leaving it running.
  - the taskkill spawn can't re-enter the deadlock class: it captures no
    pipes (DEVNULL), so its own timeout cleanup has no reader threads to join.

Normal-path spawn contract is preserved byte-for-byte: PIPE/PIPE/DEVNULL,
text + utf-8 errors="replace", hidden-window creationflags on Windows only,
nonzero returncode -> "". Each call site keeps its own timeout (1.5s / 2.5s).

Supersedes #68622 (Sora-bluesky — git_probe fix + tree-kill) and #66038
(iamwongeeeee — coding_context fix), folding both into one shared helper so
the two sites can't drift and every timeout tree-kills the descendant. Tests
consolidated onto the helper, incl. the previously-missing assertion that a
Windows timeout escalates to taskkill /T /F.

Co-authored-by: Sora-bluesky <sora.bluesky.dev@gmail.com>
Co-authored-by: iamwongeeeee <wykim777@naver.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* fix(tui): revision-aware reload.mcp — an ack now means the revision was LOADED

Review on #20379, finding 1 (High). Two ways an MCP config revision could
be silently acknowledged without ever being applied:

Client: the poll advanced its accepted mcp_rev BEFORE calling reload.mcp,
and quietRpc collapses failures to null — a reload that failed against a
temporarily broken server left the revision recorded as applied, and no
subsequent poll retried until an unrelated MCP edit. The handshake is now
syncMcpReload(): send the observed rev with the request, advance `accepted`
only when the server answers status=reloaded (to the server's loaded_rev,
falling back to the requested rev on older gateways), and re-compare on
EVERY poll tick — decoupled from mtime — so a transient failure heals on
the next tick. An in-flight guard stops the 5s poll from stacking requests
behind a slow reload.

Server: generation-only coalescing let a follower triggered by revision B
ack against revision A's registry when the config changed under a slow
leader. The leader now re-hashes the MCP-relevant config after discovery
and repeats until stable (bounded), records _mcp_reload_loaded_rev, and a
follower coalesces only when the revision it was asked to load matches —
otherwise it re-runs the full reload itself. Responses carry loaded_rev.

Deterministic tests for the exact failure sequences: failed reload → no
ack, no generation advance; A-then-B overlap → follower re-runs; matching
rev → coalesces; failed leader → follower re-runs; legacy no-rev callers
keep generation-only coalescing (thread ordering via an instrumented lock,
no sleeps). Client: 6 vitest cases on the ack/retry/in-flight contract.

* fix(ui-tui): boot theme cache gets provenance — a stale hint can't pin the terminal

Review on #20379, finding 2 (High). The boot cache seeded the previous
session's background into HERMES_TUI_BACKGROUND, the same slot a CURRENT
OSC-11 answer occupies — so a cache written on a light terminal pinned a
now-pure-black terminal to light forever: the new OSC-11 #000000 answer is
distrusted by design, the pure-white OSC-10 foreground is distrusted too,
and the macOS appearance fallback refuses to run while the slot is set.

Seeding now records provenance (themeBoot.seedBootEnvironment, extracted
and testable against a passed env). When the current terminal answers the
background probe with the untrusted fingerprint, the gateway handler calls
invalidateBootBackground(): the slot clears ONLY while it still holds the
seeded value (a trusted answer that overwrote it is authoritative), OSC-10
gets first claim in the same startup batch, and a short settle pass re-
derives from the live fallback chain if nothing answered.

The cache is also pin-coherent now: commitTheme persists the config mode
pin (display.tui_theme) alongside the resolved theme + physical background.
Previously "/theme light" on a dark terminal cached a light theme next to a
dark background — the next launch painted light, flipped dark when the skin
resolved against the seeded background, then flipped light again on config
hydration: the exact multi-stage flash the cache exists to eliminate. The
seeded pin counts as config-owned (bootSeededPin), so a later 'auto' can
still clear it instead of mistaking it for a user shell export.

Tests cover the review's sequences: stale-light cache vs current dark
terminal (invalidate → fallback chain), stale-dark vs ambiguous light,
pinned light on a dark physical background across restart (and the
inverse), trusted-overwrite protection, and the explicit-signal guards.

* fix(ui-tui): stacked dialog gets input before the grid under it

Review on #20379, finding 3 (Medium). /grid-test's `d` opens a dialog on
top without clearing the grid, but the grid's input branch ran FIRST — so
Esc/q/Enter mutated the hidden grid (close/unzoom/promote) instead of
closing the visible dialog, contradicting its "Esc/q/Enter close" hint.

Input routing now follows visual stacking: the dialog/grid dispatch is
extracted into handleStackedModalInput() with the dialog branch first, the
hook consumes through it, and tests drive the real dispatch against the
overlay store — each advertised close key closes only the dialog (grid
byte-identical), grid keys don't leak through while the dialog is up, and
the same keys route to the grid again after it closes.

* fix(ui-tui): make `npm run visual` portable off POSIX — zero new deps

Review on #20379, finding 4 (Medium). Three portability defects in the
visual verification harness:

- `FORCE_COLOR=3 COLORTERM=truecolor tsx ...` POSIX env assignment does not
  work under the Windows npm command shell. The script is now a plain Node
  launcher (scripts/visual/run.mjs) that sets the env itself and spawns tsx
  via require.resolve('tsx/cli') — no cross-env, no shell syntax.
- Hardcoded /tmp/tui-visual.{html,png} resolve to a drive-root path like
  C:\tmp on native Windows (and fail when that directory doesn't exist).
  Both scripts now derive the output directory from a shared paths.mjs
  helper: os.tmpdir()/hermes-tui-visual (created recursively;
  HERMES_TUI_VISUAL_DIR overrides for CI or side-by-side runs).
- electron was undeclared by ui-tui and only worked via hoisting luck. The
  launcher now resolves it EXPLICITLY from the install tree the desktop
  workspace already provides (require('electron') in plain Node returns the
  binary path), with an ELECTRON_BIN override and a clear error pointing at
  the repo-root install when it's absent — instead of declaring a second
  ~100MB dependency on a TUI workspace for a dev-only harness.

Also fixes the trailing-whitespace line in render.tsx that `git diff
--check` flags. Verified end-to-end: render writes the HTML scene sheet
and the electron shot step produces the screenshot from the tmpdir path;
the missing-electron error path prints the guidance message.

* perf(ui-tui): shimmer loaders share one clock and stop after a bounded period

Review on #20379, finding 5 (Perf). Every ShimmerRows mounted its own 90 ms
setInterval — the session panel can show lazy skills AND lazy tools at
once, and a lazy watch session stays lazy indefinitely, so an otherwise-
idle TUI ran ~22 React state updates per second forever.

All shimmer compositions now subscribe to a single module-level clock: one
interval regardless of how many skeletons are on screen, updates delivered
in one timer callback so React batches them into a single render pass, and
the interval is torn down with the last subscriber. Each mount's animation
is also bounded (SHIMMER_ANIMATE_MS, 30 s): after the budget the skeleton
freezes in place — it still reads as "loading" — and stops costing renders
entirely.

Tests: fake-timer coverage that N subscribers share one timer in lockstep,
the interval stops with the last unsubscribe, and a late subscriber
restarts the clock cleanly.

* fmt(js): prettier pass on the new review tests

* chore: gitignore node_modules symlinks, not just directories

Worktrees symlink node_modules to the main checkout; the dir-only
node_modules/ pattern doesn't match symlinks, so one slipped into a
commit and broke npm ci on CI (ENOTDIR). Dropping the trailing slash
matches both.

* fix(desktop): stop long-session transcript from drifting to old turns (#69019)

content-visibility:auto on turn groups (perf: off-screen turns skip
style/layout/paint) pairs with contain-intrinsic-size:auto, which only
remembers a turn's size after it renders. A turn that finished streaming
near the bottom had its smaller mid-stream size remembered; once it
scrolled off the top edge and got skipped, it collapsed to that stale
height. With overflow-anchor:none the viewport can't self-correct, so the
stick-to-bottom lock drifts and the view creeps up over older turns — the
'long session eventually shows old responses' visual glitch.

Exempt the newest turns (live tail) from virtualization so a turn is only
ever skipped after its layout has settled at its final size (remembered ==
real -> skipping changes no height). Off-screen older turns still skip, so
the dialog/popover whole-document recalc win on long transcripts is kept
(it scales with the hundreds of old turns, not the small tail).

* ci: retrigger (transient setup-uv manifest fetch flake)

* feat(ui-tui): widget-app SDK — registry, host, dispatch; demos become apps

The SDK the desktop app already has, ported to the TUI: a WidgetApp contract
(id/help/mode/init/reduce/render/usage), a registry, and a host that owns the
active widget, routes input to its reducer, and renders it. The grid-test and
dialog-test debug surfaces are reimplemented as widget apps instead of bespoke
overlay state, and slash commands are generated from the registry. Input for an
open widget is owned by the active app (supersedes the demo-only stacked-modal
routing) — the single active widget enforces topmost-owns-input structurally.

* feat(ui-tui): weather reference app — the async-data contract, themed ASCII art

/weather [location]: wttr.in current conditions behind a Dialog, art bucket
table-driven off WWO weather codes, every tint a theme family tone (sun =
primary, rain = shell blue, thunder = warn). Proves the async story the
demos don't: init returns a loading phase and fires the fetch; results land
through the new host.updateWidget, which patches state ONLY while the app
is still active — a late resolution can never resurrect a closed app or
clobber a different one. `r` refetches; Esc/q/Enter close.

Four async-contract tests (loading→ready via updateWidget, late-resolution
guard, error phase, keymap). 1253 TS tests green.

* feat(ui-tui): ambient widget mode — registry-driven slash catalog + in-flow dock

Widgets can render as ambient (glanceable, non-blocking) instead of modal,
docked in the normal layout flow above/below the status bar rather than taking
over the screen. The slash catalog is generated from the widget registry so new
apps surface automatically, and /ticker lands as the first live-animation
ambient demo.

* feat(ui-tui): self-authored widgets — user-widget loader, hot-load, skill

Hermes can write its own widgets: a loader discovers $HERMES_HOME/tui-widgets/*.mjs,
fs.watch hot-loads them the moment they land (no restart), and a tui-widgets skill
teaches the agent the contract and the openWidget-at-register auto-open recipe.
Load/error/remove events announce themselves in the transcript; a lazy intro
skeleton covers the first paint.

* feat(ui-tui): widget primitives — charts, accordion, shimmer, stable streams

Reusable render primitives the SDK exposes to widget authors: sparkline/gauge/
hbars chart helpers (dimension-stable so live updates never resize the card),
an Accordion for expand/collapse sections, animated shimmer loaders, and a
streams demo that no longer reserves a phantom icon column on unfocused titles.

* docs(skill): tui-widgets — auto-open recipe (openWidget at end of register)

* feat(ui-tui): ambient zone system + widget crash boundary

A full placement grid so the agent can put a widget where it asks — dock-top/
bottom and corner zones, with corners as reserved rails that take real space
instead of floating over content. A per-widget error boundary plus lenient
ShimmerRows means generated widget code can't crash the TUI.

* refactor(ui-tui): host placement router + grid-test width-floor fix

host.tsx collapses to one placement router over a shared render context, and the
grid-test app drops its width floor too (carrying the #20379 review rule). Final
formatting pass folded in.

* feat(themes): cross-surface theme SDK — one skin themes CLI, TUI, and desktop

Make the Python skin engine the single source of truth for a canonical theme
shape consumed by every surface, so a skin authored in $HERMES_HOME/skins/*.yaml
(by a user or by Hermes from a prompt) themes the CLI, TUI, and desktop GUI at
once — the theme analogue of the plugin SDK.

- @hermes/shared: canonical `HermesSkin` token shape + `SKIN_COLOR_TOKENS` enum,
  consumed by both TS surfaces (TUI `GatewaySkin` and desktop dedup onto it).
- Desktop: `skinToDesktopTheme` resolver (skin → CSS-var palette, VS Code-style
  derive-from-seed) + `backend-sync` that registers backend skins into the theme
  registry (Appearance/Cmd-K/`/skin`) and applies on a real change. Seeds on
  gateway.ready (never stomps a persisted pick), applies on skin.changed and the
  post-turn `config.get skin` poll (catch-all for agent-edited config.yaml).
- TUI: `fromSkin` now maps the status bar + `background` keys it was dropping.
- Gateway: `config.get skin` also returns the full resolved palette (additive).
- Skill: `hermes-themes` teaches the agent to author + activate a skin.

Each surface keeps its own normalizing resolver (ansi for the TUI, CSS vars for
the desktop, prompt_toolkit/Rich for the CLI).

* fix(themes): activate skins via `hermes config set`, never a config.yaml hand-edit

The skill told the agent to `patch` display.skin into config.yaml; a stray indent
corrupts the file and breaks the live gateway (the reported "/ menu broke"), and
a raw file edit never live-applies in a running CLI/TUI ("nothing happened").
Route activation through the safe writer (`hermes config set display.skin`), and
state plainly that a tool call can't hot-switch a running CLI/TUI — the user runs
`/skin <name>` (desktop still auto-repaints on the next turn).

* feat(themes): agent-authored skins switch live via a gateway skin watcher

A skin Hermes activates (`hermes config set display.skin X`) or recolors in
place now goes live on every surface (CLI, TUI, desktop) within ~half a
second, on its own — no `/skin`, no tool-hook timing, no user action.

A gateway daemon polls the resolved skin signature `(name, active-file mtime)`
every 0.5s and broadcasts `skin.changed` on any real move — a name switch OR a
live color edit to the active skin. It routes through the SAME path `/skin`
uses, so all surfaces repaint identically. The watcher seeds its baseline at
gateway.ready (stdio + ws) so it only fires on a real change; the `/skin` RPC
seeds the baseline too so it never double-broadcasts.

Subsumes the desktop's post-turn `config.get skin` poll (its skin.changed
handler already applies).

* feat(themes): TUI paints its own background from the skin (OSC 11)

The TUI inherited the terminal's background; now a skin's `background` paints the
whole surface via OSC 11 when a skin is applied, and clears back to the terminal
default (OSC 111) on revert and on exit (ridden in through resetTerminalModes).
Opt-in: a skin with no `background` leaves the terminal untouched, and the
restore only fires if we actually painted. Desktop already themed its own bg;
this closes the loop so Hermes owns its background on every surface.

* feat(themes): element tokens (ui_tool, ui_thinking) + skinnable diffs

Theming was semantic-only: the gold tool `●` was `accent`, shared with
headings/links/chevrons, so "recolor tool calls" was impossible and the agent
had no key to point at. Add `ui_tool` (● + tool spinner) and `ui_thinking`
(reasoning body) tokens that fall back to accent/muted — defaults unchanged,
but now independently settable. Make diffs skinnable too (`diff_*`), which
fromSkin previously hardcoded. Document the full element→key map in the skill so
Hermes knows which knob turns what.

* fix(themes): tweak the ACTIVE skin in place, never fork default

Changing one color ("make the tool ● cyan") forked `default` — which has no
`background` — so applying it reset the terminal to its own (black) default and
dropped the active skin's palette. Teach the skill to edit the active skin's file
in place for a tweak (watcher repaints on the mtime bump), and to fork a built-in
only by carrying its full palette. Hard pitfall: never fork `default` for a tweak.

* feat(themes): `hermes skin set` — deterministic one-color tweak, bg untouched

Changing a single color kept wrecking the rest because the agent hand-authored a
new skin (often from `default`, which has no `background`, resetting the terminal
to black). Add `hermes skin set <key> <hex>`: edits the ACTIVE skin's one key in
place (a built-in is forked into an editable copy carrying its full palette), so
everything else — background included — is preserved. Plus `skin use` / `skin
list`. The skill now points tweaks at this command instead of hand-authoring.

* feat(themes): dedicated code-syntax palette keys

Code highlighting reused brand tokens (accent/text/border/muted), so it couldn't
be themed independently. Add syntax_string/number/keyword/comment skin keys →
syntax* theme tokens (defaulting to those brand tokens, so defaults are
unchanged) and point the highlighter at them. Documented in the element→key map.

* test(themes): E2E live skin switch — config write → skin.changed broadcast

* fix(themes): reconcile element/syntax tokens with main's derive+adapt pipeline

Element tokens (ui_tool/ui_thinking), skinnable diffs, and code-syntax keys
flow through buildPalette → adaptColorsToBackground instead of a hand-mapped
color block, so they inherit #20379's contrast/polarity machinery. thinking
and syntaxComment track the EFFECTIVE muted (banner_dim override included);
the skin's `background` feeds the surface (it also paints the terminal via
OSC 11); statusFg falls back through ui_text/banner_text. Tests assert the
routing/independence contracts rather than pre-adaptation hexes.

* fix(themes): apply a runtime switch back to default on the desktop

ingestBackendSkin returned early for name === 'default' even when
apply=true, so a real runtime switch to the default skin (/skin default
on CLI/TUI, or config.set display.skin=default) emitted skin.changed but
never repainted the desktop. 'default' is no-opinion on the PALETTE (the
desktop keeps its own nous default, so we still never register a converted
theme under it), but it IS a valid apply TARGET: setTheme normalizes
'default' -> nous, so switching back repaints to the desktop default.
Skip only the registry step for 'default' and let it flow through the
apply guard. Addresses Copilot review.

* fix(tui_gateway): serve candidate-inclusive display on warm/live resume

#65919 persists verification candidates (finish_reason=verification_required
/ verify_hook_continue) to state.db but collapses them out of the in-memory
model history via repair_message_sequence. The eager session.resume + REST
paths read the verbatim display lineage (candidate present), but the
warm/live-reuse payload (_live_session_payload) built its user-visible
messages from the collapsed in-memory model history — so switching to a
still-live session dropped the substantive verification answer that a cold
resume of the SAME session showed. That divergence is the cross-session
"substantive text vanishes on switch" class, and the direct sibling of the
resume-duplication regression fixed in #68149.

Reconcile the persisted display lineage (candidate-inclusive, the same
get_messages_as_conversation(..., include_ancestors=True) read the eager
resume + REST paths use) with the fresh in-memory tail in
_live_visible_history, so all three surfaces agree by construction while a
not-yet-flushed live turn is still shown. Extracted
_reconcile_display_with_live as a pure, DI-testable function (anchors on the
last persisted row's (role, text); appends only the uncovered in-memory tail;
trusts the DB display when the tail can't be anchored).

Tests: unit coverage for candidate-inclusion, freshness, empty/raising-DB
fallback, and the combined candidate+fresh-tail case. The existing freshness
guard (test_session_resume_live_payload_uses_current_history_with_ancestors)
stays green.

* fix(tui_gateway): candidate-inclusive display on child-watch resume + E2E

Complete the #65919 warm/live-payload fix across its sibling path and add
real-SessionDB cross-builder coverage.

- Child-watch (lazy) resume: the delegated-subagent watch window served
  _history_to_messages(repaired_history) for its user-visible messages, which
  collapses out persisted verification candidates just like the warm-payload
  path did. Build the visible messages from the verbatim child-only display
  projection (repair_alternation=False) while the repaired history still feeds
  live replay; fall back to the repaired history if the display read fails.

- E2E cross-builder consistency (real SessionDB, not mocks): a persisted
  verification candidate is collapsed out of the model projection but kept in
  the display projection, and _live_visible_history now equals the eager
  session.resume display projection (candidate present). Adds the combined
  candidate + fully-flushed-second-turn case and a lazy child-watch handler
  test that asserts the candidate survives in resp["result"]["messages"].

* fix(cli): add skin to _BUILTIN_SUBCOMMANDS for plugin gating

The new hermes skin subcommand must be declared so startup plugin
discovery can skip when the user targets it.

* fmt(js): `npm run fix` on merge (#69048)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* feat(desktop): Billing page revamp — current-plan card, in-app plans view, tier art (#68722)

* feat(desktop): revamp Billing page — plan card, in-app plans view, tier art

Reshape the desktop Billing settings per wayfinder ticket 09. New page order:
Plan → Payment → One-time top-up → Automatic refill → Usage, with the at-a-glance
summary strip unchanged at the top.

- CurrentPlanCard replaces the old Subscription row: tier name + price + renewal
  and at most one button — "View plans" (free/no-sub + can_change_plan),
  "Change plan" (subscriber + can_change_plan), or none for teams / non-changers.
  Teams keep the portal "Adjust plan ↗" link so they are not stranded. The button
  navigates in-app to the plans sub-view.
- bview=plans sub-view mirrors the settings pview/kview pattern (useRouteEnumParam,
  default overview). BillingPlansView renders a grid of PlanCard from live tiers[]
  (is_enabled, sorted by tier_order, free tier included).
- PlanCard: tier art + name + $/mo + monthly credits as dollars ("$110 credits/mo").
  Current tier is highlighted + inert; higher/no-current tiers get "Choose ↗"
  (opens portal with plan=<tierId>); lower tiers are a DISABLED "Downgrade" with a
  caption — downgrades move in-app in ticket 11 (gateway pending-change flow), so
  this PR intentionally links them out/disabled rather than wiring the money path.
- buildManageSubscriptionUrl gains an optional third arg (tierId) → appends
  plan=<tierId>. Signature kept identical to draft PR #68666 for a trivial rebase;
  NAS #748 validates the param server-side.
- Tier art: four NAS hero webps rendered as ~40px thumbnails over a Nous-blue well
  with per-tier blend modes (the only place Nous blue appears). Keyed by lowercase
  tier NAME (free/starter→connect, plus→memory, super→automation, ultra→sandbox);
  unknown name → text-only card. Imported via vite static imports for packaged
  file:// + webSecurity.
- Top-up vs auto-refill disambiguated by section label + first sentence: "One-time
  top-up" / "Buy credits now" vs "Automatic refill" / "Refill when low" (configured
  copy reads "Charges $X automatically when your balance falls below $Y.").
- Variant-A auto-refill editing: Manage swaps the row's left side (caption → the two
  $ fields with a pre-allocated error line) and the action column (Manage → Save/
  Cancel) in place, with the row height reserved for the tallest state so the Usage
  section never shifts. Fixes the spurious on-open validation error (errors now show
  only after an edit or a save attempt). Save/disable API calls + confirm-disable
  flow unchanged.
- Remove subscriptionTierChips and the subscription-row chips; reshape (not delete)
  deriveBillingView to expose plan + tiers. Buy-credits row keeps the chips seam.
- Dev fixtures: add free-personal and subscriber-personal (personal orgs, full
  4-tier Free/Plus/Super/Ultra catalog) so the plans view is exercisable.

Tests: update/extend index.test.tsx + use-billing-state.test.ts, add tier-art.test.ts;
delete the old chips tests. Desktop billing suite 70/70 green, typecheck clean.

* fix(desktop): mark the free/lowest tier current (not an upgrade) when there is no subscription

Visual verification caught a spec-fidelity bug: in the plans grid, an account with
no active subscription rendered the Free tier ($0/mo, tier_order 0) as a "Choose ↗"
upgrade — clicking would deep-link the portal to "subscribe to Free".

Ruling: current-card = tier.is_current OR (subscription.current == null AND the tier
is the lowest-order / $0 tier). derivePlanTiers now falls back to the lowest-order
tier as the stand-in current plan when there is no subscription, so the free card
renders exactly like is_current (inert, "Current plan") and — being the lowest order
— no tier can be a downgrade; every paid tier is a "Choose ↗" upgrade.

CurrentPlanCard is unaffected (still "Free" + "View plans"); subscriber-personal is
unchanged (Free stays a disabled Downgrade below the current Plus tier).

Tests: free-personal grid now asserts Free = current/inert, no downgrade state, three
Choose buttons; text-only unknown-tier test gains a free tier so the unknown paid tier
is unambiguously an upgrade. Billing suite 70/70 green, typecheck + lint clean.

* chore(desktop): shrink bundled tier art to 128px thumbnails

The plan-card wells render the art at ~40px; shipping the full landing
images added 2.7 MB to the repo for no visible difference. 128px covers
2x displays; total is now 26 KB.

* fix(desktop): address 6 adversarial-review findings on the Billing revamp

1. Grandfathered current tier (BLOCKER). NAS marks a grandfathered current tier
   is_enabled:false; the enabled-only filter dropped it, leaving currentOrder
   undefined so every lower tier rendered as an actionable "Choose ↗". derivePlanTiers
   now resolves current identity/ordering against the UNFILTERED tiers and keeps the
   grandfathered current tier in the grid as the inert "Current plan" card; downgrades
   classify against its tier_order. (Non-current disabled tiers are still dropped.)

2. Dead plan-card button. derivePlanCard offered "View plans"/"Change plan" purely on
   can_change_plan, but the grid could be empty / current-only and showPlans refused,
   so the button no-oped. It now offers the in-app action ONLY when the grid has ≥1
   actionable (non-current) tier; otherwise it falls back to the portal link.

3. Deep-link bypass. showPlans now gates on the same capability that renders the button
   (view.plan?.action), so a team / non-changer deep-linking bview=plans always falls
   back to overview instead of a grid of live Choose buttons.

4. Lost portal escape hatch. Whenever the card has no in-app action (teams, non-changers,
   refused subscription, empty catalog) it now ALWAYS carries the "Adjust plan ↗" portal
   link built from subscription?.portal_url ?? billing.portal_url — the refusal caption
   no longer promises a portal the UI didn't render.

5. Choose URLs dropping org_id/plan. (a) derivePlanTiers now threads billing.portal_url
   as the fallback base for the Choose URL. (b) buildManageSubscriptionUrl treats the
   hard-coded FALLBACK_PORTAL_BILLING_URL as a last-resort ORIGIN (applying org_id/plan)
   instead of a bare return, so a null portal_url never strips the routing params.

6. Zero-shift on narrow panes. Replaced the magic min-h-28 (under-reserved once the two
   inputs stack below @2xl) with exact reservation: the edit form is always rendered and
   both states share one grid cell ([grid-template-areas:'stack']), invisible+aria-hidden
   when not editing — the row equals the tallest state at every width, no breakpoint math.
   The refusal stays inside the reserved layer.

Tests: +12 (grandfathered current, no-dead-button + empty-catalog portal link, team &
personal deep-link fallback to overview, billing.portal_url-backed Choose URL, fallback
org_id/plan, reserved-form-mounted); updated the two portal-link expectations for §4.
Billing suite 78/78 green; typecheck (app/electron/e2e) + lint clean.

* refactor(desktop): reuse the shared openExternalLink helper in the plans view

* fix(desktop): honor the auto_reload wire contract — null card + disable amounts

A full-stack contract sweep (desktop ↔ shared types ↔ gateway ↔ NAS) surfaced two
real desktop bugs in the auto-refill row:

A. auto_reload.card can be null. The gateway's _parse_auto_reload_card returns None
   for a missing/unknown-kind card and _serialize_billing_state emits `card: null`,
   but the shared BillingAutoReload.card union had no null arm and use-billing-state
   dereferenced `autoReload.card.kind` bare — a crash on the enabled path. Add `| null`
   to the shared union (contract honesty) and guard the read (`card?.kind`); null now
   falls through to the default enabled path, same as a canonical card.

B. Disable was rejected by the gateway. billing.auto_reload unconditionally requires
   threshold + top_up_amount, so `updateAutoReload({ enabled: false })` came back
   invalid_request. (The TUI always sends both; desktop fixture mode stubbed it.)
   disable() now sends the current threshold_usd/reload_to_usd from the autoReload
   prop alongside enabled: false, matching the TUI.

Tests: enabled auto_reload with card:null renders the normal enabled row (derivation
+ render, no crash); disable call carries both current amounts. Billing suite 80/80
green; typecheck (app/electron/e2e) + lint clean.

* fix(tui): guard the nullable auto_reload card in the auto-reload screen

The shared BillingAutoReload.card union gained its honest null arm (the
gateway emits card: null for a missing/unknown card); the TUI's only bare
dereference follows the same default path as a canonical card.

* fix(desktop): align billing inputs to the sm control height

The three billing inputs used an ad-hoc h-8 (32px) next to size=sm
buttons (24px). They now use the control system's size=sm with a
py-[3px] compensation for the input's real 1px border — buttons draw
theirs as an inset shadow, so sm alone still sits 2px taller. All five
controls in the buy row now measure 24px.

* fix(desktop): plan-card actionability + billing view-model hardening

Code-quality review of the Billing revamp (PR #68722).

BLOCKING — a top-tier subscriber (only downgrades/current below them) opened a
plans grid with zero enabled actions AND no portal link. The plan card gated its
in-app button on `tiers.some(state !== 'current')`, which counts the (disabled)
downgrade tiles. It now gates on an actual UPGRADE being present
(`capable && tiers.some(state === 'upgrade')`); with no upgrade the card falls back
to its "Adjust plan ↗" portal link, and the bview=plans deep link (gated on the same
plan.action) falls back to overview.

Reviewer structural items:
- One "plans capability" verdict (personal + can_change_plan + subscription ok) is
  derived once in deriveBillingView and threaded to BOTH derivePlanCard and
  derivePlanTiers; the grid only mints upgrade actions when capable, so the invariant
  lives in one place.
- BillingPlanTierView is now a discriminated union (`current` | `downgrade` w/
  disabledCaption | `upgrade` w/ required action), and BillingPlanCardView is an
  action-XOR-link union — deleting the `tier.action?.url ?? ''` and `plan.link?.url`
  defensive branches in the consumers.
- `findCurrentTier(subscription)` replaces the repeated is_current||id predicate at
  its three sites (plan card price, grid ordering, summary plan line).
- BillingView exposes named `paymentRow` / `topupRow` / `refillRow` instead of an
  `accountRows[]` + three `.find(id)` lookups.
- The auto-refill row that edits in place carries an explicit `manageInApp: true`;
  AutoReloadRow keys off it instead of sniffing the action label/url.
- tier-art header comment no longer cites an internal repo path; dead `?.` removed
  from RowValue (via a destructured const) and the plan-card link handler.

Behavior is identical except the blocking fix. Billing suite 82/82 green; typecheck
(app/electron/e2e) + lint clean.

* refactor(desktop): adopt inline-review nits on the billing plan card

Resolves the inline suggestion threads:
- plan-card gate reads a named `hasActionableTier` = "a tile carries an action"
  (union-safe `'action' in tier`, equivalent to the old upgrade-only check).
- re-narrow link/action inside the click callbacks (`plan.link && …`,
  `tier.action && …`) rather than relying on outer narrowing.

Behavior unchanged; billing suite 82/82 green, typecheck + lint clean.

* fmt(js): `npm run fix` on merge (#69050)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* feat(cli): plan catalog on Free + plan= deep link + top-up/auto-refill copy split (#68689)

* feat(cli): plan catalog on Free + plan= deep link + top-up/auto-refill copy split

Bring the plain (non-TUI) CLI billing surface to parity with the desktop/TUI
billing changes:

- /subscription on Free (admin/owner, interactive) prints the plan catalog
  (name · $/mo · $credits/mo, from the same tiers[] data the TUI uses; monthly
  credits render as dollars). A numbered pick opens the manage-subscription
  deep-link directly with plan=<tier_id> appended.
- subscription_manage_url(state, tier_id=...) appends plan=<tier_id> (the stable
  tiers[] id) when a tier was picked, org_id first — mirrors the TUI's ?plan=.
  The paid change flow's blocked/unknown-preview portal fallback carries plan=
  for upgrades only; downgrades stay generic/native.
- /topup overview splits one-time top-up from automatic refill, the distinction
  stated in each first sentence ("Add funds now — a single charge…" vs "Refill
  when low — charges … automatically …"), keeping "credits" out of the
  dollars-only surface.
- Downgrades remain native (chargeless scheduled change), unchanged.

Updates the CLI-parity section of docs/billing-lifecycle.md and tests under
tests/hermes_cli + tests/agent.

* refactor(billing): share plan-catalog helpers + harden manage-url builder

- subscription_manage_url now preserves unrelated portal query params (parse_qsl,
  popping only the contract-owned org_id/plan) and restricts to http/https schemes,
  matching the desktop URL builder — the function owns the contract.
- Lift the plan-catalog derivation into agent/subscription_view.py so the CLI Free
  catalog and the paid picker/blocked-preview branch share one implementation:
  selectable_tiers (enabled paid, not current, sorted), format_tier_row (name · $/mo
  · $credits/mo — thousands-grouped like the TUI's toLocaleString, credits suffix
  hidden when absent/zero), and is_upgrade(state, tier_id).

* fix(cli): numbered pick, canonical guarded browser opener, partial auto-refill copy

- Free catalog: accept a bare digit as a pick (the shared normalizer only knows the
  confirm-dialog digit aliases, so `1` used to resolve to None → "Cancelled"). The
  Nth digit maps to the Nth printed row.
- Extract one _open_url_in_browser used by every "open the portal" path, applying the
  device-code flows' console-browser / remote-session guard (webbrowser.open returns
  True even for lynx/w3m over SSH) and returning whether a real browser opened.
- Consume the shared selectable_tiers / format_tier_row / is_upgrade helpers from the
  Free catalog, the paid picker, and the blocked-preview branch.
- /topup auto-refill copy: the concrete "charges $X … below $Y." sentence only when
  both amounts are present and finite; otherwise the generic sentence.

* docs(billing): correct CLI-parity rows (drop cross-repo ref, downgrade invariant)

Remove the other-repo PR reference from the manage-URL row, and state the real
downgrade invariant: a blocked downgrade may print the generic manage URL but never
carries plan=<tier_id> — selected-tier deep-links are reserved for new subscriptions
and upgrades.

* feat(desktop): native in-app downgrade — chargeless preview → schedule → undo (#68761)

* feat(desktop): native in-app downgrade (chargeless preview → schedule → undo)

Ticket 11, stacked on the Billing revamp (ticket 09). Downgrades no longer bounce
to the portal — picking a lower tier runs the gateway pending-change flow in-app;
the scheduled state renders on the plan card with an undo. Upgrades keep the portal
deep link.

- api.ts: add previewSubscriptionChange / scheduleSubscriptionChange /
  resumeSubscription wrappers over subscription.preview|change|resume
  ({subscription_type_id} / {}), typed via SubscriptionPreviewResponse +
  BillingMutationResponse (now re-exported from types.ts).
- use-subscription-change.ts (new): useDowngradeFlow (preview → confirm → schedule,
  refetch + onScheduled on success; typed refusals surface via the shared
  BillingRefusalInline, so insufficient_scope drives the existing step-up exactly
  like the auto-reload save, retried in place) and useResumeFlow (confirm-less undo).
  Both accept a `simulate` switch so DEV fixtures click through with canned success.
- plans-view.tsx: downgrade tiles are now an actionable "Downgrade" that opens an
  in-card preview → confirm panel (mirrors the TUI confirm copy: "…takes effect
  <date>. No charge now; you keep your current plan until then."). The scheduled
  downgrade target renders an inert "Scheduled" marker; other lower tiers stay
  actionable (picking one reschedules).
- CurrentPlanCard: when a downgrade is pending, the caption reads "Changes to
  <tier> on <when>." with an inline Undo → resume → refetch. One line, no jumps.
- use-billing-state.ts: BillingPlanTierView gains a `scheduled` state (and drops the
  ticket-09 disabled-downgrade caption); derivePlanTiers matches the pending target
  by name (NAS sends no id for it) before the downgrade branch; BillingPlanCardView
  gains `pending`, derived from current.pending_downgrade_* .
- inline-feedback.tsx (new): extracted openExternal / BillingRefusalInline /
  StepUpInlineAction / InlineMessage so the plans view reuses the step-up-aware
  refusal renderer without a circular import; openExternal now delegates to the
  canonical @/lib/external-link opener.
- dev-fixtures.ts: add `pending-downgrade` (subscriber-personal on Plus with a Free
  downgrade scheduled for Aug 15) for the plan-card pending state + grid marker.

Tests (+16 → 94 green in the billing suite): api wrappers (preview/change/resume +
insufficient_scope refusal); view derivation (pending plan-card state, scheduled
grid marker); confirm flow (preview shown, change called with the right tier_id,
refetch on success, schedule refusal → step-up affordance); undo flow; the
use-subscription-change hooks (preview-refusal retry, cancel, simulate path).
Updated the ticket-09 downgrade tests for the now-actionable tile. typecheck
(app/electron/e2e) + lint clean.

PR (later): base sid/desktop-billing-revamp; retarget to main after #68722 (09) merges.

* fix(desktop): format downgrade credits delta as signed dollars

The downgrade preview rendered the raw wire string ("Monthly credits change:
-88."), violating the "monthly credits are DOLLARS" ruling. NAS sends
monthly_credits_delta as a bare decimal; format it as signed dollars through the
same money formatter ("−$88/mo", sign preserved, abs value formatted). Zero /
absent still hides the line.

Adds formatMonthlyCreditsDelta (exported) + unit tests (negative/positive/zero/
absent) and asserts the rendered "Monthly credits change: −$88/mo." in the confirm
flow. Billing suite 99/99 green; typecheck + lint clean.

* fix(desktop): downgrade flow hardening — concurrency guard, a11y, DEV-gated sim

Addresses the adversarial review of the native-downgrade diff.

- Concurrency: useDowngradeFlow exposes `mutating` (true only while the schedule
  RPC is in flight). While a change commits, every other Downgrade tile and the
  Back button are disabled; the active panel's Confirm/Cancel already lock. The
  plan-card Undo blocks on its own resume via `busy`. (The server also 409s
  overlapping per-org mutations — this is UI honesty, not the only defense.)
- Accessibility: the confirm panel is role="status" aria-live="polite" and takes
  focus on open (tabIndex=-1 container); closing it returns focus to the tile card,
  so keyboard focus is never stranded and the async preview text is announced.
- DEV-gated simulation: the canned preview/change/resume seam is ignored unless
  import.meta.env.DEV, so a production build never takes the simulated branch even
  if a `simulate` prop leaks through.
- Comments: documented the deliberate manual-retry-after-step-up (no auto-replay,
  matching auto-reload) and that name-matching the scheduled target is safe because
  SubscriptionTypes.name is @unique in NAS.

Tests (+5 → 104 green in the billing suite): mutating exposed only during schedule;
simulate ignored outside DEV; other downgrade tiles + Back disabled mid-schedule;
Undo disabled mid-resume; confirm panel role + focus on open. typecheck
(app/electron/e2e) + lint clean.

* fix(desktop): scheduled cancellations, downgrade-flow concurrency, inline nits

Addresses the native-downgrade review threads.

Scheduled cancellations were invisible (NEW review item). subscription.current
carries cancel_at_period_end + cancellation_effective_* and subscription.resume
clears cancellations exactly like downgrades, but the pending-transition helper only
read pending_downgrade_*, so a portal/TUI-scheduled cancellation rendered as a plain
renewal with no Undo. The pending state is now a union — { kind:'downgrade', tierName,
when } | { kind:'cancellation', when } — computed once in deriveBillingView and
threaded to BOTH the plan card and the grid. The card reads "Cancels on <date>." with
the same Undo (resume); the grid shows a Scheduled marker only for downgrades (a
cancellation has no target tier). Precedence: a downgrade wins if both fields are set
(it names a concrete target — the stronger signal), commented at the helper. Adds a
`pending-cancellation` fixture + tests (card copy, undo wiring, no grid marker,
downgrade-wins precedence).

Concurrency: confirm() takes a synchronous scheduling ref (mirroring useResumeFlow)
so two same-tick clicks — before React commits busy='schedule' — cannot fire two
schedule RPCs; the ref clears on every exit (simulated/stale/refusal/success).
useResumeFlow reorders its unlock: a refusal releases immediately, a success holds
runningRef/busy THROUGH the refetch so Undo never re-enables against the still-pending
card. Test: a synchronous double-activation fires one schedule RPC.

Inline nits: re-narrow link/action inside the click callbacks (`plan.link && …`,
`tier.action && …`) instead of relying on outer narrowing / `?? ''`.

Billing suite green (109); typecheck (app/electron/e2e) + lint clean.

* refactor(desktop): move DEV billing simulation behind the api seam

The fixture simulation lived as `simulate` / `simulateResume` prop drills and
`if (simulated)` branches inside the flow hooks, and it could not actually produce
the state it advertised (a simulated schedule never showed the pending card).

Replaced with `createSimulatedBillingApi(fixture)` — a fully in-memory BillingApi
built once, DEV-gated, in BillingSettingsWithDevFixtures where the fixture is known,
and supplied to the whole subtree via a new `BillingApiProvider` (context override on
`useBillingApi`; `null` = the real gateway api). It serves fetches from a mutable copy
of the fixture and its subscription-change mutations WRITE that copy's pending state:
schedule sets a pending downgrade, resume clears a pending downgrade OR cancellation.
Fixture mode now flows through the SAME react-query path (fetch short-circuit deleted;
queries always enabled; an effect refetches on fixture switch), so the click-through
genuinely progresses — schedule → pending card + Undo + Scheduled marker, undo → cleared.

Deleted `SubscriptionSimulation`, `simulationEnabled`, both prop drills, and every
`if (simulated)` branch — the hooks are now production-pure. Added a test driving the
full simulated loop (schedule → pending appears → resume → cleared), plus cancellation
undo and no-shared-mutation coverage. Removed the now-obsolete simulate hook tests.

Billing suite green (110); typecheck (app/electron/e2e) + lint clean.

* refactor(desktop): extract billing row/card components out of index.tsx

Purely mechanical, no behavior change: split the settings billing route file
(1065 → 593 lines) into focused siblings now that the downgrade feature has settled
their final shape.

- billing-amounts.ts — the dollar parse/format/validate/clamp helpers.
- account-row-value.tsx — RowValue (shared by AccountRow + AutoReloadRow).
- current-plan-card.tsx — CurrentPlanCard.
- auto-reload-row.tsx — AutoReloadRow (the in-place auto-refill editor).

index.tsx keeps the page shell, AccountRow dispatch, BuyCredits flow, and the fixture
wiring. Billing suite green (110); typecheck (app/electron/e2e) + lint clean.

* refactor(desktop): tighten the downgrade flow — phase union, previewMessage, tidy shared modules

Polish that composes with the api-seam rework:

- ActiveDowngrade's four nullables become a `DowngradePhase` discriminated union
  (previewing | previewFailed | ready | scheduling | scheduleFailed). Impossible
  combinations (a preview AND a refusal, "ready" with no quote) can no longer be
  represented; the hook and panel branch on one `kind`, and `mutating` is simply
  `phase.kind === 'scheduling'`.
- The five-way ternary in DowngradeConfirm is replaced by a pure `previewMessage(phase,
  fallbackTierName)` helper; the misnamed `caption` className local is renamed `captionCn`.
- inline-feedback.tsx now holds ONLY the shared refusal/step-up pieces: `openExternal`
  moves to its own `open-external.ts` (a thin wrapper over `@/lib/external-link`'s
  `openExternalLink`), and `InlineMessage` moves back into its sole consumer
  (auto-reload-row.tsx).

No behavior change. Billing suite green (110); typecheck (app/electron/e2e) + lint clean.

* fmt(js): `npm run fix` on merge (#69067)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* fix(gateway): hard-exit CLI runner after graceful teardown

* test: update gateway run stub for hard-exit helper

* fix(gateway): hard-exit on KeyboardInterrupt path too

The KeyboardInterrupt handler in run_gateway() was the only exit path
that still used bare 'return' instead of _hard_exit_after_gateway_teardown().
While less common than service-managed restarts, a console Ctrl+C still
leaves the process vulnerable to the same Python finalization hang on
non-daemon worker threads (cron ThreadPoolExecutor jobs). Route it through
the same backstop, with a 'return' guard for test stubs that don't raise
on code 0 (production os._exit never returns).

* fix(cli): pass conversation_history on /new /resume /branch flush

Closes #68454

Root cause: cold-resumed transcript rows lack _DB_PERSISTED_MARKER until a
normal turn flush stamps them. Immediate /new,/resume,/branch flushed with
no history boundary, so every restored row was re-appended to the old session.

Fix: pass conversation_history=self.conversation_history at all three sites
(mirrors #68205). Add offline regression coverage for noop + tail-only write.

Verification: pytest tests/agent/test_session_rotation_flush_cold_resume_68454.py (4 passed)

* test: drop source-grep change-detector from #68480

The three behavior tests (control proves dup, boundary is noop, tail-only
write) fully cover the flush semantics. The source-grep test reading
cli.py + cli_commands_mixin.py as text and asserting a string appears is
a change-detector that breaks on benign refactors without adding coverage.

* test: update mock assertions for conversation_history kwarg

The /branch and /resume flush tests asserted the old positional-only
call signature. Update to match the fix from #68480.

* fix(gateway): make adapter fatal-error handoff cancellation-proof; exit if a platform is stranded

The fatal-error notification runs on the failing adapter's own polling
task, and adapter.disconnect() inside the handler can cancel that task
(its current-task guard misses because _safe_adapter_disconnect runs the
close in a wrapper task). The CancelledError killed the handler between
the fatal log and the reconnect queue, leaving the platform permanently
dead inside a live gateway process. #68447 fixed this for telegram at
the adapter layer; this hardens the shared gateway dispatch so every
platform gets the same protection (qqbot #25505/#29005, photon #68693).

- _handle_adapter_fatal_error now runs the real handler in a detached
  task, awaited through asyncio.shield() so caller cancellation cannot
  tunnel into it (Task.cancel() also cancels the task's _fut_waiter).
- If a retryable platform still ends up neither reconnected nor queued,
  the gateway exits with failure so launchd/systemd KeepAlive restarts
  it instead of running indefinitely with a dead platform (#68693).

Fixes #68693

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: map anoop.mehendale@gmail.com -> anoopmehendale-cue

For PR #69007 salvage (#69112).

* fix(update): isolate systemctl timeouts per gateway unit during fleet restart

A TimeoutExpired from one hermes-gateway*.service used to abort the whole
per-scope restart loop, leaving later profile gateways on pre-update
in-memory code after hermes update. Catch timeouts per unit, continue the
fleet, warn with the exact stale units, and exit non-zero when any remain
unrestarted (#68523).

* test(update): cover fleet restart timeout isolation (#68523)

* fix: refresh vulnerable npm lockfile entries

* chore: AUTHOR_MAP for tinetwork

* fix(compression): prevent stale-budget retry loops

* fix(compression): harden startup route scoping

* fix(providers): align custom route scoping

* test(compression): cover overflow after blocked preflight

* test(providers): cover route URL identity boundaries

* test(providers): cover query path slash identity

* test(providers): complete hermetic route coverage

* test(providers): cover URL whitespace route identity

* fix(providers): fail closed on missing active route

* fix(providers): scope route-owned runtime settings

* fix: restore base_url rstrip, extract should_clear_context_pin helper

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.

* fix(compression): ignore assistant handoff summaries in tail anchor

Assistant-role compaction summaries were treated as the last visible assistant reply after head protection decayed. That pulled the tail boundary back to the summary itself and left zero new turns to summarize.

Exclude internal context summaries from both the visible-reply search and the assistant fallback, mirroring the existing user-role summary exclusion.

* chore: AUTHOR_MAP for McHermes

* fix(telegram): group authz fallback + command sender identity

- authz_mixin: add config.extra fallback for group_allowed_chats
  when observe-unmentioned mode strips user_id from env-var check
- authz_mixin: check adapter allow_from/group_allow_from for
  user authorization from config.yaml without env vars
- telegram/adapter: separate group_allow_from for group chats
  vs allow_from for DMs
- telegram/adapter: preserve sender source for command messages
  so admin-only slash commands work in groups
- telegram/adapter: add _telegram_extra fallback for
  group_allow_from config reading

* fix(telegram): address review findings from PR #67816

- Update test_observed_group_context_preserves_slash_command_text_for_dispatch
  to assert user_id is preserved for COMMAND messages (new correct behavior)
- Add _coerce_allow_set helper to handle both list and comma-separated
  string allowlist inputs (prevents character-by-character iteration bug)
- Include 'channel' in chat_type checks for group-scoped authorization
- Add _telegram_extra fallback for group_allowed_chats (consistent with
  group_allow_from fallback)
- Add AUTHOR_MAP entry for nyaruko@hermes -> tsuk1nose

* fix(telegram): update auth check tests for group_allow_from split

Update test_telegram_auth_check.py to use group_allow_from for group
messages (matching the PR's intentional behavior split: allow_from for
DMs, group_allow_from for groups). Add test_is_user_authorized_from_message_group_allow_from
to cover the new group path.

* fix(openviking): recover pending session commits

* docs: clarify OpenViking local setup

(cherry picked from commit a6807170f1)
(cherry picked from commit 6fb4e9aa8a)

* fix(openviking): serialize orphan session recovery

* fix(openviking): chunk structured session sync

Preserve ordered structured turns across OpenViking's 100-message batch limit and resume retries from the first unconfirmed message.

Based on the OpenViking batching work from commit 1a567f7067 in #58981.

* refactor: cleanup follow-up for salvaged PR #58871

- Remove dead current_sid parameter from _recover_pending_sessions
- Remove dead cleanup parameter from _release_owner_run_claim (always True)
- Set _run_lock_path after flock succeeds, not before
- Collapse redundant BlockingIOError branch (covered by OSError+errno check)
- Track _pending_marked_sids to skip re-writing marker file on every sync_turn

* fix(openviking): inject session-start memory context

(cherry picked from commit 18b474d0bd)

* fix(openviking): align session context with shared profile contract

* fix: discard both session IDs on compression for profile re-injection

The _profile_prefetched_sessions set stores whichever session_id was
passed to prefetch(), which may differ from self._session_id. On
compression, only old_session_id (self._session_id) was discarded,
missing the case where the stored key was the prefetch session_id
parameter. Discard both old and new IDs to cover all cases.

* chore: add kshitij@kshitij.dev to AUTHOR_MAP

* fix(secrets): fall back to stale disk cache when bws live fetch fails

Without this, a single DNS hiccup or BWS outage at gateway startup leaves
the whole fleet running with an empty credential pool — every model call
fails until someone restarts after the network recovers.  When a previous
successful fetch already populated the disk cache, return those secrets
with an explicit warning instead of raising RuntimeError.

`use_cache=False` (explicit opt-out) still raises so manual flows like
the setup wizard surface the original error.  The disk cache is not
re-written on the fallback path so a process restart still triggers a
proper TTL re-check.

Fixes #41925

* fix(secrets): port stale-cache fallback to current DiskCache API + gate by error kind

The stale-fallback branch called _read_disk_cache(), a helper removed in
db495b0fba when disk-cache logic moved to the
shared DiskCache class — every fallback attempt raised NameError instead of
serving cached secrets, silently defeating the PR's whole purpose. Port to
_DISK_CACHE.read().

Also tighten the fallback per DiskCache's TTL contract and the secret-source
error taxonomy:
- Gate on cache_ttl_seconds > 0 so a caller that opted out of caching
  entirely (ttl=0) never gets a secret value that didn't come from a live
  fetch, even on the failure path.
- Gate on _classify_bws_error(str(exc)) being NETWORK or TIMEOUT, reusing
  the existing classifier — an AUTH_FAILED or malformed-output failure must
  still raise, since serving stale secrets there would mask a real
  credential/config problem instead of a transient outage.

Ported the test helpers off the removed _write_disk_cache to a direct JSON
write (matching this file's existing disk-cache test convention) and added
tests for the auth-failure, malformed-output, and zero-TTL gates. Reverting
the fix and re-running confirms 7 of 8 stale-fallback tests fail with the
original NameError.

* fix(secrets): fold OP_CONNECT_HOST/OP_CONNECT_TOKEN into 1Password auth cache-key

_auth_fingerprint() built the 1Password secret cache-key from the
service-account token, OP_ACCOUNT, and OP_SESSION_* vars but omitted
OP_CONNECT_HOST/OP_CONNECT_TOKEN, which are in _OP_ENV_ALLOWLIST and are
forwarded to the op child (the Connect-server auth path). Rotating
OP_CONNECT_TOKEN or re-pointing OP_CONNECT_HOST at a different Connect
identity left the fingerprint unchanged, so both the in-process and disk
caches kept serving secrets resolved under the old Connect credentials for
the full TTL (default 300s, disk-persisted across invocations). This
contradicts the function's own docstring invariant that a value cached
under a previous identity is never served under a new one; it closes the
gap for the Connect path, matching the OP_SESSION_*/service-account paths
that are already protected.

* fix(secrets): pass OP_LOAD_DESKTOP_APP_SETTINGS through to the op child env

The 1Password secret source builds a minimal allowlisted environment for the
`op read` child process. The allowlist omits OP_LOAD_DESKTOP_APP_SETTINGS, so a
user who exports it (shell, .env, or service unit) sees it silently stripped
before it reaches `op`.

That var is `op`'s documented switch to skip the desktop-app integration probe.
When the 1Password desktop app is installed, `op` probes its settings/socket at
startup *before* evaluating service-account auth. If the desktop app's group
container is wedged (e.g. macOS 'Interrupted system call' on the 1Password group
container), that probe blocks with no timeout, so `op read` hangs indefinitely
even with a valid OP_SERVICE_ACCOUNT_TOKEN present. Setting
OP_LOAD_DESKTOP_APP_SETTINGS=false is the intended escape hatch — but stripping
it means it has no effect on exactly the headless boxes that need it.

Fix: add OP_LOAD_DESKTOP_APP_SETTINGS to _OP_ENV_ALLOWLIST so the documented
var reaches the child. No behavior change when it's unset. Adds a focused test
alongside the existing allowlist test.

Repro: on a machine with a wedged 1Password desktop container + a valid SA
token, `op read` hangs 600s+ without the var and returns in ~4s with it — but
only if it actually reaches the op process, which this allowlist entry ensures.

Co-authored-by: Minh Nguyen <menhguin@users.noreply.github.com>

* fix(mcp): pass secret-source-injected env vars to stdio servers

Surgical reapply of PR #37523 onto current main (the original branch
predates the SecretSource registry refactor).  _build_safe_env() now
forwards env vars tagged in env_loader._SECRET_SOURCES — widened from
Bitwarden-only to any registered secret source (Bitwarden, 1Password,
plugin backends), since the provenance map is source-agnostic.
Explicit server env: config still wins; untagged secrets stay filtered.

Fixes #37499.

* fix(env): stop printing Bitwarden secret names

* fix(secrets): validate bitwarden status token

Keep the env-presence row, but add a real Bitwarden probe so revoked or malformed tokens no longer look healthy in hermes secrets bitwarden status.

Also document the new status behavior and lock it in with a dedicated regression test.

Refs: NousResearch/hermes-agent#40275
Tested: ./scripts/run_tests.sh tests/hermes_cli/test_bitwarden_status.py tests/test_bitwarden_secrets.py
Tested: .venv/bin/python -m ruff check hermes_cli/secrets_cli.py tests/hermes_cli/test_bitwarden_status.py

* fix(secrets): mark _APPLIED_HOMES only after a real fetch attempt (#40597) (#69056)

_apply_external_secret_sources() added the home to _APPLIED_HOMES before
loading config, so a malformed config.yaml, a missing secrets section, or
all-sources-disabled permanently disabled secret loading for the process
— even after the user fixed the config.  Long-lived processes (gateway)
never recovered without a restart.

Now the home is marked only after apply_all() actually ran with at least
one enabled source.  Fetch errors still mark the home (so import-time
load_hermes_dotenv() calls don't re-fetch and re-print the same failure
3-5x per startup); the cheap early-exit paths stay retryable.

Fixes #40597.

* fix(secrets): fall back to os.environ on scope miss when multiplexing is off

fdab380a1 wraps every cron job in a <home>/.env secret scope regardless of
deployment mode. get_secret() treats any installed scope as authoritative,
so in single-profile deployments where provider keys live only in the
process environment (systemd Environment=, pass-cli/op run wrappers, shell
exports) every cron credential read returns empty, the OpenAI client is
built with the no-key-required placeholder, and each scheduled job 401s —
while interactive turns keep working. Scope-miss reads now fall through to
os.environ when multiplexing is off; multiplexed scopes stay authoritative.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(gateway): activate multiplex flag in cross-profile env isolation test

The test installs a secret scope and asserts a scope miss does NOT fall
back to the default profile's env — that isolation guarantee only holds
under multiplexing, which the real gateway activates at startup via
set_multiplex_active().  With the #67827 overlay fallthrough (scope miss
→ os.environ when multiplex is OFF), the test needs to model the
multiplexed runtime it is actually testing.

* feat(secrets): orchestrator-level preserve_existing + profile aliasing (#69058)

Fixes the profile-clobber bug cluster at the apply_all() chokepoint so
every secret source — bundled and plugin — gets both behaviors for free:

- secrets.preserve_existing (#58073): env var names whose existing .env /
  shell value always wins, even against a source with
  override_existing: true.  Escape hatch for per-profile platform
  secrets while everything else rotates centrally.
- Profile aliasing (#51447): under a named profile, an applied
  FOO_<PROFILE> var (credential-shaped suffixes only) also hydrates the
  canonical FOO, so adapters/plugins that read fixed env names see the
  profile's value.  Direct supply beats alias; protected/claimed/
  override guards all apply; secrets.profile_alias: false disables.

Reimplements the intent of PR #58085 (tianma-if, preserve_existing on the
legacy Bitwarden apply shim) and PR #51616 (LeonSGP43, profile aliasing
inside the Bitwarden backend) on the SecretSource orchestrator that
superseded those code paths.

Fixes #58073.  Fixes #51447.

Co-authored-by: tianma-if <5895871+tianma-if@users.noreply.github.com>
Co-authored-by: LeonSGP43 <154585401+LeonSGP43@users.noreply.github.com>

* test(state): accept FTS5-specific corruption message in read-probe test

With the v23 external-content FTS layout, corrupt shadow segments surface
as 'fts5: corrupt structure record for table ...' instead of the generic
'database disk image is malformed'. Same corruption class, same variance
already documented and handled by _is_fts_write_corruption_error — widen
the test assertion to match.

---------

Co-authored-by: ethernet <arilotter@gmail.com>
Co-authored-by: brooklyn! <brooklyn.bb.nicholson@gmail.com>
Co-authored-by: Gille <4317663+helix4u@users.noreply.github.com>
Co-authored-by: yoniebans <jonny@nousresearch.com>
Co-authored-by: Rod-fernandez <rodrigo@nxtlevelsaas.com>
Co-authored-by: David Andrews (LexGenius.ai) <david@lexgenius.ai>
Co-authored-by: xxxigm <54813621+xxxigm@users.noreply.github.com>
Co-authored-by: hermes-seaeye[bot] <307254004+hermes-seaeye[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: SHL0MS <SHL0MS@users.noreply.github.com>
Co-authored-by: HexLab98 <liruixinch@outlook.com>
Co-authored-by: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com>
Co-authored-by: PRATHAMESH75 <prathamesh290504@gmail.com>
Co-authored-by: x7peeps <xtpeeps@qq.com>
Co-authored-by: Imgaojp <6065749+Imgaojp@users.noreply.github.com>
Co-authored-by: Siddharth Balyan <52913345+alt-glitch@users.noreply.github.com>
Co-authored-by: Ben Barclay <ben@nousresearch.com>
Co-authored-by: sbe27 <283218367+sbe27@users.noreply.github.com>
Co-authored-by: TARS <tars@users.noreply.github.com>
Co-authored-by: Austin Pickett <pickett.austin@gmail.com>
Co-authored-by: Rudimar Ronsoni <rudimar@outlook.com>
Co-authored-by: Austin Pickett <austinpickett@users.noreply.github.com>
Co-authored-by: Joshua <joshua@amokk.net>
Co-authored-by: alelpoan <155192176+alelpoan@users.noreply.github.com>
Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
Co-authored-by: Cossackx <121278003+Cossackx@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Jaret Bottoms <jaretbottoms@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Enough1122 <chenjin@hermes.local>
Co-authored-by: Frowtek <frowte3k@gmail.com>
Co-authored-by: Aniruddha Adak <aniruddhaadak80@users.noreply.github.com>
Co-authored-by: eazye19 <eazye19@users.noreply.github.com>
Co-authored-by: frohsinnllc <231045016+frohsinnllc@users.noreply.github.com>
Co-authored-by: hanyu1212 <28571259+hanyu1212@users.noreply.github.com>
Co-authored-by: 雨哥 <hanyu1212@users.noreply.github.com>
Co-authored-by: yungchentang <46495124+yungchentang@users.noreply.github.com>
Co-authored-by: trevorgordon981 <trevorbgordon@gmail.com>
Co-authored-by: Börje <borje@dqsverige.se>
Co-authored-by: fazerluga-creator <fazerluga@gmail.com>
Co-authored-by: yoma <yingwaizhiying@gmail.com>
Co-authored-by: Kennedy Umege <kenmege@yahoo.com>
Co-authored-by: pierrenode <298902573+pierrenode@users.noreply.github.com>
Co-authored-by: Sora-bluesky <sora.bluesky.dev@gmail.com>
Co-authored-by: iamwongeeeee <wykim777@naver.com>
Co-authored-by: web3blind <264741654+web3blind@users.noreply.github.com>
Co-authored-by: Bartok9 <danielrpike9@gmail.com>
Co-authored-by: anoopmehendale-cue <anoop.mehendale@gmail.com>
Co-authored-by: tinetwork <martin@tinetwork.com>
Co-authored-by: cucurigoo <241698038+cucurigoo@users.noreply.github.com>
Co-authored-by: McHermes <mchermes@edu.dreamcatcher.ai>
Co-authored-by: Nyaruko <nyaruko@hermes>
Co-authored-by: Hao Zhe <haozhe4547@gmail.com>
Co-authored-by: Naveen Fernando <90832919+NPFernando@users.noreply.github.com>
Co-authored-by: Chris Korhonen <ckorhonen@gmail.com>
Co-authored-by: Flownium <157689911+itsflownium@users.noreply.github.com>
Co-authored-by: kshitij <kshitij@kshitij.dev>
Co-authored-by: JackJin <1037461232@qq.com>
Co-authored-by: briandevans <252620095+briandevans@users.noreply.github.com>
Co-authored-by: menhguin <17287724+menhguin@users.noreply.github.com>
Co-authored-by: Minh Nguyen <menhguin@users.noreply.github.com>
Co-authored-by: SeoYeonKim <28585885+westkite1201@users.noreply.github.com>
Co-authored-by: andrexibiza <84248988+andrexibiza@users.noreply.github.com>
Co-authored-by: izumi0uu <izumi0uu@gmail.com>
Co-authored-by: Soju06 <qlskssk@gmail.com>
Co-authored-by: tianma-if <5895871+tianma-if@users.noreply.github.com>
Co-authored-by: LeonSGP43 <154585401+LeonSGP43@users.noreply.github.com>
2026-07-22 04:42:50 -07:00

8773 lines
384 KiB
Python

#!/usr/bin/env python3
"""
SQLite State Store for Hermes Agent.
Provides persistent session storage with FTS5 full-text search, replacing
the per-session JSONL file approach. Stores session metadata, full message
history, and model configuration for CLI and gateway sessions.
Key design decisions:
- WAL mode for concurrent readers + one writer (gateway multi-platform)
- FTS5 virtual table for fast text search across all session messages
- Compression-triggered session splitting via parent_session_id chains
- Batch runner and RL trajectories are NOT stored here (separate systems)
- Session source tagging ('cli', 'telegram', 'discord', etc.) for filtering
"""
import asyncio
import json
import logging
import random
import re
import sqlite3
import sys
import threading
import time
from pathlib import Path
from agent.memory_manager import sanitize_context
from agent.message_sanitization import _sanitize_surrogates
from hermes_constants import get_hermes_home
from typing import Any, Callable, Dict, List, Optional, Tuple, TypeVar
logger = logging.getLogger(__name__)
def _scrub_surrogates(value: Any) -> Any:
"""Replace lone surrogates when *value* is text; pass anything else through.
sqlite3 encodes bound ``str`` parameters as UTF-8 and raises
``UnicodeEncodeError`` on lone surrogates (U+D800..U+DFFF), so a single
such code point anywhere in a message aborts the whole write. No-op for
well-formed text.
"""
return _sanitize_surrogates(value) if isinstance(value, str) else value
def workspace_key(row: Dict[str, Any]) -> Optional[str]:
"""A session's workspace grouping key: its git repo root when known, else
its cwd.
Branch is deliberately excluded so checking out a new branch doesn't
fragment a workspace's session history. Returns None for cwd-less (unbound)
sessions. Both fields are already recorded on ``sessions`` — this just picks
the coarser identity for grouping/filtering.
"""
root = (row.get("git_repo_root") or "").strip()
if root:
return root
cwd = (row.get("cwd") or "").strip()
return cwd or None
def _delegate_from_json(col: str = "model_config") -> str:
return f"json_extract(COALESCE({col}, '{{}}'), '$._delegate_from')"
def _cwd_prefix_clause(cwd_prefix: str) -> Tuple[str, List[str]]:
prefix = cwd_prefix.rstrip("/\\") or cwd_prefix
return "(s.cwd = ? OR s.cwd LIKE ? OR s.cwd LIKE ?)", [prefix, f"{prefix}/%", f"{prefix}\\%"]
# A child session counts as a /branch (kept visible, never cascade-deleted) if
# it carries the stable marker OR the legacy end_reason heuristic holds.
_BRANCH_CHILD_SQL = (
"json_extract(COALESCE({a}.model_config, '{{}}'), '$._branched_from') IS NOT NULL"
" OR EXISTS (SELECT 1 FROM sessions p"
" WHERE p.id = {a}.parent_session_id"
" AND p.end_reason = 'branched'"
" AND {a}.started_at >= p.ended_at)"
)
_COMPRESSION_CHILD_SQL = (
"EXISTS (SELECT 1 FROM sessions p"
" WHERE p.id = {a}.parent_session_id"
" AND p.end_reason = 'compression')"
)
# Rows that surface in pickers: roots + branch children (subagent runs and
# compression continuations stay hidden).
_LISTABLE_CHILD_SQL = f"(s.parent_session_id IS NULL OR {_BRANCH_CHILD_SQL.format(a='s')})"
def _ephemeral_child_sql(alias: str = "s") -> str:
"""Subagent runs (cascade-delete targets), not branches or compression tips."""
branch = _BRANCH_CHILD_SQL.format(a=alias)
compression = _COMPRESSION_CHILD_SQL.format(a=alias)
return (
f"({alias}.parent_session_id IS NOT NULL"
f" AND NOT ({branch})"
f" AND NOT ({compression}))"
)
def _collect_delegate_child_ids(conn, parent_ids: List[str]) -> List[str]:
"""Delegate-subagent ids to cascade-delete with *parent_ids*.
Only rows carrying the ``_delegate_from`` marker (set at creation, and
backfilled by the v16 migration) — generic untagged children keep the
orphan-don't-delete contract. Walks marker chains recursively so an
orchestrator subagent's own delegate children go too (FK safety).
"""
df = _delegate_from_json()
seeds = {sid for sid in parent_ids if sid}
# Seed the visited set with the parents themselves. A delegation marker
# chain can loop back onto a parent — a cycle, or a parent that is also
# another parent's delegate child when several ids are deleted at once —
# and without this guard that parent would be collected as one of its own
# descendants and cascade-deleted along with all of its messages. Callers
# delete the parents separately, so parents must never appear in the
# returned child set. (#49148)
found: set[str] = set(seeds)
frontier = list(seeds)
while frontier:
ph = ",".join("?" * len(frontier))
cursor = conn.execute(
f"SELECT id FROM sessions WHERE {df} IN ({ph}) "
f"OR (parent_session_id IN ({ph}) AND {df} IS NOT NULL)",
frontier + frontier,
)
frontier = [row["id"] for row in cursor.fetchall() if row["id"] not in found]
found.update(frontier)
# Return only the discovered children — never the parents themselves.
return [sid for sid in found if sid not in seeds]
def _delete_delegate_children(conn, parent_ids: List[str]) -> List[str]:
ids = _collect_delegate_child_ids(conn, parent_ids)
if ids:
ph = ",".join("?" * len(ids))
conn.execute(f"DELETE FROM messages WHERE session_id IN ({ph})", ids)
# FK safety: orphan any untagged stragglers pointing at a doomed row.
conn.execute(
f"UPDATE sessions SET parent_session_id = NULL "
f"WHERE parent_session_id IN ({ph})",
ids,
)
conn.execute(f"DELETE FROM sessions WHERE id IN ({ph})", ids)
return ids
T = TypeVar("T")
DEFAULT_DB_PATH = get_hermes_home() / "state.db"
SCHEMA_VERSION = 23
# FTS storage-layout version, tracked INDEPENDENTLY of SCHEMA_VERSION in the
# state_meta key ``fts_storage_version``. The main schema version advances
# freely on open (so future migrations always land); the FTS *layout* only
# reaches the current version when a DB is either born fresh or explicitly
# optimized via ``hermes sessions optimize-storage``. A legacy DB sits at
# layout 0 (marker absent) with a working inline index until the user opts in.
# 1 = v23 external-content layout (content/tool_name/tool_calls,
# tool-row-excluded trigram)
FTS_STORAGE_VERSION = 1
# Cap on user-controlled FTS5 query input before regex/sanitizer processing.
# Search queries do not need to be arbitrarily large, and bounding them keeps
# sanitizer/runtime behavior predictable under adversarial input.
MAX_FTS5_QUERY_CHARS = 2_048
# ---------------------------------------------------------------------------
# WAL-compatibility fallback
# ---------------------------------------------------------------------------
# SQLite's WAL mode requires shared-memory (mmap) coordination and fcntl
# byte-range locks that don't reliably work on network filesystems (NFS,
# SMB/CIFS, some FUSE mounts, WSL1). Upstream documents this explicitly:
# https://www.sqlite.org/wal.html#sometimes_queries_return_sqlite_busy_in_wal_mode
#
# On those filesystems ``PRAGMA journal_mode=WAL`` raises
# ``sqlite3.OperationalError: locking protocol`` (SQLITE_PROTOCOL). If we
# propagate that, every feature backed by state.db / kanban.db breaks
# silently — /resume, /title, /history, /branch, kanban dispatcher, etc.
#
# Instead, fall back to ``journal_mode=DELETE`` (the pre-WAL default) which
# works on NFS. Concurrency drops — concurrent readers are blocked during
# a write — but the feature works.
_WAL_INCOMPAT_MARKERS = (
"locking protocol", # SQLITE_PROTOCOL on NFS/SMB
"not authorized", # Some FUSE mounts block WAL pragma outright
)
# Last SessionDB() init error, per-process. Surfaced in /resume and
# related slash-command error strings so users know WHY the DB is
# unavailable instead of getting a bare "Session database not available."
# Only SessionDB.__init__ writes to this; kanban_db.connect() failures
# do not update it (by design — kanban failures are reported via their
# own caller's error handling, not via /resume-style slash commands).
_last_init_error: Optional[str] = None
_last_init_error_lock = threading.Lock()
# Paths for which we've already logged a WAL-fallback WARNING. Without
# this, kanban_db.connect() (called on every kanban operation — see
# hermes_cli/kanban_db.py for ~30 call sites) would re-log the same
# filesystem-incompat warning on every connection, filling errors.log.
_wal_fallback_warned_paths: set[str] = set()
_wal_fallback_warned_lock = threading.Lock()
_FTS_TRIGGERS = (
"messages_fts_insert",
"messages_fts_delete",
"messages_fts_update",
"messages_fts_trigram_insert",
"messages_fts_trigram_delete",
"messages_fts_trigram_update",
)
def _set_last_init_error(msg: Optional[str]) -> None:
"""Record (or clear) the most recent state.db init failure.
Thread-safe via _last_init_error_lock. Callers pass a message to
record a failure or None to clear. SessionDB.__init__ only calls
this to SET on failure — it deliberately does NOT clear on success,
because in a multi-threaded caller (e.g. gateway / web_server per-
request SessionDB() instantiation), a concurrent successful open
racing past a different thread's failure would erase the cause
string that thread's /resume handler is about to format. Explicit
clears (e.g. test fixtures) are still supported by passing None.
"""
global _last_init_error
with _last_init_error_lock:
_last_init_error = msg
def get_last_init_error() -> Optional[str]:
"""Return the most recent state.db init failure, if any.
Slash-command handlers (``/resume``, ``/title``, ``/history``, ``/branch``)
call this to surface the underlying cause in their error messages when
``_session_db is None``. Returns ``None`` if SessionDB initialized
successfully (or hasn't been attempted).
"""
return _last_init_error
# Distinctive opening shared by both background-review harness prompts
# (_SKILL_REVIEW_PROMPT and _MEMORY_REVIEW_PROMPT in agent/background_review.py).
# Matched case-sensitively against the leading content of a user/system message.
_REVIEW_HARNESS_PREFIXES = (
"Review the conversation above and update the skill library",
"Review the conversation above and consider saving to memory",
)
def _is_background_review_harness_message(msg: Dict[str, Any]) -> bool:
"""True when ``msg`` is a persisted background-review harness prompt.
These are user/system turns the forked skill/memory review agent wrote into
a real session in older builds (before the ``_persist_disabled`` isolation
fix). They instruct the agent to act as the curator under a hard tool
restriction, so replaying them as live history hijacks the session.
"""
if not isinstance(msg, dict):
return False
if msg.get("role") not in {"user", "system"}:
return False
content = msg.get("content")
if not isinstance(content, str):
return False
head = content.lstrip()
return any(head.startswith(p) for p in _REVIEW_HARNESS_PREFIXES)
def _strip_background_review_harness(
messages: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
"""Drop background-review harness messages and the curator-mode assistant
reply that immediately followed each one.
Walk the list once; when a harness user/system message is found, skip it and
also skip the next message if it is the assistant turn that answered it.
Everything else passes through untouched and in order.
"""
if not messages:
return messages
out: List[Dict[str, Any]] = []
skip_next_assistant = False
for msg in messages:
if _is_background_review_harness_message(msg):
skip_next_assistant = True
continue
if skip_next_assistant:
skip_next_assistant = False
if isinstance(msg, dict) and msg.get("role") == "assistant":
# The curator-mode reply to the harness prompt — drop it.
continue
out.append(msg)
return out
def format_session_db_unavailable(prefix: str = "Session database not available") -> str:
"""Format a user-facing 'session DB unavailable' message with cause.
When ``SessionDB()`` init fails, callers set ``_session_db = None`` and
several slash commands (/resume, /title, /history, /branch) previously
responded with a bare ``"Session database not available."`` — no
indication of WHY. This helper includes the captured cause (typically
``"locking protocol"`` from NFS/SMB) and points users at the known
culprit so they can fix it themselves.
Example output:
Session database not available: locking protocol (state.db may be
on NFS/SMB — see https://www.sqlite.org/wal.html).
"""
cause = get_last_init_error()
if not cause:
return f"{prefix}."
hint = ""
if any(marker in cause.lower() for marker in _WAL_INCOMPAT_MARKERS):
hint = " (state.db may be on NFS/SMB/FUSE — see https://www.sqlite.org/wal.html)"
return f"{prefix}: {cause}{hint}."
def _on_disk_journal_mode(conn: sqlite3.Connection) -> Optional[str]:
"""Read the journal mode from the SQLite DB header on disk.
Returns the mode string (e.g. ``"wal"``, ``"delete"``), or ``None``
if the value cannot be determined (new DB, or PRAGMA read failed).
"""
try:
row = conn.execute("PRAGMA journal_mode").fetchone()
except sqlite3.OperationalError:
return None
if row is None:
return None
mode = row[0]
if isinstance(mode, bytes): # defensive: sqlite3 occasionally returns bytes
try:
mode = mode.decode("ascii")
except UnicodeDecodeError:
return None
return str(mode).strip().lower() if mode is not None else None
def _apply_macos_checkpoint_barrier(conn: sqlite3.Connection) -> None:
"""Enable ``PRAGMA checkpoint_fullfsync`` on macOS (no-op elsewhere).
On Darwin, ``synchronous=FULL`` (the WAL default) issues a plain
``fsync()``, which Apple documents does *not* guarantee that data
has reached stable storage or that writes are not reordered — see
the ``fsync(2)`` man page. SQLite's WAL corruption-safety guarantee
assumes the OS honors the fsync write barrier; macOS does not unless
the app uses ``F_FULLFSYNC``.
During a launchd *system* shutdown/reboot the OS page cache is
dropped (effectively a power-loss event for in-flight pages), so a
WAL checkpoint whose ``fsync()`` "reported" durable may never have
hit the platter — corrupting ``state.db`` with a malformed image.
This is the trigger in issue #30636 ("SIGTERM during launchd
shutdown under high load"), distinct from a plain in-session kill
(which the page cache survives and SQLite recovers from).
``checkpoint_fullfsync=1`` forces an ``F_FULLFSYNC`` barrier only at
checkpoint boundaries — where WAL frames land in the main DB — so the
cost amortizes to roughly +0.1 ms/commit (vs ~+4 ms for the broader
``fullfsync=1`` that flushes on every commit's WAL sync). Guarded by
``sys.platform == "darwin"`` because ``F_FULLFSYNC`` is macOS-only;
on other platforms the PRAGMA is a no-op, so we skip it entirely.
Best-effort: never raises.
"""
if sys.platform != "darwin":
return
try:
conn.execute("PRAGMA checkpoint_fullfsync=1")
except sqlite3.OperationalError:
pass
def _enforce_macos_synchronous_full(conn: sqlite3.Connection) -> None:
"""Enforce ``PRAGMA synchronous=FULL`` on macOS to prevent btree corruption.
On Darwin, the default ``synchronous=NORMAL`` only calls ``fsync()``,
which Apple's fsync(2) man page explicitly states does *not* guarantee
data-on-platter or write-ordering. During a WAL checkpoint race with
process termination (e.g., launchd shutdown), this can leave the main
DB with half-written btree pages → ``btreeInitPage error 11``.
WAL mode's durability guarantee assumes the OS honors fsync barriers;
macOS does not unless we explicitly set ``synchronous=FULL``, which issues
a real ``fsync()`` on every transaction commit. The ``F_FULLFSYNC``
barrier at checkpoint boundaries is handled separately by
:func:`_apply_macos_checkpoint_barrier`.
This function is called after any successful WAL activation (either
from ``apply_wal_with_fallback()`` setting a fresh WAL or when probing
an existing WAL mode). It ensures macOS connections always use FULL
synchronous mode, even if a prior connection set ``synchronous=NORMAL``.
Best-effort: never raises.
"""
if sys.platform != "darwin":
return
try:
conn.execute("PRAGMA synchronous=FULL")
except sqlite3.OperationalError:
pass
def apply_wal_with_fallback(
conn: sqlite3.Connection,
*,
db_label: str = "state.db",
) -> str:
"""Set ``journal_mode=WAL`` on ``conn``, falling back to DELETE on failure.
Returns the journal mode actually set (``"wal"`` or ``"delete"``).
On WAL-incompatible filesystems (NFS, SMB, some FUSE), SQLite raises
``OperationalError("locking protocol")`` when setting WAL. We fall
back to DELETE mode — the pre-WAL default, which works on NFS — and
log one WARNING explaining why.
The WARNING is deduplicated per ``db_label``: repeated connections
to the same underlying DB (e.g. kanban_db.connect() which is called
on every kanban operation) log once per process, not once per call.
Different db_labels log independently, so state.db and kanban.db
each get one warning on the same NFS mount.
Shared by :class:`SessionDB` and ``hermes_cli.kanban_db.connect`` so
both databases get identical fallback behavior.
Never downgrades to DELETE if the on-disk DB header reports WAL — see _on_disk_journal_mode.
"""
# Read-only probe — no flock, no checkpoint, no WAL/SHM unlink.
# Skipping the set-pragma prevents WAL-init from unlinking files other connections hold open.
try:
current_mode = conn.execute("PRAGMA journal_mode").fetchone()
if current_mode and current_mode[0] == "wal":
_apply_macos_checkpoint_barrier(conn)
_enforce_macos_synchronous_full(conn)
return "wal"
except sqlite3.OperationalError:
pass
try:
conn.execute("PRAGMA journal_mode=WAL")
_apply_macos_checkpoint_barrier(conn)
_enforce_macos_synchronous_full(conn)
return "wal"
except sqlite3.OperationalError as exc:
msg = str(exc).lower()
if not any(marker in msg for marker in _WAL_INCOMPAT_MARKERS):
# Unrelated OperationalError — don't silently swallow.
raise
# Don't downgrade if another process already set WAL on disk.
existing = _on_disk_journal_mode(conn)
if existing == "wal":
raise
_log_wal_fallback_once(db_label, exc)
conn.execute("PRAGMA journal_mode=DELETE")
return "delete"
def _log_wal_fallback_once(db_label: str, exc: Exception) -> None:
"""Log a single WARNING per (process, db_label) about WAL fallback.
Without this dedup, NFS users running kanban (which opens a fresh
connection on every operation — see hermes_cli/kanban_db.py) would
fill errors.log with hundreds of identical warnings per hour.
"""
with _wal_fallback_warned_lock:
if db_label in _wal_fallback_warned_paths:
return
_wal_fallback_warned_paths.add(db_label)
logger.warning(
"%s: WAL journal_mode unsupported on this filesystem (%s) — "
"falling back to journal_mode=DELETE (slower rollback-journal "
"mode; reduces concurrency but works on NFS/SMB/FUSE). See "
"https://www.sqlite.org/wal.html for details. This warning "
"fires once per process per database.",
db_label,
exc,
)
# ---------------------------------------------------------------------------
# Malformed-schema recovery
# ---------------------------------------------------------------------------
# A distinct, nastier failure class than a malformed FTS *inverted index*:
# the ``sqlite_master`` schema table itself becomes inconsistent — most
# commonly a DUPLICATE object definition, e.g. two ``CREATE VIRTUAL TABLE
# messages_fts`` rows. SQLite parses the entire schema while preparing the
# FIRST statement on a connection, so on this class *every* statement raises
# before it runs — including ``PRAGMA journal_mode`` (which is why this trips
# in ``apply_wal_with_fallback`` during ``SessionDB.__init__``, long before
# ``_init_schema`` is reached) and even ``PRAGMA integrity_check`` and a plain
# ``DROP TABLE``. The only operations that still work are
# ``PRAGMA writable_schema=ON`` plus direct ``sqlite_master`` surgery.
#
# Symptom users hit (Desktop/Dashboard show "no sessions" while 200+ JSON
# files sit on disk):
# sqlite3.DatabaseError: malformed database schema (messages_fts) -
# table messages_fts already exists
#
# The canonical ``sessions`` / ``messages`` data is intact in these cases —
# only the derived schema is broken — so recovery preserves all transcripts
# and merely rebuilds the FTS layer.
_MALFORMED_SCHEMA_MARKERS = (
"malformed database schema",
"database disk image is malformed",
)
# Process-global guard so auto-repair is attempted at most once per DB path
# per process (prevents repair loops and serialises concurrent web_server /
# gateway opens against the same malformed file).
_repair_attempted_paths: set[str] = set()
_repair_attempt_lock = threading.Lock()
def is_malformed_db_error(exc: BaseException) -> bool:
"""True if *exc* is a SQLite 'malformed schema / disk image' error.
These are the corruption classes where the schema fails to parse, so
targeted ``sqlite_master`` surgery (not an ordinary FTS rebuild) is the
only recovery path.
"""
if not isinstance(exc, sqlite3.DatabaseError):
return False
return any(marker in str(exc).lower() for marker in _MALFORMED_SCHEMA_MARKERS)
def _claim_repair_attempt(db_path: Path) -> bool:
"""Claim the one-shot repair attempt for *db_path* in this process.
Returns True for the first caller, False afterwards. Keeps a malformed
DB from triggering an unbounded repair/reopen loop and stops concurrent
callers from racing surgery on the same file.
"""
key = str(db_path)
with _repair_attempt_lock:
if key in _repair_attempted_paths:
return False
_repair_attempted_paths.add(key)
return True
def _backup_db_file(db_path: Path) -> Optional[Path]:
"""Copy a (possibly malformed) DB file to a timestamped backup beside it.
Raw file copy on purpose: the DB won't open cleanly, so we preserve the
bytes exactly for forensics / manual restore. WAL and SHM sidecars are
copied too when present. Returns the backup path, or None on failure.
"""
import datetime
import shutil
stamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
backup_path = db_path.with_name(f"{db_path.name}.malformed-backup-{stamp}")
try:
shutil.copy2(db_path, backup_path)
for suffix in ("-wal", "-shm"):
sidecar = db_path.with_name(db_path.name + suffix)
if sidecar.exists():
shutil.copy2(sidecar, backup_path.with_name(backup_path.name + suffix))
return backup_path
except Exception as exc: # pragma: no cover - best effort
logger.warning("Could not back up malformed DB %s: %s", db_path, exc)
return None
def _db_opens_cleanly(db_path: Path) -> Optional[str]:
"""Probe a DB on a fresh connection. Returns None if healthy, else a reason.
Runs the same first-statement (``PRAGMA journal_mode``) that trips the
malformed-schema parse, then ``PRAGMA integrity_check`` and a canonical
``sessions`` read, and finally a rolled-back ``messages`` write so that
FTS5 index corruption — which leaves base-table reads and
``integrity_check`` passing while every ``INSERT INTO messages`` fails
through the FTS triggers — is reported as unhealthy rather than slipping
past as a false "ok" (#50502).
"""
conn = sqlite3.connect(str(db_path), isolation_level=None)
try:
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"]
if problems:
return "; ".join(problems[:3])
conn.execute("SELECT COUNT(*) FROM sessions").fetchone()
# FTS5 read probe: run a representative MATCH query against the
# messages_fts* virtual tables. The FTS *write* probe below catches
# the corruption class where base tables read fine but writes fail
# through the triggers (#50502). It does NOT catch partial FTS5
# index corruption — bad shadow-table segments where reads still
# parse but MATCH / snippet / rank queries error out with
# "database disk image is malformed" (a `sqlite3.DatabaseError`,
# not `OperationalError`). session_search, /resume title resolution,
# and any feature relying on FTS5 discovery then break silently
# because the official repair tool's check-only path reports the
# DB as healthy. #66724.
# 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"):
try:
# No-op queries against the actual FTS5 APIs the search
# tools use. The trigram table is included because it backs
# the title-resolution path; either corruption mode would
# break session recall without this probe. MATCH '""' is
# the empty phrase-token probe — FTS5 rejects MATCH ''
# outright ("fts5: syntax error"), but a quoted empty
# phrase parses, scans zero rows, and exercises the same
# shadow-table read path the search tools use.
conn.execute(
f"SELECT 1 FROM {fts_table} WHERE {fts_table} MATCH '\"\"' LIMIT 1"
).fetchone()
except sqlite3.OperationalError as exc:
# Use the canonical capability classifier instead of a
# hand-rolled substring check. On SQLite builds without the
# fts5 module, the legacy messages_fts table may exist on
# disk (from a prior build that had FTS5) and MATCH queries
# against it raise OperationalError("no such module: fts5");
# the substring check below would misclassify that as
# corruption and send the DB into the repair path, whose
# final fallback deletes the messages_fts% schema
# (hermes_state.py:645-723). The supported degraded-runtime
# path (SessionDB._is_fts5_unavailable_error + the
# regression suite in tests/test_hermes_state.py:600-632)
# treats both "no such module: fts5" and
# "no such tokenizer: trigram" as the capability error.
if SessionDB._is_fts5_unavailable_error(exc):
# Degraded runtime — not the corruption class we probe.
continue
msg = str(exc).lower()
if "no such table" in msg or "no such column" in msg:
# FTS5 not built yet (brand new file mid-init) — not the
# corruption class we probe.
continue
return f"fts5 read probe failed on {fts_table}: {exc}"
except sqlite3.DatabaseError as exc:
# This is the corruption class #66724 actually wants caught:
# partial shadow-table damage where MATCH / snippet / rank
# queries raise DatabaseError("database disk image is malformed")
# while reads of the FTS5 table itself parse fine.
return f"fts5 read probe failed on {fts_table}: {exc}"
# FTS write probe: drive a row through the messages_fts* triggers in a
# transaction that is always rolled back, so a corrupt FTS index that
# rejects writes is caught even though reads look healthy. The probe is
# best-effort — if the messages/sessions tables don't exist yet (brand
# new file mid-init) the OperationalError is treated as "not yet a
# populated DB", not corruption.
probe_session_id = f"_hermes_fts_health_probe_{time.time_ns()}"
try:
conn.execute("BEGIN IMMEDIATE")
conn.execute(
"INSERT INTO sessions (id, source, started_at) VALUES (?, ?, ?)",
(probe_session_id, "_health_probe", time.time()),
)
conn.execute(
"INSERT INTO messages (session_id, role, content, timestamp) "
"VALUES (?, ?, ?, ?)",
(probe_session_id, "user", "_fts_health_probe", time.time()),
)
conn.execute("ROLLBACK")
except sqlite3.OperationalError as exc:
# Missing tables / FTS disabled — not the corruption class we probe.
try:
conn.execute("ROLLBACK")
except sqlite3.Error:
pass
msg = str(exc).lower()
if "no such table" in msg or "no such column" in msg:
return None
return str(exc)
return None
except sqlite3.DatabaseError as exc:
return str(exc)
finally:
conn.close()
def repair_state_db_schema(db_path: Path, *, backup: bool = True) -> Dict[str, Any]:
"""Repair a state.db whose ``sqlite_master`` schema is malformed or whose
FTS indexes reject writes.
Handles two corruption classes: the "duplicate object definition" /
malformed-schema class where even ``PRAGMA`` statements fail, and the FTS
write-corruption class (#50502) where base tables read fine and
``integrity_check`` passes but writes fail through the ``messages_fts*``
triggers. Tries least-destructive recovery first and escalates:
1. **Rebuild FTS indexes in place** via the FTS5 ``'rebuild'`` command,
which rewrites the internal b-tree segments from the canonical
``messages`` rows without dropping or recreating anything. Fixes the
FTS write-corruption class while preserving the schema intact.
2. **De-duplicate** ``sqlite_master`` (keep the lowest rowid per
``type``/``name``). Fixes the canonical "table X already exists"
case and PRESERVES the existing FTS index intact.
3. **Drop the FTS schema** (every ``messages_fts*`` object) + ``VACUUM``.
The next ``SessionDB()`` open rebuilds the FTS indexes from the
canonical ``messages`` table.
Canonical ``sessions`` / ``messages`` rows are never modified. A
timestamped raw backup is taken first unless ``backup=False``.
Returns a report dict: ``{repaired: bool, strategy: str|None,
backup_path: str|None, error: str|None}``.
"""
report: Dict[str, Any] = {
"repaired": False,
"strategy": None,
"backup_path": None,
"error": None,
}
db_path = Path(db_path)
if not db_path.exists():
report["error"] = f"{db_path} does not exist"
return report
if _db_opens_cleanly(db_path) is None:
report["repaired"] = True
report["strategy"] = "already_healthy"
return report
if backup:
bpath = _backup_db_file(db_path)
report["backup_path"] = str(bpath) if bpath else None
# ── Strategy 0: rebuild FTS indexes in place (FTS write-corruption) ──
# The FTS5 'rebuild' command rewrites the internal index from the canonical
# content table. This is the recommended, least-destructive recovery for a
# corrupt FTS index that rejects message writes while reads still succeed.
try:
conn = sqlite3.connect(str(db_path), isolation_level=None)
try:
for table_name in ("messages_fts", "messages_fts_trigram"):
try:
conn.execute(
f"INSERT INTO {table_name}({table_name}) VALUES('rebuild')"
)
except sqlite3.OperationalError:
# Table absent (FTS disabled / trigram off) — skip it.
continue
finally:
conn.close()
if _db_opens_cleanly(db_path) is None:
report["repaired"] = True
report["strategy"] = "rebuild_fts"
logger.warning(
"state.db FTS indexes rebuilt in place (schema preserved): %s",
db_path,
)
return report
except sqlite3.DatabaseError as exc:
logger.warning("state.db FTS in-place rebuild pass failed: %s", exc)
# ── Strategy 0.5: rebuild stale B-tree indexes (#63386) ──
# PRAGMA integrity_check can report "wrong # of entries in index" when a
# B-tree index (e.g. idx_sessions_handoff_state) falls out of sync with its
# base table. REINDEX rewrites the index b-tree from the canonical table
# rows using the existing index definition, fixing the mismatch without
# touching data or FTS schema.
try:
conn = sqlite3.connect(str(db_path), isolation_level=None)
try:
conn.execute("REINDEX")
conn.commit()
finally:
conn.close()
if _db_opens_cleanly(db_path) is None:
report["repaired"] = True
report["strategy"] = "reindex_btree"
logger.warning(
"state.db B-tree indexes rebuilt via REINDEX: %s", db_path
)
return report
except sqlite3.DatabaseError as exc:
logger.warning("state.db REINDEX pass failed: %s", exc)
# ── Strategy 1: de-duplicate sqlite_master (keeps FTS index) ──
try:
conn = sqlite3.connect(str(db_path), isolation_level=None)
try:
conn.execute("PRAGMA writable_schema=ON")
dupes = conn.execute(
"SELECT type, name, COUNT(*) AS c, MIN(rowid) AS keep "
"FROM sqlite_master GROUP BY type, name HAVING c > 1"
).fetchall()
for type_, name, _count, keep in dupes:
conn.execute(
"DELETE FROM sqlite_master "
"WHERE type IS ? AND name IS ? AND rowid <> ?",
(type_, name, keep),
)
conn.execute("PRAGMA writable_schema=OFF")
conn.commit()
finally:
conn.close()
if _db_opens_cleanly(db_path) is None:
report["repaired"] = True
report["strategy"] = "dedup_schema"
logger.warning(
"state.db schema repaired by de-duplicating sqlite_master "
"(FTS index preserved): %s", db_path
)
return report
except sqlite3.DatabaseError as exc:
logger.warning("state.db dedup repair pass failed: %s", exc)
# ── Strategy 2: drop all FTS schema, VACUUM, rebuild on next open ──
try:
conn = sqlite3.connect(str(db_path), isolation_level=None)
try:
conn.execute("PRAGMA writable_schema=ON")
conn.execute("DELETE FROM sqlite_master WHERE name LIKE 'messages_fts%'")
conn.execute("PRAGMA writable_schema=OFF")
conn.commit()
conn.execute("VACUUM")
finally:
conn.close()
reason = _db_opens_cleanly(db_path)
if reason is None:
report["repaired"] = True
report["strategy"] = "drop_fts_rebuild"
logger.warning(
"state.db schema repaired by dropping FTS schema; indexes "
"will rebuild from messages on next open: %s", db_path
)
return report
report["error"] = reason
except sqlite3.DatabaseError as exc:
report["error"] = str(exc)
if not report["repaired"]:
logger.error(
"state.db schema repair could not recover %s automatically "
"(backup: %s); manual restore from backup may be required.",
db_path, report["backup_path"],
)
return report
SCHEMA_SQL = """
CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
source TEXT NOT NULL,
user_id TEXT,
session_key TEXT,
chat_id TEXT,
chat_type TEXT,
thread_id TEXT,
display_name TEXT,
origin_json TEXT,
expiry_finalized INTEGER DEFAULT 0,
model TEXT,
model_config TEXT,
system_prompt TEXT,
parent_session_id TEXT,
started_at REAL NOT NULL,
ended_at REAL,
end_reason TEXT,
message_count INTEGER DEFAULT 0,
tool_call_count INTEGER DEFAULT 0,
input_tokens INTEGER DEFAULT 0,
output_tokens INTEGER DEFAULT 0,
cache_read_tokens INTEGER DEFAULT 0,
cache_write_tokens INTEGER DEFAULT 0,
reasoning_tokens INTEGER DEFAULT 0,
cwd TEXT,
git_branch TEXT,
git_repo_root TEXT,
billing_provider TEXT,
billing_base_url TEXT,
billing_mode TEXT,
estimated_cost_usd REAL,
actual_cost_usd REAL,
cost_status TEXT,
cost_source TEXT,
pricing_version TEXT,
title TEXT,
api_call_count INTEGER DEFAULT 0,
handoff_state TEXT,
handoff_platform TEXT,
handoff_error TEXT,
compression_failure_cooldown_until REAL,
compression_failure_error TEXT,
compression_fallback_streak INTEGER NOT NULL DEFAULT 0,
profile_name TEXT,
rewind_count INTEGER NOT NULL DEFAULT 0,
archived INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (parent_session_id) REFERENCES sessions(id)
);
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL REFERENCES sessions(id),
role TEXT NOT NULL,
content TEXT,
tool_call_id TEXT,
tool_calls TEXT,
tool_name TEXT,
effect_disposition TEXT,
timestamp REAL NOT NULL,
token_count INTEGER,
finish_reason TEXT,
reasoning TEXT,
reasoning_content TEXT,
reasoning_details TEXT,
codex_reasoning_items TEXT,
codex_message_items TEXT,
platform_message_id TEXT,
observed INTEGER DEFAULT 0,
active INTEGER NOT NULL DEFAULT 1,
compacted INTEGER NOT NULL DEFAULT 0,
api_content TEXT
);
CREATE TABLE IF NOT EXISTS session_model_usage (
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
model TEXT NOT NULL,
billing_provider TEXT NOT NULL DEFAULT '',
billing_base_url TEXT NOT NULL DEFAULT '',
billing_mode TEXT NOT NULL DEFAULT '',
task TEXT NOT NULL DEFAULT '',
api_call_count INTEGER NOT NULL DEFAULT 0,
input_tokens INTEGER NOT NULL DEFAULT 0,
output_tokens INTEGER NOT NULL DEFAULT 0,
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
cache_write_tokens INTEGER NOT NULL DEFAULT 0,
reasoning_tokens INTEGER NOT NULL DEFAULT 0,
estimated_cost_usd REAL NOT NULL DEFAULT 0,
actual_cost_usd REAL NOT NULL DEFAULT 0,
cost_status TEXT,
cost_source TEXT,
first_seen REAL,
last_seen REAL,
PRIMARY KEY (session_id, model, billing_provider, billing_base_url, billing_mode, task)
);
CREATE TABLE IF NOT EXISTS state_meta (
key TEXT PRIMARY KEY,
value TEXT
);
CREATE TABLE IF NOT EXISTS gateway_routing (
scope TEXT NOT NULL DEFAULT '',
session_key TEXT NOT NULL,
entry_json TEXT NOT NULL,
updated_at REAL NOT NULL,
PRIMARY KEY (scope, session_key)
);
CREATE TABLE IF NOT EXISTS compression_locks (
session_id TEXT PRIMARY KEY,
holder TEXT NOT NULL,
acquired_at REAL NOT NULL,
expires_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS async_delegations (
delegation_id TEXT PRIMARY KEY,
origin_session TEXT NOT NULL,
origin_ui_session_id TEXT NOT NULL DEFAULT '',
parent_session_id TEXT,
state TEXT NOT NULL,
dispatched_at REAL NOT NULL,
completed_at REAL,
updated_at REAL NOT NULL,
event_json TEXT,
result_json TEXT,
delivery_state TEXT NOT NULL DEFAULT 'pending',
delivery_attempts INTEGER NOT NULL DEFAULT 0,
delivered_at REAL,
owner_pid INTEGER,
owner_started_at INTEGER,
task_json TEXT,
delivery_claim TEXT,
delivery_claimed_at REAL
);
CREATE INDEX IF NOT EXISTS idx_sessions_source ON sessions(source);
CREATE INDEX IF NOT EXISTS idx_sessions_source_id ON sessions(source, id);
CREATE INDEX IF NOT EXISTS idx_sessions_parent ON sessions(parent_session_id);
CREATE INDEX IF NOT EXISTS idx_sessions_started ON sessions(started_at DESC);
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, timestamp);
CREATE INDEX IF NOT EXISTS idx_compression_locks_expires ON compression_locks(expires_at);
CREATE INDEX IF NOT EXISTS idx_session_model_usage_session ON session_model_usage(session_id);
CREATE INDEX IF NOT EXISTS idx_session_model_usage_model ON session_model_usage(model);
CREATE INDEX IF NOT EXISTS idx_async_delegations_delivery
ON async_delegations(delivery_state, completed_at);
"""
# Indexes that reference columns added in later schema versions must be
# created AFTER _reconcile_columns() has had a chance to ADD them on
# existing databases. SCHEMA_SQL above is run by sqlite executescript
# which would otherwise fail on legacy DBs ("no such column: active").
DEFERRED_INDEX_SQL = """
CREATE INDEX IF NOT EXISTS idx_messages_session_active
ON messages(session_id, active, timestamp);
CREATE INDEX IF NOT EXISTS idx_messages_active_null
ON messages(active) WHERE active IS NULL;
CREATE INDEX IF NOT EXISTS idx_sessions_session_key
ON sessions(session_key, started_at DESC);
CREATE INDEX IF NOT EXISTS idx_sessions_gateway_peer
ON sessions(source, user_id, chat_id, chat_type, thread_id, started_at DESC);
CREATE INDEX IF NOT EXISTS idx_sessions_handoff_state
ON sessions(handoff_state, started_at);
"""
# ── Deferred FTS rebuild bookkeeping (schema v23) ──
# While a background index rebuild is pending, two state_meta keys define
# which message rows are currently IN the FTS indexes:
#
# fts_rebuild_high_water H — MAX(messages.id) at the moment the old
# indexes were dropped
# fts_rebuild_progress P — highest id the chunked backfill has indexed
#
# A row is indexed iff id <= P (backfilled) OR id > H (inserted after
# the drop; ids are AUTOINCREMENT so new rows are always > H and the insert
# triggers index them live). Rows in (P, H] are not yet indexed.
#
# Every trigger below gates on that same predicate: firing an FTS5
# external-content 'delete' for a row that is NOT in the index corrupts the
# index, and skipping it for a row that IS indexed leaves a stale entry.
# When no rebuild is pending both keys are absent and COALESCE turns the
# predicate into a tautology (id > -1 OR id <= -1), i.e. normal operation.
# The two state_meta PK probes per write are negligible next to the FTS
# insert itself.
FTS_SQL = """
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
content,
tool_name,
tool_calls,
content='messages',
content_rowid='id'
);
CREATE TRIGGER IF NOT EXISTS messages_fts_insert AFTER INSERT ON messages
WHEN (new.id > COALESCE((SELECT CAST(value AS INTEGER) FROM state_meta
WHERE key = 'fts_rebuild_high_water'), -1)
OR new.id <= COALESCE((SELECT CAST(value AS INTEGER) FROM state_meta
WHERE key = 'fts_rebuild_progress'), -1))
BEGIN
INSERT INTO messages_fts(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_delete AFTER DELETE ON messages
WHEN (old.id > COALESCE((SELECT CAST(value AS INTEGER) FROM state_meta
WHERE key = 'fts_rebuild_high_water'), -1)
OR old.id <= COALESCE((SELECT CAST(value AS INTEGER) FROM state_meta
WHERE key = 'fts_rebuild_progress'), -1))
BEGIN
INSERT INTO messages_fts(messages_fts, 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_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)
AND (old.id > COALESCE((SELECT CAST(value AS INTEGER) FROM state_meta
WHERE key = 'fts_rebuild_high_water'), -1)
OR old.id <= COALESCE((SELECT CAST(value AS INTEGER) FROM state_meta
WHERE key = 'fts_rebuild_progress'), -1))
BEGIN
INSERT INTO messages_fts(messages_fts, rowid, content, tool_name, tool_calls)
VALUES ('delete', old.id, old.content, old.tool_name, old.tool_calls);
INSERT INTO messages_fts(rowid, content, tool_name, tool_calls)
VALUES (new.id, new.content, new.tool_name, new.tool_calls);
END;
"""
# Trigram FTS5 table for CJK substring search. The default unicode61
# tokenizer splits CJK characters into individual tokens, breaking phrase
# matching. The trigram tokenizer creates overlapping 3-byte sequences so
# substring queries work natively for any script (CJK, Thai, etc.).
#
# The trigram index is the most expensive index in state.db (~2.6x the size
# of the text it covers), and ``role='tool'`` rows are ~90% of message bytes
# while being almost entirely machine noise (base64 payloads, file dumps,
# delegation transcripts). The index therefore reads through
# ``messages_fts_trigram_src``, a view that excludes tool rows — they stay
# fully stored in ``messages`` and fully searchable via the standard
# ``messages_fts`` index; they just don't get trigram (CJK substring)
# treatment. ``search_messages`` routes CJK queries that filter on
# ``role='tool'`` to the LIKE fallback for the same reason.
FTS_TRIGRAM_SQL = """
CREATE VIEW IF NOT EXISTS messages_fts_trigram_src AS
SELECT id, role, content, tool_name, tool_calls
FROM messages
WHERE role <> 'tool';
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts_trigram USING fts5(
content,
tool_name,
tool_calls,
content='messages_fts_trigram_src',
content_rowid='id',
tokenize='trigram'
);
CREATE TRIGGER IF NOT EXISTS messages_fts_trigram_insert AFTER INSERT ON messages
WHEN new.role <> 'tool'
AND (new.id > COALESCE((SELECT CAST(value AS INTEGER) FROM state_meta
WHERE key = 'fts_rebuild_high_water'), -1)
OR new.id <= COALESCE((SELECT CAST(value AS INTEGER) FROM state_meta
WHERE key = 'fts_rebuild_progress'), -1))
BEGIN
INSERT INTO messages_fts_trigram(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_trigram_delete AFTER DELETE ON messages
WHEN old.role <> 'tool'
AND (old.id > COALESCE((SELECT CAST(value AS INTEGER) FROM state_meta
WHERE key = 'fts_rebuild_high_water'), -1)
OR old.id <= COALESCE((SELECT CAST(value AS INTEGER) FROM state_meta
WHERE key = 'fts_rebuild_progress'), -1))
BEGIN
INSERT INTO messages_fts_trigram(messages_fts_trigram, 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_trigram_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_rebuild_high_water'), -1)
OR old.id <= COALESCE((SELECT CAST(value AS INTEGER) FROM state_meta
WHERE key = 'fts_rebuild_progress'), -1))
BEGIN
INSERT INTO messages_fts_trigram(messages_fts_trigram, 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_trigram(rowid, content, tool_name, tool_calls)
SELECT new.id, new.content, new.tool_name, new.tool_calls
WHERE new.role <> 'tool';
END;
"""
# ── Legacy (v22 / inline-content) FTS DDL ──────────────────────────────
# Used ONLY to keep an existing pre-v23 install's search working and its
# triggers repairable UNTIL the user opts into `hermes db optimize`. This is
# the exact inline shape v11..v22 shipped: each virtual table stores its own
# copy of ``content || tool_name || tool_calls`` and the trigram table indexes
# every row (including role='tool'). We never CREATE these on a fresh install —
# fresh installs are born on the v23 external-content schema above. These
# constants exist so a legacy DB is never accidentally handed the v23 DDL
# (which would create the external-content trigram source VIEW and leave the
# DB in a mixed, broken state). `optimize_fts_storage()` is what migrates a
# legacy DB to the v23 shape.
LEGACY_FTS_SQL = """
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
content
);
CREATE TRIGGER IF NOT EXISTS messages_fts_insert AFTER INSERT ON messages BEGIN
INSERT INTO messages_fts(rowid, content) VALUES (
new.id,
COALESCE(new.content, '') || ' ' || COALESCE(new.tool_name, '') || ' ' || COALESCE(new.tool_calls, '')
);
END;
CREATE TRIGGER IF NOT EXISTS messages_fts_delete AFTER DELETE ON messages BEGIN
DELETE FROM messages_fts WHERE rowid = old.id;
END;
CREATE TRIGGER IF NOT EXISTS messages_fts_update AFTER UPDATE ON messages BEGIN
DELETE FROM messages_fts WHERE rowid = old.id;
INSERT INTO messages_fts(rowid, content) VALUES (
new.id,
COALESCE(new.content, '') || ' ' || COALESCE(new.tool_name, '') || ' ' || COALESCE(new.tool_calls, '')
);
END;
"""
LEGACY_FTS_TRIGRAM_SQL = """
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts_trigram USING fts5(
content,
tokenize='trigram'
);
CREATE TRIGGER IF NOT EXISTS messages_fts_trigram_insert AFTER INSERT ON messages BEGIN
INSERT INTO messages_fts_trigram(rowid, content) VALUES (
new.id,
COALESCE(new.content, '') || ' ' || COALESCE(new.tool_name, '') || ' ' || COALESCE(new.tool_calls, '')
);
END;
CREATE TRIGGER IF NOT EXISTS messages_fts_trigram_delete AFTER DELETE ON messages BEGIN
DELETE FROM messages_fts_trigram WHERE rowid = old.id;
END;
CREATE TRIGGER IF NOT EXISTS messages_fts_trigram_update AFTER UPDATE ON messages BEGIN
DELETE FROM messages_fts_trigram WHERE rowid = old.id;
INSERT INTO messages_fts_trigram(rowid, content) VALUES (
new.id,
COALESCE(new.content, '') || ' ' || COALESCE(new.tool_name, '') || ' ' || COALESCE(new.tool_calls, '')
);
END;
"""
class SessionDB:
"""
SQLite-backed session storage with FTS5 search.
Thread-safe for the common gateway pattern (multiple reader threads,
single writer via WAL mode). Each method opens its own cursor.
"""
# ── Write-contention tuning ──
# With multiple hermes processes (gateway + CLI sessions + worktree agents)
# all sharing one state.db, WAL write-lock contention causes visible TUI
# freezes. SQLite's built-in busy handler uses a deterministic sleep
# schedule that causes convoy effects under high concurrency.
#
# Instead, we keep the SQLite timeout short (1s) and handle retries at the
# application level with random jitter, which naturally staggers competing
# writers and avoids the convoy.
_WRITE_MAX_RETRIES = 15
_WRITE_RETRY_MIN_S = 0.020 # 20ms
_WRITE_RETRY_MAX_S = 0.150 # 150ms
# Attempt a WAL checkpoint every N successful writes (PASSIVE mode).
_CHECKPOINT_EVERY_N_WRITES = 50
# Merge fragmented FTS5 segments every N successful writes. The message
# triggers append one segment per insert; left unmaintained these grow
# into tens of thousands of segments, so every MATCH must scan them all
# and every insert pays a growing automerge cost — which lengthens the
# write-lock hold time and starves competing writers (gateway + cron
# processes share one state.db), surfacing as "database is locked".
# 'optimize' is a no-op once the index is already merged, so an idle DB
# pays almost nothing; the cadence is deliberately coarse so the one-off
# merge cost is amortised far below the checkpoint cadence.
_OPTIMIZE_EVERY_N_WRITES = 1000
# Session imports intentionally use a lower cap than exports: import holds
# one BEGIN IMMEDIATE transaction, so bounded batches avoid starving live
# gateway/CLI writers. The dashboard accepts one exported JSON/JSONL file
# at a time, so these still cover normal history restores.
_IMPORT_MAX_SESSIONS = 500
_IMPORT_MAX_MESSAGES_PER_SESSION = 10_000
_IMPORT_MAX_TOTAL_MESSAGES = 50_000
_IMPORT_MAX_SESSION_BYTES = 5 * 1024 * 1024
_IMPORT_MAX_TOTAL_BYTES = 25 * 1024 * 1024
def __init__(self, db_path: Path = None, read_only: bool = False):
self.db_path = db_path or DEFAULT_DB_PATH
self.read_only = read_only
self._lock = threading.Lock()
self._write_count = 0
# One-shot guard for the runtime FTS rebuild recovery on the write
# path. A corrupt FTS shadow table makes EVERY message write raise
# the malformed/corrupt error class via the sync triggers; we repair
# in place at most once per SessionDB instance so a genuinely
# unrecoverable database can't put writers into a rebuild loop.
self._fts_runtime_rebuild_attempted = False
self._fts_enabled = False
self._trigram_available = False
self._fts_unavailable_warned = False
self._conn = None
try:
if read_only:
# Read-only attach for cross-profile aggregation: SELECT-only,
# so we skip schema init entirely (no DDL, no FTS probe, no
# column reconcile). Crucially this takes NO write lock, so
# polling another profile's live DB on every sidebar refresh
# never contends with that profile's running backend. The DB
# must already exist + be initialised (callers guard on
# db_path.exists()); a SELECT against an empty file raises and
# the caller degrades per-profile.
self._conn = sqlite3.connect(
f"file:{self.db_path}?mode=ro",
uri=True,
check_same_thread=False,
timeout=1.0,
isolation_level=None,
)
self._conn.row_factory = sqlite3.Row
return
self.db_path.parent.mkdir(parents=True, exist_ok=True)
def _connect_and_init():
self._conn = sqlite3.connect(
str(self.db_path),
check_same_thread=False,
# Short timeout — application-level retry with random
# jitter handles contention instead of sitting in
# SQLite's internal busy handler for up to 30s.
timeout=1.0,
# auto-starts transactions on DML, which conflicts with
# our explicit BEGIN IMMEDIATE. None = we manage
# transactions ourselves.
isolation_level=None,
)
self._conn.row_factory = sqlite3.Row
apply_wal_with_fallback(self._conn, db_label="state.db")
self._conn.execute("PRAGMA foreign_keys=ON")
self._init_schema()
try:
_connect_and_init()
except sqlite3.DatabaseError as exc:
# The malformed-schema class (e.g. a duplicate sqlite_master
# row for messages_fts) fails on the very first statement —
# before _init_schema can run — so it can't be caught at the
# FTS-rebuild layer. Recover by repairing sqlite_master in
# place (backup first; canonical sessions/messages preserved),
# then reopen once. This is what lets Desktop/Dashboard
# self-heal instead of silently showing "no sessions".
if not is_malformed_db_error(exc) or not _claim_repair_attempt(self.db_path):
raise
logger.error(
"state.db schema is malformed (%s) — attempting automatic "
"repair (a backup copy is made first).", exc,
)
try:
if self._conn is not None:
self._conn.close()
except Exception:
pass
report = repair_state_db_schema(self.db_path)
if not report.get("repaired"):
raise
_connect_and_init()
# NOTE: the v23 FTS optimization is OPT-IN (`hermes db optimize`),
# never auto-started on open. Legacy installs keep their working
# v22 inline FTS untouched here; only the explicit foreground
# command demotes + rebuilds. This avoids a background worker
# racing session lifecycle and the surprise disk/latency cost on
# an unattended open. (An interrupted optimize resumes when the
# user re-runs the command.)
except Exception as exc:
# Capture the cause so /resume and friends can surface WHY the
# session DB is unavailable instead of a bare "Session database
# not available." Callers that catch this exception keep their
# existing ``self._session_db = None`` degradation path.
#
# Note: we deliberately do NOT clear _last_init_error on the
# success path (no else branch). In multi-threaded callers
# (gateway, web_server per-request SessionDB()), a concurrent
# successful open racing past this failure would erase the
# cause that another thread's /resume is about to format.
# Tests that need to reset the state can call
# ``hermes_state._set_last_init_error(None)`` explicitly.
_set_last_init_error(f"{type(exc).__name__}: {exc}")
raise
# ── Core write helper ──
@staticmethod
def _is_fts5_unavailable_error(exc: sqlite3.OperationalError) -> bool:
err = str(exc).lower()
if "no such module" in err and "fts5" in err:
return True
# SQLite builds that have FTS5 but lack the optional trigram tokenizer
# raise "no such tokenizer: trigram" instead of "no such module".
# Scope to trigram specifically to avoid masking unrelated tokenizer errors.
if "no such tokenizer: trigram" 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()
@staticmethod
def _db_has_legacy_inline_fts(cursor: sqlite3.Cursor) -> bool:
"""True when messages_fts exists in ANY pre-v23 shape.
v23's messages_fts is external-content over THREE real columns
(content, tool_name, tool_calls). Every pre-v23 shape lacks the
tool_name/tool_calls columns — whether the old inline single-column
form (v11..v22) or the even older external-content single-column form
(v10-era, pre-#16751). We therefore detect "needs optimize" as "the
stored CREATE lacks the tool_name column", which is the precise v23
marker and correctly catches BOTH legacy variants.
Returns False when messages_fts doesn't exist yet (fresh DB mid-init):
the post-migration FTS setup block will create it in the v23 shape.
"""
row = cursor.execute(
"SELECT sql FROM sqlite_master "
"WHERE type = 'table' AND name = 'messages_fts'"
).fetchone()
if row is None:
return False
sql = (row[0] if not isinstance(row, sqlite3.Row) else row["sql"]) or ""
# The v23 table declares tool_name/tool_calls columns. Their absence
# means a legacy shape that doesn't index tool metadata → optimize.
return "tool_name" not in sql
def _warn_trigram_unavailable(self, exc: sqlite3.OperationalError) -> None:
"""Log once that the trigram tokenizer is missing; base FTS5 stays enabled."""
if getattr(self, "_trigram_unavailable_warned", False):
return
self._trigram_unavailable_warned = True
logger.info(
"SQLite trigram tokenizer unavailable for %s "
"(requires SQLite >= 3.34, this build is %s); "
"CJK/substring search will fall back to LIKE: %s",
self.db_path,
sqlite3.sqlite_version,
exc,
)
def _warn_fts5_unavailable(self, exc: sqlite3.OperationalError) -> None:
self._fts_enabled = False
if self._fts_unavailable_warned:
return
self._fts_unavailable_warned = True
logger.warning(
"SQLite FTS5 unavailable for %s; full-text session search "
"disabled. Run `hermes update` to rebuild the venv with a "
"current Python (managed uv guarantees FTS5). "
"(underlying error: %s)",
self.db_path,
exc,
)
def _sqlite_supports_fts5(self, cursor: sqlite3.Cursor) -> bool:
try:
cursor.execute("CREATE VIRTUAL TABLE temp._hermes_fts5_probe USING fts5(x)")
cursor.execute("DROP TABLE temp._hermes_fts5_probe")
return True
except sqlite3.OperationalError as exc:
if not self._is_fts5_unavailable_error(exc):
raise
self._warn_fts5_unavailable(exc)
return False
@staticmethod
def _drop_fts_triggers(cursor: sqlite3.Cursor) -> None:
for trigger in _FTS_TRIGGERS:
try:
cursor.execute(f"DROP TRIGGER IF EXISTS {trigger}")
except sqlite3.OperationalError:
pass
@staticmethod
def _fts_trigger_count(cursor: sqlite3.Cursor) -> int:
placeholders = ",".join("?" for _ in _FTS_TRIGGERS)
row = cursor.execute(
f"SELECT COUNT(*) FROM sqlite_master "
f"WHERE type = 'trigger' AND name IN ({placeholders})",
_FTS_TRIGGERS,
).fetchone()
return int(row[0] if not isinstance(row, sqlite3.Row) else row[0])
@staticmethod
def _rebuild_fts_indexes(
cursor: sqlite3.Cursor,
*,
include_trigram: bool = True,
) -> None:
# Both FTS tables are external-content (v23+): the special 'rebuild'
# command wipes the inverted index and repopulates it from the
# content source (messages for the standard index, the tool-row-
# excluding messages_fts_trigram_src view for the trigram index).
cursor.execute("INSERT INTO messages_fts(messages_fts) VALUES('rebuild')")
if include_trigram:
cursor.execute(
"INSERT INTO messages_fts_trigram(messages_fts_trigram) VALUES('rebuild')"
)
# 'rebuild' indexes EVERY row, so any deferred-backfill markers are
# now satisfied — clear them, otherwise the background worker would
# re-insert rows the rebuild already covered (duplicate entries).
cursor.execute(
"DELETE FROM state_meta WHERE key IN "
"('fts_rebuild_high_water', 'fts_rebuild_progress')"
)
@staticmethod
def _rebuild_legacy_fts_indexes(
cursor: sqlite3.Cursor,
*,
include_trigram: bool = True,
) -> None:
"""Rebuild the LEGACY inline FTS indexes (pre-v23) from messages.
Used only to repair a legacy DB whose triggers degraded under an
earlier no-FTS5 runtime. Inline tables have no external-content
'rebuild' source, so we DELETE + reinsert the concatenated content
the legacy triggers produced. Never touches the v23 shape.
"""
cursor.execute("DELETE FROM messages_fts")
cursor.execute(
"INSERT INTO messages_fts(rowid, content) "
"SELECT id, "
"COALESCE(content, '') || ' ' || "
"COALESCE(tool_name, '') || ' ' || "
"COALESCE(tool_calls, '') "
"FROM messages"
)
if not include_trigram:
return
cursor.execute("DELETE FROM messages_fts_trigram")
cursor.execute(
"INSERT INTO messages_fts_trigram(rowid, content) "
"SELECT id, "
"COALESCE(content, '') || ' ' || "
"COALESCE(tool_name, '') || ' ' || "
"COALESCE(tool_calls, '') "
"FROM messages"
)
def _fts_table_probe(self, cursor: sqlite3.Cursor, table_name: str) -> Optional[bool]:
try:
cursor.execute(f"SELECT * FROM {table_name} LIMIT 0")
return True
except sqlite3.OperationalError as exc:
if self._is_fts5_unavailable_error(exc):
# Only disable FTS entirely when the whole module is missing.
# A missing trigram tokenizer only affects trigram searches.
if self._is_trigram_unavailable_error(exc):
self._warn_trigram_unavailable(exc)
else:
self._warn_fts5_unavailable(exc)
return None
if "no such table" in str(exc).lower():
return False
raise
def _ensure_fts_schema(
self,
cursor: sqlite3.Cursor,
table_name: str,
ddl: str,
) -> bool:
status = self._fts_table_probe(cursor, table_name)
if status is None:
return False
try:
# Run even when the virtual table exists so any dropped or missing
# triggers are recreated after a previous no-FTS5 runtime disabled
# them to keep message writes working.
cursor.executescript(ddl)
return True
except sqlite3.OperationalError as exc:
if not self._is_fts5_unavailable_error(exc):
raise
# Only disable FTS entirely when the whole FTS5 module is missing.
# A missing specific tokenizer (e.g. trigram) means only that
# particular table cannot be created — the base FTS5 table is fine.
if self._is_trigram_unavailable_error(exc):
self._warn_trigram_unavailable(exc)
else:
self._warn_fts5_unavailable(exc)
return False
def _execute_write(
self,
fn: Callable[[sqlite3.Connection], T],
) -> T:
"""Execute a write transaction with BEGIN IMMEDIATE and jitter retry.
*fn* receives the connection and should perform INSERT/UPDATE/DELETE
statements. The caller must NOT call ``commit()`` — that's handled
here after *fn* returns.
BEGIN IMMEDIATE acquires the WAL write lock at transaction start
(not at commit time), so lock contention surfaces immediately.
On ``database is locked``, we release the Python lock, sleep a
random 20-150ms, and retry — breaking the convoy pattern that
SQLite's built-in deterministic backoff creates.
Returns whatever *fn* returns.
"""
last_err: Optional[Exception] = None
for attempt in range(self._WRITE_MAX_RETRIES):
try:
with self._lock:
self._conn.execute("BEGIN IMMEDIATE")
try:
result = fn(self._conn)
self._conn.commit()
except BaseException:
try:
self._conn.rollback()
except Exception:
pass
raise
# Success — periodic best-effort checkpoint + FTS merge.
self._write_count += 1
if self._write_count % self._CHECKPOINT_EVERY_N_WRITES == 0:
self._try_wal_checkpoint()
if self._write_count % self._OPTIMIZE_EVERY_N_WRITES == 0:
self._try_optimize_fts()
return result
except sqlite3.OperationalError as exc:
err_msg = str(exc).lower()
if "locked" in err_msg or "busy" in err_msg:
last_err = exc
if attempt < self._WRITE_MAX_RETRIES - 1:
jitter = random.uniform(
self._WRITE_RETRY_MIN_S,
self._WRITE_RETRY_MAX_S,
)
time.sleep(jitter)
continue
# Non-lock error or retries exhausted — propagate.
raise
except sqlite3.DatabaseError as exc:
# Corrupt FTS shadow tables make every write raise the
# malformed/corrupt error class through the FTS sync triggers
# while the canonical messages table is intact. The gateway
# session store has its own retry queue for transcript
# appends (#65637 salvage), but cron and CLI writers call
# SessionDB directly — without this, their writes hard-fail
# until the next process restart triggers the offline repair.
# Rebuild the FTS index in place (once per instance) via
# rebuild_fts() and retry the failed write immediately.
if not self._try_runtime_fts_rebuild(exc):
raise
continue
# Retries exhausted (shouldn't normally reach here).
raise last_err or sqlite3.OperationalError(
"database is locked after max retries"
)
@staticmethod
def _is_fts_write_corruption_error(exc: sqlite3.DatabaseError) -> bool:
"""True for the error class a corrupt FTS index raises on writes.
The message varies by SQLite version: older builds raise the generic
``database disk image is malformed`` (covered by
``is_malformed_db_error``); newer builds (e.g. ubuntu-latest CI)
raise the FTS5-specific ``fts5: corrupt structure record for table
"messages_fts"``. Both mean the same thing for the write path: the
canonical rows are fine, the FTS shadow tables are not.
"""
if is_malformed_db_error(exc):
return True
msg = str(exc).lower()
return "fts5" in msg and "corrupt" in msg
def _try_runtime_fts_rebuild(self, exc: sqlite3.DatabaseError) -> bool:
"""One-shot in-place FTS rebuild after a corrupt-index write failure.
Returns True when a rebuild was performed and the failed write should
be retried; False when the error isn't the FTS-corruption class, FTS
is disabled, or a rebuild was already attempted for this instance.
Delegates to :meth:`rebuild_fts` (the FTS5 ``'rebuild'`` command —
index rewritten from the canonical messages table, zero message-row
mutation). Safe to call from ``_execute_write``'s except path: the
failed transaction was rolled back and ``self._lock`` released before
the exception propagated, and ``rebuild_fts`` re-acquires it.
E2E-verified: a corrupted ``messages_fts_data`` shadow table rejects
every append; after the in-place rebuild the same append succeeds and
search works again.
"""
if self._fts_runtime_rebuild_attempted:
return False
if not self._fts_enabled:
return False
if not self._is_fts_write_corruption_error(exc):
return False
self._fts_runtime_rebuild_attempted = True
logger.warning(
"state.db write failed with an FTS-corruption error (%s) — "
"attempting one-shot in-place FTS rebuild; canonical message "
"rows are preserved.", exc,
)
try:
rebuilt = self.rebuild_fts()
except Exception as rebuild_exc:
logger.error(
"In-place FTS rebuild failed (%s); the database needs the "
"full offline repair path (repair_state_db_schema).",
rebuild_exc,
)
return False
if not rebuilt:
logger.error(
"In-place FTS rebuild made no progress; the database needs "
"the full offline repair path (repair_state_db_schema)."
)
return False
logger.warning(
"state.db FTS indexes rebuilt in place (%d); retrying the failed write.",
rebuilt,
)
return True
def _try_wal_checkpoint(self) -> None:
"""Best-effort PASSIVE WAL checkpoint. Never raises.
Flushes committed WAL frames back into the main DB file without
requiring an exclusive lock. PASSIVE is safe for frequent
periodic use because it does not block concurrent writers and
cannot corrupt B-tree pages under I/O pressure.
PASSIVE does not truncate the WAL file — it stays at its
high-water mark. WAL truncation happens in :meth:`close`
(TRUNCATE) and pre-VACUUM checkpoints, which run infrequently
under controlled conditions.
Previous TRUNCATE strategy caused B-tree corruption on large
databases (65K+ pages) due to the exclusive-lock I/O pressure
from checkpointing thousands of frames at once (issue #45383).
"""
try:
with self._lock:
result = self._conn.execute(
"PRAGMA wal_checkpoint(PASSIVE)"
).fetchone()
if result and result[1] > 0:
logger.debug(
"WAL checkpoint: %d/%d pages checkpointed",
result[2], result[1],
)
except Exception as exc:
logger.warning("WAL checkpoint (PASSIVE) failed: %s", exc)
def _try_optimize_fts(self) -> None:
"""Best-effort FTS5 segment merge. Never raises.
Runs on the ``_OPTIMIZE_EVERY_N_WRITES`` cadence from the write hot
path (off the lock — ``optimize_fts`` re-acquires ``self._lock``
itself, mirroring ``_try_wal_checkpoint``). ``read_only`` connections
never reach the write path, so this is implicitly skipped for them.
Once the index is merged the 'optimize' command is close to free, so
the steady-state cost is negligible; the expensive case is only the
first merge of a long-neglected index.
"""
try:
self.optimize_fts()
except Exception:
pass # Best effort — never fatal.
def close(self):
"""Close the database connection.
Attempts a TRUNCATE WAL checkpoint first so that exiting processes
help shrink the WAL file.
"""
with self._lock:
if self._conn:
try:
self._conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
except Exception as exc:
logger.debug("WAL checkpoint (TRUNCATE) at close failed: %s", exc)
self._conn.close()
self._conn = None
# ── Chunked FTS rebuild engine (v23 opt-in optimize) ──
#
# `optimize_fts_storage()` (the `hermes sessions optimize-storage`
# command) drops the legacy inline FTS indexes and backfills the new
# external-content ones. A single blocking rebuild measured ~16 minutes
# of held write lock on a real 25 GB DB, so the backfill runs in small
# chunks, each in its own short write transaction:
# - concurrent readers/writers are never starved (WAL stays small,
# each chunk checkpoints via the normal _execute_write cadence);
# - an interrupted run (Ctrl-C, crash) resumes from
# fts_rebuild_progress when the command is re-run;
# - multiple processes sharing the DB don't double-run it — each chunk
# claims work by compare-and-swap on fts_rebuild_progress, so even a
# concurrent second runner just interleaves chunks safely.
#
# THROTTLING (the part that keeps a live gateway sharing the DB
# responsive): a greedy chunk loop re-acquires BEGIN IMMEDIATE nearly
# back-to-back and can starve another process's writer into exhausting
# its lock retries (an early 5000-row/50ms version owned the write lock
# ~85% of the time and visibly froze concurrent CLI sessions on a large
# install). Two layers prevent that:
# 1. Small chunks (500 rows) — a foreground write queues behind a
# chunk for at most ~tens of ms.
# 2. Inter-chunk pause — the loop sleeps max(_FTS_REBUILD_MIN_PAUSE,
# chunk cost x _FTS_REBUILD_DUTY_FACTOR) between chunks, capping
# this process's share of DB bandwidth so concurrent writers always
# find open windows. This works cross-process (unlike any
# same-process activity stamp) because it bounds our own duty
# cycle unconditionally.
_FTS_REBUILD_CHUNK_ROWS = 500
_FTS_REBUILD_DUTY_FACTOR = 4.0 # sleep >= 4x chunk cost (≤20% duty)
_FTS_REBUILD_MIN_PAUSE = 0.2 # seconds — floor between chunks
def fts_rebuild_status(self) -> Optional[Dict[str, Any]]:
"""Return deferred-rebuild progress, or None when no rebuild pending.
Shape: {"pending": True, "total": <rows at drop time>,
"indexed": <rows backfilled>, "percent": <0-100 int>}.
Consumed by search_messages() notes and by status surfaces
(dashboard/desktop can poll this to render a progress indicator).
"""
high_water = self.get_meta("fts_rebuild_high_water")
if high_water is None:
return None
progress = int(self.get_meta("fts_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_rebuild_finish(self) -> None:
"""Finalize the deferred rebuild: boundary sweep + clear markers.
The sweep is cheap insurance against any write that slipped through
the migration-boundary instant (between high_water capture and
trigger activation): re-index any row near the boundary that the
index is missing. docsize has one row per indexed doc, so the
anti-join is exact and runs on a narrow id range.
"""
def _do(conn):
hw_row = conn.execute(
"SELECT value FROM state_meta WHERE key = 'fts_rebuild_high_water'"
).fetchone()
if hw_row is not None:
hw = int(hw_row[0])
# Sweep a generous window around the boundary.
lo, hi = hw - 1000, hw + 1000
conn.execute(
"INSERT INTO messages_fts(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 NOT EXISTS (SELECT 1 FROM messages_fts_docsize d WHERE d.id = m.id)",
(lo, hi),
)
conn.execute(
"INSERT INTO messages_fts_trigram(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_trigram_docsize d WHERE d.id = m.id)",
(lo, hi),
)
conn.execute(
"DELETE FROM state_meta WHERE key IN "
"('fts_rebuild_high_water', 'fts_rebuild_progress')"
)
self._execute_write(_do)
logger.info("Deferred FTS rebuild complete — all messages indexed.")
# Demoted v22 FTS shadow tables awaiting teardown (see the v23 migration:
# DROP of a multi-GB FTS vtable blocks for minutes, so the migration
# demotes the vtable definitions out of sqlite_master and renames the
# orphaned shadow tables — now plain tables — to fts_v22_trash_*; the
# worker empties them in bounded chunks, then drops them cheaply).
_FTS_TRASH_PREFIX = "fts_v22_trash_"
def _fts_teardown_trash_step(self) -> bool:
"""Tear down one chunk of a demoted v22 FTS shadow table.
The trash tables are PLAIN tables (their vtable parent was demoted
away during the migration), so chunked DELETE + final DROP involve
no FTS5 machinery at all. Returns True while teardown work remains.
"""
with self._lock:
trash = [
r[0] for r in self._conn.execute(
"SELECT name FROM sqlite_master WHERE type = 'table' "
"AND name LIKE ? ESCAPE '\\'",
(self._FTS_TRASH_PREFIX.replace("_", "\\_") + "%",),
).fetchall()
]
if not trash:
return False
tbl = trash[0]
def _do(conn):
pk_cols = [
r[1] for r in conn.execute(f"PRAGMA table_info({tbl})")
if r[5] > 0
]
key = ", ".join(pk_cols) if pk_cols else "rowid"
cur = conn.execute(
f"DELETE FROM {tbl} WHERE ({key}) IN "
f"(SELECT {key} FROM {tbl} LIMIT {self._FTS_REBUILD_CHUNK_ROWS})"
)
if cur.rowcount == 0:
# Empty — the DROP is cheap now.
conn.execute(f"DROP TABLE IF EXISTS {tbl}")
logger.info("Old FTS shadow table %s torn down.", tbl)
return True # re-check: more trash tables / chunks may remain
try:
return bool(self._execute_write(_do))
except sqlite3.OperationalError as exc:
logger.debug("FTS trash teardown chunk failed (will retry): %s", exc)
return True
def fts_rebuild_step(self) -> bool:
"""Backfill one chunk of the deferred FTS rebuild.
Returns True when more work remains, False when the rebuild is
complete (or none is pending). Safe to call from any process at any
time; chunks are claimed atomically inside the write transaction, so
concurrent callers interleave instead of duplicating rows.
"""
if not self._fts_enabled:
return False
high_water_raw = self.get_meta("fts_rebuild_high_water")
if high_water_raw is None:
return False
high_water = int(high_water_raw)
include_trigram = self._trigram_available
chunk = self._FTS_REBUILD_CHUNK_ROWS
def _do(conn):
# Re-read progress inside the write transaction (BEGIN IMMEDIATE
# is already held by _execute_write) — this is the claim: two
# workers can't read the same progress value concurrently.
row = conn.execute(
"SELECT value FROM state_meta WHERE key = 'fts_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
# The chunk upper bound is an id, not a row count, so gaps from
# deleted rows don't shrink chunks below the claimed range.
upper = min(progress + chunk, high_water)
conn.execute(
"INSERT INTO messages_fts(rowid, content, tool_name, tool_calls) "
"SELECT id, content, tool_name, tool_calls FROM messages "
"WHERE id > ? AND id <= ?",
(progress, upper),
)
if include_trigram:
conn.execute(
"INSERT INTO messages_fts_trigram"
"(rowid, content, tool_name, tool_calls) "
"SELECT id, content, tool_name, tool_calls FROM messages "
"WHERE id > ? AND id <= ? AND role <> 'tool'",
(progress, upper),
)
# Publish progress in the same transaction as the rows it
# covers — crash-atomic: either both land or neither does.
conn.execute(
"UPDATE state_meta SET value = ? "
"WHERE key = 'fts_rebuild_progress'",
(str(upper),),
)
return upper < high_water
try:
more = self._execute_write(_do)
except sqlite3.OperationalError as exc:
logger.debug("FTS rebuild chunk failed (will retry): %s", exc)
return True # transient (lock contention) — caller retries
if more is False:
status = self.fts_rebuild_status()
if status is not None and status["indexed"] >= status["total"]:
self._fts_rebuild_finish()
return False
return bool(more)
# ── Opt-in v23 FTS storage optimization (`hermes sessions optimize-storage`) ──
#
# This is the ONLY path that migrates an existing legacy (v22 inline) DB
# to the v23 external-content schema. It is deliberately foreground and
# user-invoked, never automatic, because it is disk-heavy and long. It
# runs the throttled/resumable chunk engine above to completion
# synchronously — demote → new schema → chunked backfill → chunked
# teardown — with progress callbacks, a disk preflight in the CLI
# wrapper, a VACUUM at the end, and a defensive schema_version bump.
def fts_optimize_available(self) -> bool:
"""True when `optimize_fts_storage()` has work to do: either this DB
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)."""
if not self._fts_enabled or self.read_only:
return False
with self._lock:
if self._db_has_legacy_inline_fts(self._conn):
return True
# Interrupted optimize: demotion already removed the legacy
# vtables (so the check above is False), but the transition is
# unfinished until the backfill markers are cleared and the
# demoted trash tables are torn down. Search stays complete
# through the gap supplement meanwhile; re-running resumes.
if self._conn.execute(
"SELECT 1 FROM state_meta "
"WHERE key = 'fts_rebuild_high_water' LIMIT 1"
).fetchone():
return True
return self._has_fts_trash(self._conn)
def _has_fts_trash(self, conn) -> bool:
"""True when demoted v22 shadow tables are still awaiting teardown.
Caller must hold ``self._lock`` (or pass a migration-time cursor)."""
return bool(conn.execute(
"SELECT 1 FROM sqlite_master WHERE type = 'table' "
"AND name LIKE ? ESCAPE '\\' LIMIT 1",
(self._FTS_TRASH_PREFIX.replace("_", "\\_") + "%",),
).fetchone())
def _demote_legacy_fts_to_trash(self) -> int:
"""Demote the legacy inline FTS vtables and stage their shadow tables
for chunked teardown. Returns MAX(messages.id) as the rebuild high
water. O(1) schema surgery — the heavy delete is deferred to the
chunked teardown, exactly as the validated auto path did."""
def _do(conn):
self._drop_fts_triggers(conn)
conn.execute("DROP VIEW IF EXISTS messages_fts_trigram_src")
had = bool(conn.execute(
"SELECT 1 FROM sqlite_master WHERE type = 'table' "
"AND name IN ('messages_fts', 'messages_fts_trigram') "
"AND sql LIKE 'CREATE VIRTUAL TABLE%' LIMIT 1"
).fetchone())
if had:
conn.execute("PRAGMA writable_schema=ON")
conn.execute(
"DELETE FROM sqlite_master WHERE type = 'table' "
"AND name IN ('messages_fts', 'messages_fts_trigram') "
"AND sql LIKE 'CREATE VIRTUAL TABLE%'"
)
conn.execute("PRAGMA writable_schema=RESET")
shadows = [
r[0] for r in conn.execute(
"SELECT name FROM sqlite_master WHERE type = 'table' "
"AND (name LIKE 'messages_fts_%' ESCAPE '\\' "
"OR name LIKE 'messages_fts_trigram_%' ESCAPE '\\')"
).fetchall()
]
for sh in shadows:
conn.execute(f"ALTER TABLE {sh} RENAME TO fts_v22_trash_{sh}")
# Create the new v23 empty schema + set the backfill markers.
self._ensure_fts_schema(conn, "messages_fts", FTS_SQL)
self._ensure_fts_schema(conn, "messages_fts_trigram", FTS_TRIGRAM_SQL)
hw = conn.execute("SELECT COALESCE(MAX(id), 0) FROM messages").fetchone()[0]
for k, v in (
("fts_rebuild_high_water", str(hw)),
("fts_rebuild_progress", "0"),
):
conn.execute(
"INSERT INTO state_meta (key, value) VALUES (?, ?) "
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
(k, v),
)
conn.execute("DELETE FROM state_meta WHERE key = 'fts_optimize_available'")
return hw
return int(self._execute_write(_do))
def optimize_fts_storage(
self,
*,
progress_cb: Optional[Callable[[Dict[str, Any]], None]] = None,
vacuum: bool = True,
) -> Dict[str, Any]:
"""Migrate a legacy v22 inline-FTS DB to the v23 external-content
schema, foreground and to completion. Safe to re-run: if a previous
attempt was interrupted it resumes from the progress marker.
``progress_cb`` receives {"phase", "percent", "indexed", "total"}
dicts for a CLI progress bar. Returns a summary dict.
The trigram tokenizer being unavailable is not fatal — the base index
is still rebuilt (CJK falls back to LIKE), mirroring normal startup.
"""
if not self._fts_enabled:
return {"ok": False, "reason": "fts5_unavailable"}
if self.read_only:
return {"ok": False, "reason": "read_only"}
# Only demote if we're actually still on the legacy shape. If a prior
# run already demoted (markers/trash present), skip straight to
# finishing the backfill + teardown — this is what makes re-running
# after an interruption safe.
with self._lock:
legacy = self._db_has_legacy_inline_fts(self._conn)
pending = self.get_meta("fts_rebuild_high_water") is not None
if legacy and not pending:
self._demote_legacy_fts_to_trash()
def _emit(phase: str) -> None:
if progress_cb is None:
return
st = self.fts_rebuild_status()
progress_cb({
"phase": phase,
"percent": st["percent"] if st else 100,
"indexed": st["indexed"] if st else 0,
"total": st["total"] if st else 0,
})
def _pause(chunk_seconds: float) -> None:
"""Inter-chunk throttle (see the chunk-engine note above).
The chunk methods themselves never sleep, so this loop is the
single place the duty cycle is enforced: without it, back-to-back
BEGIN IMMEDIATE chunks starve any live gateway/CLI process
sharing the DB out of its lock retries (the measured ~85%
write-lock ownership that froze concurrent sessions).
"""
time.sleep(max(
self._FTS_REBUILD_MIN_PAUSE,
chunk_seconds * self._FTS_REBUILD_DUTY_FACTOR,
))
# Phase 1: backfill (foreground, throttled between chunks so a live
# gateway sharing the DB stays responsive).
_emit("backfill")
while True:
_t0 = time.monotonic()
if not self.fts_rebuild_step():
break
_emit("backfill")
_pause(time.monotonic() - _t0)
_emit("backfill")
# Phase 2: tear down the demoted legacy shadow tables in chunks.
_emit("teardown")
while True:
_t0 = time.monotonic()
if not self._fts_teardown_trash_step():
break
_emit("teardown")
_pause(time.monotonic() - _t0)
# Phase 3: reclaim freed pages to the OS.
vacuum_ok = None
if vacuum:
_emit("vacuum")
try:
with self._lock:
self._conn.execute("VACUUM")
vacuum_ok = True
except sqlite3.OperationalError as exc:
# Most common cause: not enough free disk for VACUUM's temp
# copy. The optimization still succeeded; space just isn't
# reclaimed until a later VACUUM. Non-fatal.
logger.warning("VACUUM after FTS optimize failed: %s", exc)
vacuum_ok = False
# Phase 4: stamp the FTS storage layout as current, clear the "available"
# flag, and advance schema_version if it was somehow still behind (the
# main version normally advances on open now, but bump defensively so a
# DB opened only by pre-decoupling code still settles). The FTS-layout
# marker is the source of truth for "is this DB optimized".
def _settle(conn):
conn.execute(
"INSERT INTO state_meta (key, value) VALUES ('fts_storage_version', ?) "
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
(str(FTS_STORAGE_VERSION),),
)
conn.execute("DELETE FROM state_meta WHERE key = 'fts_optimize_available'")
conn.execute(
"UPDATE schema_version SET version = ? WHERE version < ?",
(SCHEMA_VERSION, SCHEMA_VERSION),
)
self._execute_write(_settle)
_emit("done")
logger.info(
"FTS storage optimization complete (layout v%d).", FTS_STORAGE_VERSION
)
return {"ok": True, "vacuumed": vacuum_ok}
@staticmethod
def _parse_schema_columns(schema_sql: str) -> Dict[str, Dict[str, str]]:
"""Extract expected columns per table from SCHEMA_SQL.
Uses an in-memory SQLite database to parse the SQL — SQLite itself
handles all syntax (DEFAULT expressions with commas, inline
REFERENCES, CHECK constraints, etc.) so there are zero regex
edge cases. The in-memory DB is opened, the schema DDL is
executed, and PRAGMA table_info extracts the column metadata.
Adding a column to SCHEMA_SQL is all that's needed; the
reconciliation loop picks it up automatically.
"""
ref = sqlite3.connect(":memory:")
try:
ref.executescript(schema_sql)
table_columns: Dict[str, Dict[str, str]] = {}
for (tbl,) in ref.execute(
"SELECT name FROM sqlite_master "
"WHERE type='table' AND name NOT LIKE 'sqlite_%'"
).fetchall():
cols: Dict[str, str] = {}
for row in ref.execute(
f'PRAGMA table_info("{tbl}")'
).fetchall():
# row: (cid, name, type, notnull, dflt_value, pk)
col_name = row[1]
col_type = row[2] or ""
notnull = row[3]
default = row[4]
pk = row[5]
# Reconstruct the type expression for ALTER TABLE ADD COLUMN
parts = [col_type] if col_type else []
if notnull and not pk:
parts.append("NOT NULL")
if default is not None:
parts.append(f"DEFAULT {default}")
cols[col_name] = " ".join(parts)
table_columns[tbl] = cols
return table_columns
finally:
ref.close()
def _reconcile_columns(self, cursor: sqlite3.Cursor) -> None:
"""Ensure live tables have every column declared in SCHEMA_SQL.
Follows the Beets/sqlite-utils pattern: the CREATE TABLE definition
in SCHEMA_SQL is the single source of truth for the desired schema.
On every startup this method diffs the live columns (via PRAGMA
table_info) against the declared columns, and ADDs any that are
missing.
This makes column additions a declarative operation — just add
the column to SCHEMA_SQL and it appears on the next startup.
Version-gated migration blocks are no longer needed for ADD COLUMN.
"""
expected = self._parse_schema_columns(SCHEMA_SQL)
for table_name, declared_cols in expected.items():
# Get current columns from the live table
try:
rows = cursor.execute(
f'PRAGMA table_info("{table_name}")'
).fetchall()
except sqlite3.OperationalError:
continue # Table doesn't exist yet (shouldn't happen after executescript)
live_cols = set()
for row in rows:
# PRAGMA table_info returns (cid, name, type, notnull, dflt_value, pk)
name = row[1] if isinstance(row, (tuple, list)) else row["name"]
live_cols.add(name)
for col_name, col_type in declared_cols.items():
if col_name not in live_cols:
safe_name = col_name.replace('"', '""')
try:
cursor.execute(
f'ALTER TABLE "{table_name}" ADD COLUMN "{safe_name}" {col_type}'
)
except sqlite3.OperationalError as exc:
# Expected: "duplicate column name" from a race or
# re-run. Unexpected: "Cannot add a NOT NULL column
# with default value NULL" from a schema mistake.
# Log at DEBUG so it's visible in agent.log.
logger.debug(
"reconcile %s.%s: %s", table_name, col_name, exc,
)
def _init_schema(self):
"""Create tables and FTS if they don't exist, reconcile columns.
Schema management follows the declarative reconciliation pattern
(Beets, sqlite-utils): SCHEMA_SQL is the single source of truth.
On existing databases, _reconcile_columns() diffs live columns
against SCHEMA_SQL and ADDs any missing ones. This eliminates
the version-gated migration chain for column additions, making
it impossible for reordered or inserted migrations to skip columns.
The schema_version table is retained for future data migrations
(transforming existing rows) which cannot be handled declaratively.
"""
cursor = self._conn.cursor()
cursor.executescript(SCHEMA_SQL)
# ── Declarative column reconciliation ──────────────────────────
# Diff live tables against SCHEMA_SQL and ADD any missing columns.
# This is idempotent and self-healing: even if a version-gated
# migration was skipped (e.g. due to version renumbering), the
# column gets created here.
self._reconcile_columns(cursor)
# Indexes that reference reconciler-added columns must be created
# AFTER _reconcile_columns runs — declaring them in SCHEMA_SQL
# makes the initial executescript fail on legacy DBs (the index's
# WHERE clause references a column that doesn't exist yet).
try:
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_messages_platform_msg_id "
"ON messages(session_id, platform_message_id) "
"WHERE platform_message_id IS NOT NULL"
)
except sqlite3.OperationalError as exc:
logger.debug("idx_messages_platform_msg_id create skipped: %s", exc)
# Deferred indexes that reference the reconciler-added ``active``
# column (idx_messages_session_active) — same ordering constraint.
cursor.executescript(DEFERRED_INDEX_SQL)
# Heal NULL ``active`` rows unconditionally on every startup.
# On real-world DBs the reconciler-added ``active`` column can lack
# its NOT NULL DEFAULT 1 (older reconciler builds reconstructed the
# type without the default — see #51646: PRAGMA shows
# (17,'active','INTEGER',0,None,0) in the wild), so INSERTs that
# omitted the column wrote NULL and the ``WHERE active = 1``
# transcript loaders hid the whole history. The INSERTs now set
# active=1 explicitly; this idempotent repair un-hides rows written
# before the fix. It was previously gated at ``current_version <
# 12`` which never re-ran for already-v12+ databases.
try:
cursor.execute(
"UPDATE messages SET active = 1 WHERE active IS NULL"
)
except sqlite3.OperationalError:
pass
fts5_available = self._sqlite_supports_fts5(cursor)
fts_migrations_complete = True
if not fts5_available:
# Existing FTS triggers can still fire on messages INSERT/UPDATE
# even though the current sqlite runtime cannot read the virtual
# tables they target. Drop only the triggers so core persistence
# continues; if a future runtime has FTS5, _ensure_fts_schema()
# recreates them.
self._drop_fts_triggers(cursor)
# ── Schema version bookkeeping ─────────────────────────────────
# Bump to current so future data migrations (if any) can gate on
# version. No version-gated column additions remain.
cursor.execute("SELECT version FROM schema_version LIMIT 1")
row = cursor.fetchone()
if row is None:
cursor.execute(
"INSERT INTO schema_version (version) VALUES (?)",
(SCHEMA_VERSION,),
)
else:
current_version = row["version"] if isinstance(row, sqlite3.Row) else row[0]
# Data migrations that can't be expressed declaratively (row
# backfills, index changes tied to a specific version step) stay
# in a version-gated chain. Column additions are handled by
# _reconcile_columns() above and no longer need entries here.
if current_version < 10 and SCHEMA_VERSION == 10:
# v10: trigram FTS5 table for CJK/substring search. The
# virtual table + triggers are created unconditionally via
# FTS_TRIGRAM_SQL below, but existing rows need a one-time
# backfill into the FTS index.
#
# Only run this when v10 itself is the target schema. Current
# v11+ code drops and rebuilds both FTS tables below, so doing
# the v10-only trigram backfill first only burns startup time
# and WAL space before v11 throws the work away.
if fts5_available:
_fts_trigram_exists = self._fts_table_probe(
cursor, "messages_fts_trigram"
)
if _fts_trigram_exists is False:
if self._ensure_fts_schema(
cursor, "messages_fts_trigram", FTS_TRIGRAM_SQL
):
cursor.execute(
"INSERT INTO messages_fts_trigram(rowid, content) "
"SELECT id, content FROM messages WHERE content IS NOT NULL"
)
else:
fts_migrations_complete = False
elif _fts_trigram_exists is None:
fts_migrations_complete = False
else:
fts_migrations_complete = False
if current_version < 11 and SCHEMA_VERSION < 23:
# v11 (SUPERSEDED by v23): re-index FTS5 tables to cover
# tool_name + tool_calls in inline mode (#16751). v23 drops
# and rebuilds both FTS tables in external-content form, so
# running the v11 inline backfill first would only burn
# startup time and WAL space before v23 throws the work
# away — and its inline INSERT shape no longer matches the
# current external-content FTS_SQL anyway. Kept only for
# source archaeology; unreachable while SCHEMA_VERSION >= 23.
pass
if current_version < 16:
# v16: tag delegate subagent rows so pickers stay clean after
# parent deletes that used to orphan them (parent_session_id → NULL).
try:
cursor.execute(
"UPDATE sessions SET model_config = json_set("
"COALESCE(model_config, '{}'), '$._delegate_from', parent_session_id) "
f"WHERE parent_session_id IS NOT NULL "
"AND json_extract(COALESCE(model_config, '{}'), '$._delegate_from') IS NULL "
f"AND {_ephemeral_child_sql('sessions')}"
)
cursor.execute(
"UPDATE sessions SET model_config = json_set("
"COALESCE(model_config, '{}'), '$._delegate_from', '__orphaned__') "
"WHERE parent_session_id IS NULL "
"AND json_extract(COALESCE(model_config, '{}'), '$._delegate_from') IS NULL "
"AND json_extract(COALESCE(model_config, '{}'), '$._branched_from') IS NULL "
"AND title IS NULL "
"AND message_count <= 25 "
"AND EXISTS (SELECT 1 FROM messages m "
" WHERE m.session_id = sessions.id AND m.role = 'tool') "
"AND NOT EXISTS (SELECT 1 FROM sessions ch "
" WHERE ch.parent_session_id = sessions.id)"
)
except sqlite3.OperationalError:
pass
if current_version < 18:
# v18: gateway metadata consolidation (#9006). Backfill
# display_name / origin_json / expiry_finalized from
# sessions.json so pre-migration gateway sessions are
# discoverable from state.db without the JSON index.
try:
self._backfill_gateway_metadata_from_sessions_json(cursor)
except Exception as exc:
# Backfill is best-effort: sessions.json may be absent,
# corrupted, or partially stale. Missing metadata simply
# means consumers fall back to sessions.json for those
# rows until the gateway rewrites them.
logger.debug("v18 gateway metadata backfill skipped: %s", exc)
if current_version < 20:
# v20: per-model usage attribution (issue #51607). Going
# forward update_token_counts() records each API call into
# session_model_usage keyed by the live model, but existing
# sessions only have their aggregate totals on the sessions
# row. Seed one usage row per historical session from those
# aggregates so insights reads uniformly from the new table.
# INSERT OR IGNORE keeps it idempotent: if newer code already
# wrote a (session_id, model, provider) row for a session, the
# PK conflict skips the stale aggregate rather than doubling it.
try:
cursor.execute(
"""INSERT OR IGNORE INTO session_model_usage (
session_id, model, billing_provider,
billing_base_url, billing_mode,
api_call_count, input_tokens,
output_tokens, cache_read_tokens,
cache_write_tokens, reasoning_tokens,
estimated_cost_usd, actual_cost_usd,
cost_status, cost_source, first_seen, last_seen
)
SELECT id, COALESCE(model, 'unknown'),
COALESCE(billing_provider, ''),
COALESCE(billing_base_url, ''),
COALESCE(billing_mode, ''),
COALESCE(api_call_count, 0),
COALESCE(input_tokens, 0),
COALESCE(output_tokens, 0),
COALESCE(cache_read_tokens, 0),
COALESCE(cache_write_tokens, 0),
COALESCE(reasoning_tokens, 0),
COALESCE(estimated_cost_usd, 0),
COALESCE(actual_cost_usd, 0),
cost_status, cost_source,
started_at, COALESCE(ended_at, started_at)
FROM sessions
WHERE COALESCE(input_tokens, 0)
+ COALESCE(output_tokens, 0)
+ COALESCE(cache_read_tokens, 0)
+ COALESCE(cache_write_tokens, 0)
+ COALESCE(reasoning_tokens, 0) > 0"""
)
except sqlite3.OperationalError:
pass
if current_version < 22:
# v22: task-dimension usage attribution (issue #23270).
# session_model_usage gains a ``task`` column ('' = main agent
# loop; 'vision'/'compression'/'title_generation'/... =
# auxiliary calls) so aux model spend is visible in analytics.
# The column participates in the PRIMARY KEY and SQLite cannot
# ALTER a PK, so rebuild the table. The reconciler will have
# already ADDed the plain column on legacy DBs (harmless);
# the rebuild bakes it into the PK properly. Existing rows are
# main-loop accounting by definition → task=''.
try:
legacy_pk = cursor.execute(
"SELECT COUNT(*) FROM pragma_table_info('session_model_usage') "
"WHERE name = 'task' AND pk > 0"
).fetchone()[0]
if not legacy_pk:
cursor.execute("ALTER TABLE session_model_usage RENAME TO session_model_usage_v21")
cursor.execute(
"""CREATE TABLE session_model_usage (
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
model TEXT NOT NULL,
billing_provider TEXT NOT NULL DEFAULT '',
billing_base_url TEXT NOT NULL DEFAULT '',
billing_mode TEXT NOT NULL DEFAULT '',
task TEXT NOT NULL DEFAULT '',
api_call_count INTEGER NOT NULL DEFAULT 0,
input_tokens INTEGER NOT NULL DEFAULT 0,
output_tokens INTEGER NOT NULL DEFAULT 0,
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
cache_write_tokens INTEGER NOT NULL DEFAULT 0,
reasoning_tokens INTEGER NOT NULL DEFAULT 0,
estimated_cost_usd REAL NOT NULL DEFAULT 0,
actual_cost_usd REAL NOT NULL DEFAULT 0,
cost_status TEXT,
cost_source TEXT,
first_seen REAL,
last_seen REAL,
PRIMARY KEY (session_id, model, billing_provider, billing_base_url, billing_mode, task)
)"""
)
cursor.execute(
"""INSERT INTO session_model_usage (
session_id, model, billing_provider, billing_base_url,
billing_mode, task, api_call_count, input_tokens,
output_tokens, cache_read_tokens, cache_write_tokens,
reasoning_tokens, estimated_cost_usd, actual_cost_usd,
cost_status, cost_source, first_seen, last_seen
)
SELECT session_id, model, billing_provider, billing_base_url,
billing_mode, '', api_call_count, input_tokens,
output_tokens, cache_read_tokens, cache_write_tokens,
reasoning_tokens, estimated_cost_usd, actual_cost_usd,
cost_status, cost_source, first_seen, last_seen
FROM session_model_usage_v21"""
)
cursor.execute("DROP TABLE session_model_usage_v21")
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_session_model_usage_session "
"ON session_model_usage(session_id)"
)
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_session_model_usage_model "
"ON session_model_usage(model)"
)
except sqlite3.OperationalError as exc:
logger.debug("v22 session_model_usage rebuild skipped: %s", exc)
if current_version < 23:
# v23: FTS storage redesign (issues #22478, #43690, #55233).
# The v11 inline-mode FTS tables each store a full private
# copy of every message (content || tool_name || tool_calls),
# and the trigram index additionally covers role='tool' rows
# (~90% of message bytes: base64 payloads, file dumps) at
# ~2.6x amplification — together ~75% of state.db on heavy
# installs (observed: 18.9 GB of a 25 GB DB).
#
# OPT-IN, NOT AUTOMATIC. The transition (demote old vtables →
# new external-content schema → backfill → teardown → VACUUM)
# is disk-heavy (transient ~2x file size to fully reclaim via
# VACUUM) and long (~1-2h background on a 25 GB DB). Doing it
# silently on every big user's next open — with a completeness
# guarantee that depends on the process staying alive long
# enough — is the wrong default. So on an EXISTING install we
# touch nothing here: the v22 inline FTS keeps working exactly
# as before, and we only record a flag advertising that the
# optimization is available. `hermes sessions optimize-storage`
# performs the whole transition as one deliberate, disk-checked,
# progress-reported foreground operation.
#
# DECOUPLED VERSIONING. Crucially, this does NOT hold back the
# main schema_version. The FTS storage LAYOUT is tracked by an
# independent `fts_storage_version` marker (see
# _fts_storage_version / SETTLE below), so schema_version
# advances to SCHEMA_VERSION here like every other migration —
# future v24+ migrations land automatically for legacy-FTS
# users too. Only the FTS *layout* waits for opt-in.
if fts5_available and self._db_has_legacy_inline_fts(cursor):
self.set_meta("fts_optimize_available", "1", cursor=cursor)
# The FTS storage layout is versioned independently of the main
# schema (see the v23 note above). Stamp the current layout so the
# main version can always advance: a fresh/optimized DB is at
# FTS_STORAGE_VERSION; a legacy DB is left at whatever it had
# (absent/0) until `optimize-storage` runs. An INTERRUPTED
# optimize (legacy vtables already demoted, but rebuild markers
# or demoted trash tables still present) is NOT stamped either —
# the marker is the source of truth for "fully optimized", and
# `fts_optimize_available()` keeps offering the resume until the
# transition actually completes.
if (
fts5_available
and not self._db_has_legacy_inline_fts(cursor)
and cursor.execute(
"SELECT 1 FROM state_meta "
"WHERE key = 'fts_rebuild_high_water' LIMIT 1"
).fetchone() is None
and not self._has_fts_trash(cursor)
):
self.set_meta(
"fts_storage_version", str(FTS_STORAGE_VERSION), cursor=cursor
)
# Advance schema_version to current for ALL non-FTS-layout
# migrations. This is deliberately NOT gated on the FTS opt-in —
# holding the whole version back would block every future schema
# migration for a user who never optimizes. FTS5 being unavailable
# is the one case we skip (we can't have created the current FTS
# objects, so claiming the current schema would be a lie).
if (
current_version < SCHEMA_VERSION
and fts_migrations_complete
and fts5_available
):
cursor.execute(
"UPDATE schema_version SET version = ?",
(SCHEMA_VERSION,),
)
# Unique title index — always ensure it exists. Older databases may
# contain duplicate aliases from before the constraint was enforced;
# preserve every session while letting the newest one retain the alias.
title_index_sql = (
"CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_title_unique "
"ON sessions(title) WHERE title IS NOT NULL"
)
try:
cursor.execute(title_index_sql)
except sqlite3.IntegrityError:
# The index is an optimization — its creation must never abort
# opening the database, so the repair itself is also guarded.
try:
cursor.execute(
"""UPDATE sessions AS older
SET title = NULL
WHERE title IS NOT NULL
AND EXISTS (
SELECT 1 FROM sessions AS newer
WHERE newer.title = older.title
AND newer.rowid > older.rowid
)"""
)
logger.warning(
"Cleared %d duplicate session title(s) while restoring the unique index",
cursor.rowcount,
)
cursor.execute(title_index_sql)
except sqlite3.Error:
logger.exception(
"Could not repair duplicate session titles; "
"unique title index not created"
)
except sqlite3.OperationalError:
pass # Index already exists
if fts5_available:
# FTS5 setup. Run the DDL even when the virtual table exists so
# CREATE TRIGGER IF NOT EXISTS repairs trigger-only degradation from
# an earlier no-FTS5 runtime.
#
# OPT-IN v23 boundary: a legacy v22 install (inline-content FTS,
# not yet opted into `hermes db optimize`) must keep its EXISTING
# inline schema + triggers. Running the v23 external-content DDL
# here would create the trigram source VIEW and leave the DB in a
# mixed inline/external state. So for a legacy DB we only ensure
# its inline triggers exist (via the legacy DDL), and skip the
# v23 view/external tables entirely. Fresh installs and opted-in
# DBs have no legacy inline FTS, so they get the v23 DDL.
if self._db_has_legacy_inline_fts(cursor):
triggers_need_repair = (
self._fts_trigger_count(cursor) < len(_FTS_TRIGGERS)
)
self._fts_enabled = self._ensure_fts_schema(
cursor, "messages_fts", LEGACY_FTS_SQL
)
if self._fts_enabled:
trigram_enabled = self._ensure_fts_schema(
cursor, "messages_fts_trigram", LEGACY_FTS_TRIGRAM_SQL
)
self._trigram_available = trigram_enabled
if triggers_need_repair:
self._rebuild_legacy_fts_indexes(
cursor, include_trigram=trigram_enabled
)
else:
triggers_need_repair = (
self._fts_trigger_count(cursor) < len(_FTS_TRIGGERS)
)
self._fts_enabled = self._ensure_fts_schema(
cursor, "messages_fts", FTS_SQL
)
# Trigram FTS5 for CJK/substring search. This is optional
# relative to the main FTS table; if it cannot be created,
# CJK search falls back to LIKE.
if self._fts_enabled:
trigram_enabled = self._ensure_fts_schema(
cursor, "messages_fts_trigram", FTS_TRIGRAM_SQL
)
self._trigram_available = trigram_enabled
if triggers_need_repair:
self._rebuild_fts_indexes(
cursor,
include_trigram=trigram_enabled,
)
self._conn.commit()
# =========================================================================
# Session lifecycle
# =========================================================================
def _insert_session_row(
self,
session_id: str,
source: str,
model: str = None,
model_config: Dict[str, Any] = None,
system_prompt: str = None,
user_id: str = None,
session_key: str = None,
chat_id: str = None,
chat_type: str = None,
thread_id: str = None,
parent_session_id: str = None,
cwd: str = None,
profile_name: str = None,
) -> None:
"""Insert a session row, enriching NULL metadata on conflict.
The gateway's ``get_or_create_session`` creates a bare row (source +
user_id) *before* the agent exists; the agent's later
``create_session`` then carries the real ``model`` / ``model_config`` /
``system_prompt``. A plain ``INSERT OR IGNORE`` silently dropped that
enrichment, leaving gateway sessions with NULL model/billing metadata.
The ``ON CONFLICT`` upsert backfills those fields via ``COALESCE`` —
only filling columns that are still NULL, never overwriting values an
earlier writer already set (so a later bare call with source="unknown"
can't clobber a real source/model).
``chat_id``/``thread_id`` record the messaging origin (the chat/room and
thread the session was started in) so that gateway ``/resume`` can prove
a persisted, now-inactive row belongs to the caller's chat/thread before
switching to it (IDOR scoping — without them the ``sessions`` table has
no chat/thread to compare).
"""
def _do(conn):
conn.execute(
"""INSERT INTO sessions (
id, source, user_id, session_key, chat_id, chat_type, thread_id,
model, model_config, system_prompt, parent_session_id, cwd, profile_name, started_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
model = COALESCE(sessions.model, excluded.model),
model_config = COALESCE(sessions.model_config, excluded.model_config),
system_prompt = COALESCE(sessions.system_prompt, excluded.system_prompt),
session_key = COALESCE(sessions.session_key, excluded.session_key),
chat_id = COALESCE(sessions.chat_id, excluded.chat_id),
chat_type = COALESCE(sessions.chat_type, excluded.chat_type),
thread_id = COALESCE(sessions.thread_id, excluded.thread_id),
parent_session_id = COALESCE(sessions.parent_session_id, excluded.parent_session_id),
cwd = COALESCE(sessions.cwd, excluded.cwd),
profile_name = COALESCE(sessions.profile_name, excluded.profile_name)""",
(
session_id,
source,
user_id,
session_key,
chat_id,
chat_type,
thread_id,
model,
json.dumps(model_config) if model_config else None,
system_prompt,
parent_session_id,
cwd,
profile_name,
time.time(),
),
)
self._execute_write(_do)
def create_session(self, session_id: str, source: str, **kwargs) -> str:
"""Create a new session record. Returns the session_id."""
self._insert_session_row(session_id, source, **kwargs)
return session_id
def record_gateway_session_peer(
self,
session_id: str,
*,
source: str,
user_id: str = None,
session_key: str = None,
chat_id: str = None,
chat_type: str = None,
thread_id: str = None,
display_name: str = None,
origin_json: str = None,
) -> None:
"""Persist the gateway routing peer for an existing session row.
``display_name`` / ``origin_json`` carry the gateway's presentation
and full origin metadata (#9006) so consumers (mcp_serve, mirror,
channel directory) can read routing data from state.db instead of
sessions.json. They are COALESCE'd only in the sense that ``None``
leaves the existing value untouched.
"""
if not session_id or not session_key:
return
def _do(conn):
conn.execute(
"""UPDATE sessions
SET session_key = ?, source = ?, user_id = ?, chat_id = ?,
chat_type = ?, thread_id = ?,
display_name = COALESCE(?, display_name),
origin_json = COALESCE(?, origin_json)
WHERE id = ?""",
(
session_key,
source,
user_id,
chat_id,
chat_type,
thread_id,
display_name,
origin_json,
session_id,
),
)
self._execute_write(_do)
def set_expiry_finalized(self, session_id: str, finalized: bool = True) -> None:
"""Mark a gateway session's expiry-finalization flag in state.db.
Mirrors ``SessionEntry.expiry_finalized`` (sessions.json) so the flag
survives even if the JSON index is pruned or lost (#9006).
"""
if not session_id:
return
def _do(conn):
conn.execute(
"UPDATE sessions SET expiry_finalized = ? WHERE id = ?",
(1 if finalized else 0, session_id),
)
self._execute_write(_do)
# ── Gateway routing index (replaces sessions.json, #9006 follow-up) ────
def save_gateway_routing_entry(
self, session_key: str, entry_json: str, *, scope: str = ""
) -> None:
"""Upsert one gateway routing entry (session_key -> SessionEntry JSON).
The gateway_routing table is the durable replacement for
sessions.json: one row per routing key, holding the full serialized
``SessionEntry`` so the gateway can rehydrate exactly what it wrote.
``scope`` namespaces the index the way separate sessions.json files
did (one per sessions_dir) — callers pass their sessions_dir path so
two stores with different directories never share routing state.
"""
if not session_key or not entry_json:
return
def _do(conn):
conn.execute(
"""INSERT INTO gateway_routing (scope, session_key, entry_json, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(scope, session_key) DO UPDATE SET
entry_json = excluded.entry_json,
updated_at = excluded.updated_at""",
(scope, session_key, entry_json, time.time()),
)
self._execute_write(_do)
def replace_gateway_routing_entries(
self, entries: Dict[str, str], *, scope: str = ""
) -> None:
"""Atomically replace the routing index for *scope* with *entries*.
Mirrors the sessions.json full-rewrite semantics: keys absent from
*entries* are removed (pruned/reset sessions disappear from the
index). Runs as a single write transaction. Other scopes are
untouched.
"""
now = time.time()
def _do(conn):
conn.execute("DELETE FROM gateway_routing WHERE scope = ?", (scope,))
if entries:
conn.executemany(
"INSERT INTO gateway_routing (scope, session_key, entry_json, updated_at) "
"VALUES (?, ?, ?, ?)",
[(scope, k, v, now) for k, v in entries.items() if k and v],
)
self._execute_write(_do)
def load_gateway_routing_entries(self, *, scope: str = "") -> Dict[str, str]:
"""Load routing entries for *scope* as {session_key: entry_json}."""
with self._lock:
rows = self._conn.execute(
"SELECT session_key, entry_json FROM gateway_routing WHERE scope = ?",
(scope,),
).fetchall()
return {r["session_key"]: r["entry_json"] for r in rows}
def delete_gateway_routing_entries(
self, session_keys: List[str], *, scope: str = ""
) -> None:
"""Remove routing entries for the given session keys in *scope*."""
if not session_keys:
return
def _do(conn):
conn.executemany(
"DELETE FROM gateway_routing WHERE scope = ? AND session_key = ?",
[(scope, k) for k in session_keys],
)
self._execute_write(_do)
def list_gateway_sessions(
self,
*,
platform: Optional[str] = None,
active_only: bool = True,
) -> List[Dict[str, Any]]:
"""List gateway sessions (rows with a session_key) from state.db.
Returns the newest row per session_key — the same shape consumers got
from sessions.json: one live mapping per routing key. ``platform``
filters on ``source``; ``active_only`` restricts to sessions that
have not ended.
"""
query = """
SELECT sessions.*,
COALESCE(
(SELECT MAX(m.timestamp) FROM messages m
WHERE m.session_id = sessions.id),
sessions.started_at
) AS last_active
FROM sessions
WHERE session_key IS NOT NULL
AND started_at = (
SELECT MAX(s2.started_at) FROM sessions s2
WHERE s2.session_key = sessions.session_key
)
"""
params: list = []
if platform:
query += " AND LOWER(source) = LOWER(?)"
params.append(platform)
if active_only:
query += " AND ended_at IS NULL"
query += " ORDER BY last_active DESC"
with self._lock:
rows = self._conn.execute(query, params).fetchall()
return [dict(r) for r in rows]
def find_session_by_origin(
self,
*,
platform: str,
chat_id: str,
thread_id: Optional[str] = None,
user_id: Optional[str] = None,
) -> Optional[str]:
"""Find the most recent live session_id for a platform + chat origin.
Equivalent of gateway/mirror's sessions.json scan: matches on
source + chat_id (+ thread_id when provided). When ``user_id`` is
provided, exact sender matches are preferred; if multiple distinct
users share the chat and none matches, returns None rather than
contaminating another participant's session.
"""
if not platform or chat_id in (None, ""):
return None
query = """
SELECT id, user_id, started_at FROM sessions
WHERE LOWER(source) = LOWER(?)
AND session_key IS NOT NULL
AND chat_id = ?
AND ended_at IS NULL
"""
params: list = [platform, str(chat_id)]
if thread_id is not None:
query += " AND COALESCE(thread_id, '') = ?"
params.append(str(thread_id))
query += " ORDER BY started_at DESC"
with self._lock:
rows = [dict(r) for r in self._conn.execute(query, params).fetchall()]
if not rows:
return None
if user_id:
exact = [r for r in rows if str(r.get("user_id") or "") == str(user_id)]
if exact:
return str(exact[0]["id"])
if len(rows) > 1:
return None
elif len(rows) > 1:
distinct_users = {
str(r.get("user_id") or "").strip()
for r in rows
if str(r.get("user_id") or "").strip()
}
if len(distinct_users) > 1:
return None
return str(rows[0]["id"])
def _backfill_gateway_metadata_from_sessions_json(
self, cursor: sqlite3.Cursor
) -> None:
"""One-time v18 backfill of gateway metadata from sessions.json.
Existing gateway sessions predate the display_name / origin_json /
expiry_finalized columns; copy what sessions.json knows so consumers
can switch to state.db without losing pre-migration sessions.
Only fills NULL columns — never overwrites data written by newer code.
"""
sessions_file = get_hermes_home() / "sessions" / "sessions.json"
if not sessions_file.exists():
return
with open(sessions_file, "r", encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, dict):
return
for key, entry in data.items():
if str(key).startswith("_") or not isinstance(entry, dict):
continue
session_id = entry.get("session_id")
if not session_id:
continue
origin = entry.get("origin")
cursor.execute(
"""UPDATE sessions
SET session_key = COALESCE(session_key, ?),
chat_id = COALESCE(chat_id, ?),
chat_type = COALESCE(chat_type, ?),
thread_id = COALESCE(thread_id, ?),
display_name = COALESCE(display_name, ?),
origin_json = COALESCE(origin_json, ?),
expiry_finalized = CASE
WHEN COALESCE(expiry_finalized, 0) = 0 AND ? = 1 THEN 1
ELSE expiry_finalized
END
WHERE id = ?""",
(
entry.get("session_key") or key,
(origin or {}).get("chat_id") if isinstance(origin, dict) else None,
entry.get("chat_type"),
(origin or {}).get("thread_id") if isinstance(origin, dict) else None,
entry.get("display_name"),
json.dumps(origin) if isinstance(origin, dict) else None,
1 if entry.get("expiry_finalized") or entry.get("memory_flushed") else 0,
str(session_id),
),
)
def find_latest_gateway_session_for_peer(
self,
*,
source: str,
user_id: Optional[str] = None,
session_key: Optional[str] = None,
chat_id: Optional[str] = None,
chat_type: Optional[str] = None,
thread_id: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
"""Find the latest recoverable gateway session for a routing peer.
``sessions.json`` is the fast routing index, but it can be missing or
pruned after process-level restart bugs. New gateway sessions persist
the deterministic ``session_key`` on the durable session row so the
mapping can be rebuilt exactly. Rows ended only by older gateway
cleanup's ``agent_close`` bug or a mistaken TUI ``ws_orphan_reap``
(dashboard viewer disconnect before #60609) are treated as recoverable;
explicit conversation boundaries such as /new, /resume switches, and
compression splits are not.
"""
if not session_key:
return None
with self._lock:
row = self._conn.execute(
"""
SELECT * FROM sessions
WHERE session_key = ?
AND source = ?
AND (ended_at IS NULL OR end_reason IN ('agent_close', 'ws_orphan_reap'))
AND (COALESCE(message_count, 0) > 0 OR EXISTS (
SELECT 1 FROM messages WHERE messages.session_id = sessions.id LIMIT 1
))
ORDER BY started_at DESC
LIMIT 1
""",
(session_key, source),
).fetchone()
if row is not None:
return dict(row)
# Conservative fallback for rows created by current code but with a
# temporarily-missing exact key: still require the complete peer
# tuple so we never cross chats/threads/users.
if chat_id is None or chat_type is None:
return None
row = self._conn.execute(
"""
SELECT * FROM sessions
WHERE source = ?
AND COALESCE(user_id, '') = COALESCE(?, '')
AND COALESCE(chat_id, '') = COALESCE(?, '')
AND COALESCE(chat_type, '') = COALESCE(?, '')
AND COALESCE(thread_id, '') = COALESCE(?, '')
AND (ended_at IS NULL OR end_reason IN ('agent_close', 'ws_orphan_reap'))
AND (COALESCE(message_count, 0) > 0 OR EXISTS (
SELECT 1 FROM messages WHERE messages.session_id = sessions.id LIMIT 1
))
ORDER BY started_at DESC
LIMIT 1
""",
(source, user_id, chat_id, chat_type, thread_id),
).fetchone()
return dict(row) if row else None
def end_session(self, session_id: str, end_reason: str) -> None:
"""Mark a session as ended.
No-ops when the session is already ended. The first end_reason wins:
compression-split sessions must keep their ``end_reason = 'compression'``
record even if a later stale ``end_session()`` call (e.g. from a
desynced CLI session_id after ``/resume`` or ``/branch``) targets them
with a different reason. Use ``reopen_session()`` first if you
intentionally need to re-end a closed session with a new reason.
"""
def _do(conn):
conn.execute(
"UPDATE sessions SET ended_at = ?, end_reason = ? "
"WHERE id = ? AND ended_at IS NULL",
(time.time(), end_reason, session_id),
)
self._execute_write(_do)
def reopen_session(self, session_id: str) -> None:
"""Clear ended_at/end_reason so a session can be resumed."""
def _do(conn):
conn.execute(
"UPDATE sessions SET ended_at = NULL, end_reason = NULL WHERE id = ?",
(session_id,),
)
self._execute_write(_do)
def promote_to_session_reset(
self, session_id: str, reason: str = "session_reset"
) -> bool:
"""Durably mark a session as ended by an intentional reset boundary.
Promotes *only* live rows (``ended_at IS NULL``) or rows carrying an
accidental end_reason that the recovery query
(``find_latest_gateway_session_for_peer``) treats as recoverable:
``agent_close`` (older gateway cleanup bug) and ``ws_orphan_reap``
(mistaken TUI reaper). Explicit conversation boundaries such as
``compression``, ``session_reset``, ``session_switch``, etc. are
preserved — the first writer wins for those, and a later expiry
finalization must not silently overwrite them.
Plain ``end_session()`` is NOT sufficient for reset boundaries: it
no-ops on an already-ended row, so a row that agent cleanup already
closed as ``agent_close`` would stay recoverable and stale-route
recovery would resurrect the reset session with its full history
(#61220, #61993, #63539).
Keep this promotion set in sync with the recoverable set in
``find_latest_gateway_session_for_peer`` — any reason recovery would
reopen must be promotable here.
``reason`` lets reset paths keep their auditable specific reasons
(``idle``, ``daily``, ``suspended``, ``resume_pending_expired``).
Returns ``True`` when the row was promoted, ``False`` when skipped
(already has a different explicit end_reason, or row not found).
"""
if not session_id:
return False
now = time.time()
def _do(conn):
cursor = conn.execute(
"UPDATE sessions SET ended_at = ?, end_reason = ? "
"WHERE id = ? AND (ended_at IS NULL "
"OR end_reason IN ('agent_close', 'ws_orphan_reap'))",
(now, reason, session_id),
)
return cursor.rowcount
try:
rows = self._execute_write(_do)
return bool(rows)
except Exception:
return False
def update_session_cwd(
self, session_id: str, cwd: str, git_branch: str = None, git_repo_root: str = None
) -> None:
"""Persist the session working directory when a frontend knows it.
``git_branch`` records the git branch checked out in ``cwd`` at the time
the session started/resumed. The sidebar groups main-checkout sessions
by this so feature-branch work doesn't pile under a single "main" row
(the main checkout's *current* branch is transient and would
misattribute past sessions).
``git_repo_root`` records the git repo this cwd belongs to — the
authoritative project key. Resolving it here, at the lowest level, means
every surface reads the same membership instead of re-probing git in the
GUI over a partial page. Each field is only written when non-empty so a
probe failure never clobbers a previously-captured value.
"""
if not session_id or not cwd:
return
branch = (git_branch or "").strip()
repo_root = (git_repo_root or "").strip()
sets = ["cwd = ?"]
params: List[Any] = [cwd]
if branch:
sets.append("git_branch = ?")
params.append(branch)
if repo_root:
sets.append("git_repo_root = ?")
params.append(repo_root)
params.append(session_id)
def _do(conn):
conn.execute(f"UPDATE sessions SET {', '.join(sets)} WHERE id = ?", params)
self._execute_write(_do)
def backfill_repo_roots(self, cwd_to_root: Dict[str, str]) -> None:
"""Persist resolved git repo roots for cwds that don't have one yet.
Backfills history so projects light up for sessions created before the
column existed, without clobbering an already-recorded root. Only
non-empty roots are written (a non-git cwd stays NULL).
"""
pairs = [(root, cwd) for cwd, root in cwd_to_root.items() if root and cwd]
if not pairs:
return
def _do(conn):
for root, cwd in pairs:
conn.execute(
"UPDATE sessions SET git_repo_root = ? "
"WHERE cwd = ? AND COALESCE(git_repo_root, '') = ''",
(root, cwd),
)
self._execute_write(_do)
def record_compression_failure_cooldown(
self,
session_id: str,
cooldown_until: float,
error: Optional[str] = None,
) -> None:
"""Persist the active compression-failure cooldown for a session."""
if not session_id:
return
def _do(conn):
conn.execute(
"UPDATE sessions SET compression_failure_cooldown_until = ?, "
"compression_failure_error = ? WHERE id = ?",
(cooldown_until, error, session_id),
)
try:
self._execute_write(_do)
except sqlite3.Error as exc:
logger.warning(
"record_compression_failure_cooldown(%s) failed: %s",
session_id, exc,
)
def get_compression_failure_cooldown(
self,
session_id: str,
) -> Optional[Dict[str, Any]]:
"""Return the active compression-failure cooldown for ``session_id``."""
if not session_id:
return None
now = time.time()
with self._lock:
row = self._conn.execute(
"SELECT compression_failure_cooldown_until, compression_failure_error "
"FROM sessions WHERE id = ?",
(session_id,),
).fetchone()
if row is None:
return None
cooldown_until = (
row["compression_failure_cooldown_until"]
if isinstance(row, sqlite3.Row)
else row[0]
)
if cooldown_until is None:
return None
cooldown_until = float(cooldown_until)
if cooldown_until <= now:
return None
error = (
row["compression_failure_error"]
if isinstance(row, sqlite3.Row)
else row[1]
)
return {
"cooldown_until": cooldown_until,
"remaining_seconds": cooldown_until - now,
"error": error,
}
def clear_compression_failure_cooldown(self, session_id: str) -> None:
"""Clear any persisted compression-failure cooldown for a session."""
if not session_id:
return
def _do(conn):
conn.execute(
"UPDATE sessions SET compression_failure_cooldown_until = NULL, "
"compression_failure_error = NULL WHERE id = ?",
(session_id,),
)
try:
self._execute_write(_do)
except sqlite3.Error as exc:
logger.warning(
"clear_compression_failure_cooldown(%s) failed: %s",
session_id, exc,
)
def get_compression_fallback_streak(self, session_id: str) -> int:
"""Return the persisted deterministic-fallback streak."""
if not session_id:
return 0
with self._lock:
conn = self._conn
if conn is None:
return 0
row = conn.execute(
"SELECT compression_fallback_streak FROM sessions WHERE id = ?",
(session_id,),
).fetchone()
if row is None:
return 0
value = (
row["compression_fallback_streak"]
if isinstance(row, sqlite3.Row)
else row[0]
)
try:
return max(0, int(value or 0))
except (TypeError, ValueError):
return 0
def set_compression_fallback_streak(self, session_id: str, streak: int) -> None:
"""Persist the deterministic-fallback streak for one session."""
if not session_id:
return
normalized = max(0, int(streak))
def _do(conn):
conn.execute(
"UPDATE sessions SET compression_fallback_streak = ? WHERE id = ?",
(normalized, session_id),
)
self._execute_write(_do)
# ──────────────────────────────────────────────────────────────────────
# Compression locks
# ──────────────────────────────────────────────────────────────────────
# Atomic per-session locks that prevent two compression paths from
# racing on the same session_id and producing orphan child sessions.
#
# The race: ``conversation_compression.py`` rotates ``agent.session_id``
# as a side effect of a successful compression (end old session, create
# new). That mutation is local to the AIAgent instance — but ``state.db``
# is shared across all instances. Two AIAgents that share the same
# ``session_id`` at the moment they both decide to compress (most
# commonly the parent turn's agent + a background-review fork started
# right after the turn ended) each end the parent and create their own
# NEW session, parented to the same old id. The gateway SessionEntry
# only catches one rotation; the other child silently accumulates
# writes — Damien's "parent → two orphan children" repro shape.
#
# The lock is keyed by ``session_id`` and is held for the duration of
# the compress() call plus the rotation. ``holder`` identifies the
# current owner (pid:tid:nonce) for diagnostics; the lock is recovered
# via ``expires_at`` if the holder process crashed without releasing.
def refresh_compression_lock(
self,
session_id: str,
holder: str,
ttl_seconds: float = 300.0,
) -> bool:
"""Extend the compression lock lease if ``holder`` still owns it."""
if not session_id or not holder:
return False
now = time.time()
expires_at = now + ttl_seconds
def _do(conn):
cur = conn.execute(
"UPDATE compression_locks SET expires_at = ? "
"WHERE session_id = ? AND holder = ? AND expires_at >= ?",
(expires_at, session_id, holder, now),
)
return cur.rowcount > 0
try:
return bool(self._execute_write(_do))
except sqlite3.Error as exc:
logger.warning(
"refresh_compression_lock(%s) failed: %s",
session_id, exc,
)
return False
def try_acquire_compression_lock(
self,
session_id: str,
holder: str,
ttl_seconds: float = 300.0,
) -> bool:
"""Try to atomically acquire the compression lock for ``session_id``.
Returns ``True`` on success (caller now owns the lock and must
release via :meth:`release_compression_lock`). Returns ``False``
if another holder already owns a non-expired lock — the caller
MUST NOT proceed with compression in that case (its rotation would
race against the holder's, splitting the session lineage).
Expired locks (``expires_at < now``) are reclaimed transparently:
the stale row is deleted and the new holder acquires it. This
prevents a crashed compressor from permanently blocking the
session.
Implementation: single-transaction DELETE-expired + INSERT-or-IGNORE,
followed by a SELECT to confirm we got the row. SQLite serialises
writes, so the whole sequence is atomic against other writers.
"""
if not session_id:
return False
now = time.time()
expires_at = now + ttl_seconds
def _do(conn):
# First: reclaim any expired lock for this session_id.
conn.execute(
"DELETE FROM compression_locks "
"WHERE session_id = ? AND expires_at < ?",
(session_id, now),
)
# Then: try to insert. INSERT OR IGNORE returns no rowcount
# difference — verify ownership via SELECT.
conn.execute(
"INSERT OR IGNORE INTO compression_locks "
"(session_id, holder, acquired_at, expires_at) "
"VALUES (?, ?, ?, ?)",
(session_id, holder, now, expires_at),
)
row = conn.execute(
"SELECT holder FROM compression_locks WHERE session_id = ?",
(session_id,),
).fetchone()
return row is not None and (
row["holder"] if isinstance(row, sqlite3.Row) else row[0]
) == holder
try:
return bool(self._execute_write(_do))
except sqlite3.Error as exc:
logger.warning(
"try_acquire_compression_lock(%s) failed: %s",
session_id, exc,
)
# Fail open: returning False makes the caller skip compression,
# which is the safe behaviour when the lock subsystem is broken.
return False
def release_compression_lock(self, session_id: str, holder: str) -> None:
"""Release the compression lock for ``session_id`` iff we own it.
Idempotent: no-op when the lock has already expired and been
reclaimed by a different holder, or when no lock exists. The
``holder`` check prevents a late-returning compressor from
clobbering a fresh lock held by someone else.
"""
if not session_id:
return
def _do(conn):
conn.execute(
"DELETE FROM compression_locks "
"WHERE session_id = ? AND holder = ?",
(session_id, holder),
)
try:
self._execute_write(_do)
except sqlite3.Error as exc:
logger.warning(
"release_compression_lock(%s) failed: %s",
session_id, exc,
)
def get_compression_lock_holder(self, session_id: str) -> Optional[str]:
"""Return the current (non-expired) holder for ``session_id``, or None.
Diagnostic helper — not used by the locking protocol itself.
"""
if not session_id:
return None
now = time.time()
row = self._conn.execute(
"SELECT holder FROM compression_locks "
"WHERE session_id = ? AND expires_at >= ?",
(session_id, now),
).fetchone()
if row is None:
return None
return row["holder"] if isinstance(row, sqlite3.Row) else row[0]
def update_session_meta(
self,
session_id: str,
model_config_json: str,
model: Optional[str] = None,
) -> None:
"""Update model_config and optionally model for an existing session.
Uses COALESCE so that passing model=None leaves the stored model
column unchanged. Routes through _execute_write for the standard
BEGIN IMMEDIATE + jitter-retry + lock guarantee.
"""
def _do(conn):
conn.execute(
"UPDATE sessions SET model_config = ?, model = COALESCE(?, model) WHERE id = ?",
(model_config_json, model, session_id),
)
self._execute_write(_do)
def update_system_prompt(self, session_id: str, system_prompt: str) -> None:
"""Store the full assembled system prompt snapshot."""
def _do(conn):
conn.execute(
"UPDATE sessions SET system_prompt = ? WHERE id = ?",
(system_prompt, session_id),
)
self._execute_write(_do)
def update_session_model(self, session_id: str, model: str) -> None:
"""Update the model for a session after a mid-session switch.
Unlike ``update_token_counts`` which uses ``COALESCE(model, ?)``
(only filling in NULL), this unconditionally sets the model column
so that the dashboard reflects the user's latest /model choice.
"""
def _do(conn):
conn.execute(
"UPDATE sessions SET model = ? WHERE id = ?",
(model, session_id),
)
self._execute_write(_do)
def update_session_billing_route(
self,
session_id: str,
*,
provider: str,
base_url: str,
billing_mode: Optional[str] = None,
) -> None:
"""Unconditionally update the billing provider/base_url for a session.
Unlike ``update_token_counts`` which uses ``COALESCE(billing_provider, ?)``
(only filling in NULL), this unconditionally sets the billing fields so
that the dashboard reflects the user's latest /model switch.
Also nulls ``system_prompt`` so the cached snapshot (which embeds a
stale ``Model:`` / ``Provider:`` header) is rebuilt — matching the
behavior of ``update_session_model`` (see #48173, #48248).
"""
def _do(conn):
conn.execute(
"""UPDATE sessions SET
billing_provider = ?,
billing_base_url = ?,
billing_mode = COALESCE(?, billing_mode),
system_prompt = NULL
WHERE id = ?""",
(provider, base_url, billing_mode, session_id),
)
self._execute_write(_do)
def update_token_counts(
self,
session_id: str,
input_tokens: int = 0,
output_tokens: int = 0,
model: str = None,
cache_read_tokens: int = 0,
cache_write_tokens: int = 0,
reasoning_tokens: int = 0,
estimated_cost_usd: Optional[float] = None,
actual_cost_usd: Optional[float] = None,
cost_status: Optional[str] = None,
cost_source: Optional[str] = None,
pricing_version: Optional[str] = None,
billing_provider: Optional[str] = None,
billing_base_url: Optional[str] = None,
billing_mode: Optional[str] = None,
api_call_count: int = 0,
absolute: bool = False,
) -> None:
"""Update token counters and backfill model if not already set.
When *absolute* is False (default), values are **incremented** — use
this for per-API-call deltas (CLI path).
When *absolute* is True, values are **set directly** — use this when
the caller already holds cumulative totals (gateway path, where the
cached agent accumulates across messages).
"""
# Ensure the session row exists so the UPDATE doesn't silently affect
# 0 rows. Under concurrent load (cron + kanban + delegate_task) the
# initial create_session() may have failed due to SQLite locking.
# INSERT OR IGNORE is cheap and idempotent.
self._insert_session_row(session_id, "unknown", model=model)
if absolute:
sql = """UPDATE sessions SET
input_tokens = ?,
output_tokens = ?,
cache_read_tokens = ?,
cache_write_tokens = ?,
reasoning_tokens = ?,
estimated_cost_usd = COALESCE(?, 0),
actual_cost_usd = CASE
WHEN ? IS NULL THEN actual_cost_usd
ELSE ?
END,
cost_status = COALESCE(?, cost_status),
cost_source = COALESCE(?, cost_source),
pricing_version = COALESCE(?, pricing_version),
billing_provider = COALESCE(billing_provider, ?),
billing_base_url = COALESCE(billing_base_url, ?),
billing_mode = COALESCE(billing_mode, ?),
model = COALESCE(model, ?),
api_call_count = ?
WHERE id = ?"""
else:
sql = """UPDATE sessions SET
input_tokens = input_tokens + ?,
output_tokens = output_tokens + ?,
cache_read_tokens = cache_read_tokens + ?,
cache_write_tokens = cache_write_tokens + ?,
reasoning_tokens = reasoning_tokens + ?,
estimated_cost_usd = COALESCE(estimated_cost_usd, 0) + COALESCE(?, 0),
actual_cost_usd = CASE
WHEN ? IS NULL THEN actual_cost_usd
ELSE COALESCE(actual_cost_usd, 0) + ?
END,
cost_status = COALESCE(?, cost_status),
cost_source = COALESCE(?, cost_source),
pricing_version = COALESCE(?, pricing_version),
billing_provider = COALESCE(billing_provider, ?),
billing_base_url = COALESCE(billing_base_url, ?),
billing_mode = COALESCE(billing_mode, ?),
model = COALESCE(model, ?),
api_call_count = COALESCE(api_call_count, 0) + ?
WHERE id = ?"""
has_accounted_usage = bool(
input_tokens or output_tokens or cache_read_tokens
or cache_write_tokens or reasoning_tokens or api_call_count
or estimated_cost_usd or actual_cost_usd
)
params = (
input_tokens,
output_tokens,
cache_read_tokens,
cache_write_tokens,
reasoning_tokens,
estimated_cost_usd,
actual_cost_usd,
actual_cost_usd,
cost_status,
cost_source,
pricing_version,
billing_provider if has_accounted_usage else None,
billing_base_url if has_accounted_usage else None,
billing_mode if has_accounted_usage else None,
model if has_accounted_usage else None,
api_call_count,
session_id,
)
# Per-model usage attribution. ``update_token_counts`` is the single
# chokepoint every per-API-call delta flows through (CLI, gateway, cron,
# delegated runs — see conversation_loop / codex_runtime), and each call
# carries the model/provider *active at the time of that call*. The
# ``sessions`` row only keeps one (model, billing_provider) pair, so a
# mid-session ``/model`` switch otherwise attributes every token to the
# initial model (issue #51607). Recording the per-call delta into
# session_model_usage keyed by the live model preserves an accurate
# per-model breakdown regardless of how many times the user switches.
#
# Only the incremental path records here. Absolute cumulative updates
# cannot be split back into routes; Insights reconciles any positive
# residual against the aggregate session row instead.
record_model_usage = (not absolute) and (
input_tokens or output_tokens or cache_read_tokens
or cache_write_tokens or reasoning_tokens or api_call_count
or estimated_cost_usd
)
def _do(conn):
row = conn.execute(
"SELECT model, billing_provider, api_call_count FROM sessions WHERE id = ?",
(session_id,),
).fetchone()
existing_model = row["model"] if row is not None else None
existing_provider = row["billing_provider"] if row is not None else None
existing_api_calls = int((row["api_call_count"] if row is not None else 0) or 0)
# Session creation records the requested primary route before any API
# call. If it fails and fallback succeeds, the first accounted usage
# event is the first authoritative route. After that, preserve the
# legacy row: one row cannot represent mixed-provider usage.
first_accounted_route = (
existing_api_calls == 0
and has_accounted_usage
and bool(model)
and bool(billing_provider)
and (existing_model != model or existing_provider != billing_provider)
)
if first_accounted_route:
conn.execute(
"""UPDATE sessions
SET model = ?, billing_provider = ?,
billing_base_url = ?, billing_mode = ?
WHERE id = ?""",
(model, billing_provider, billing_base_url, billing_mode, session_id),
)
conn.execute(sql, params)
if record_model_usage:
self._record_model_usage(
conn,
session_id,
model=model,
billing_provider=billing_provider,
billing_base_url=billing_base_url,
billing_mode=billing_mode,
input_tokens=input_tokens,
output_tokens=output_tokens,
cache_read_tokens=cache_read_tokens,
cache_write_tokens=cache_write_tokens,
reasoning_tokens=reasoning_tokens,
estimated_cost_usd=estimated_cost_usd,
actual_cost_usd=actual_cost_usd,
cost_status=cost_status,
cost_source=cost_source,
api_call_count=api_call_count,
)
self._execute_write(_do)
def _record_model_usage(
self,
conn,
session_id: str,
*,
model: Optional[str],
billing_provider: Optional[str],
billing_base_url: Optional[str],
billing_mode: Optional[str],
input_tokens: int,
output_tokens: int,
cache_read_tokens: int,
cache_write_tokens: int,
reasoning_tokens: int,
estimated_cost_usd: Optional[float],
actual_cost_usd: Optional[float],
cost_status: Optional[str],
cost_source: Optional[str],
api_call_count: int,
task: str = "",
) -> None:
"""Accumulate a per-API-call usage delta into session_model_usage.
Runs inside the caller's write transaction (after the ``sessions``
UPDATE) so the per-model rows stay consistent with the summary row.
When the caller omits the model/provider (some paths only pass token
deltas), fall back to the values already recorded on the session row —
the same COALESCE-from-session behaviour the summary update uses.
``task`` distinguishes what kind of work consumed the tokens:
``''`` (empty) is the main agent loop; auxiliary calls record their
task name (``vision``, ``compression``, ``title_generation``, ...)
via :meth:`record_auxiliary_usage` (issue #23270).
"""
row = conn.execute(
"SELECT model, billing_provider, billing_base_url, billing_mode "
"FROM sessions WHERE id = ?",
(session_id,),
).fetchone()
sess_model = row["model"] if row is not None else None
sess_provider = row["billing_provider"] if row is not None else None
sess_base_url = row["billing_base_url"] if row is not None else None
sess_billing_mode = row["billing_mode"] if row is not None else None
# Aux-task rows (task != '') must NOT inherit the session's main-loop
# route: an aux call may use a completely different provider/model
# (vision on gemini while the main loop runs anthropic). Missing info
# stays 'unknown'/empty rather than borrowing a misleading route.
if task:
eff_model = model or "unknown"
eff_provider = billing_provider or ""
eff_base_url = billing_base_url or ""
eff_billing_mode = billing_mode or ""
else:
eff_model = model or sess_model or "unknown"
eff_provider = billing_provider or sess_provider or ""
eff_base_url = billing_base_url or sess_base_url or ""
eff_billing_mode = billing_mode or sess_billing_mode or ""
now = time.time()
conn.execute(
"""INSERT INTO session_model_usage (
session_id, model, billing_provider, billing_base_url, billing_mode,
task, api_call_count, input_tokens, output_tokens,
cache_read_tokens, cache_write_tokens, reasoning_tokens,
estimated_cost_usd, actual_cost_usd, cost_status, cost_source,
first_seen, last_seen
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(session_id, model, billing_provider, billing_base_url, billing_mode, task)
DO UPDATE SET
api_call_count = api_call_count + excluded.api_call_count,
input_tokens = input_tokens + excluded.input_tokens,
output_tokens = output_tokens + excluded.output_tokens,
cache_read_tokens = cache_read_tokens + excluded.cache_read_tokens,
cache_write_tokens = cache_write_tokens + excluded.cache_write_tokens,
reasoning_tokens = reasoning_tokens + excluded.reasoning_tokens,
estimated_cost_usd = estimated_cost_usd + excluded.estimated_cost_usd,
actual_cost_usd = actual_cost_usd + excluded.actual_cost_usd,
cost_status = COALESCE(excluded.cost_status, cost_status),
cost_source = COALESCE(excluded.cost_source, cost_source),
last_seen = excluded.last_seen""",
(
session_id,
eff_model,
eff_provider,
eff_base_url,
eff_billing_mode,
task or "",
api_call_count or 0,
input_tokens or 0,
output_tokens or 0,
cache_read_tokens or 0,
cache_write_tokens or 0,
reasoning_tokens or 0,
float(estimated_cost_usd or 0.0),
float(actual_cost_usd or 0.0),
cost_status,
cost_source,
now,
now,
),
)
def ensure_session(
self,
session_id: str,
source: str = "unknown",
model: str = None,
**kwargs,
) -> str:
"""Ensure a session row exists (INSERT OR IGNORE). Accepts optional kwargs."""
self._insert_session_row(session_id, source, model=model, **kwargs)
return session_id
def record_auxiliary_usage(
self,
session_id: str,
task: str,
*,
model: Optional[str] = None,
billing_provider: Optional[str] = None,
billing_base_url: Optional[str] = None,
input_tokens: int = 0,
output_tokens: int = 0,
cache_read_tokens: int = 0,
cache_write_tokens: int = 0,
reasoning_tokens: int = 0,
estimated_cost_usd: Optional[float] = None,
) -> None:
"""Record an auxiliary LLM call's usage against *session_id* (issue #23270).
Auxiliary calls (vision, compression, title_generation, web_extract,
session_search, ...) historically discarded their usage, leaving the
dashboard's per-model analytics blind to aux model spend. This writes
a per-(model, provider, task) delta into ``session_model_usage`` —
the same table the main loop's ``update_token_counts`` feeds — WITHOUT
touching the ``sessions`` summary row. That separation is deliberate:
the gateway overwrites session counters with absolute main-loop totals,
so folding aux tokens into the summary row would either be clobbered
or double-counted. Insights/analytics read the union of both.
Best-effort by contract: callers must never fail an aux call because
accounting failed.
"""
if not session_id or not task:
return
# FK on session_model_usage.session_id → sessions.id: ensure the row
# exists (same INSERT OR IGNORE guard update_token_counts uses — the
# initial create_session() can fail under concurrent SQLite locking).
self._insert_session_row(session_id, "unknown")
def _do(conn):
self._record_model_usage(
conn,
session_id,
model=model,
billing_provider=billing_provider,
billing_base_url=billing_base_url,
billing_mode=None,
input_tokens=input_tokens or 0,
output_tokens=output_tokens or 0,
cache_read_tokens=cache_read_tokens or 0,
cache_write_tokens=cache_write_tokens or 0,
reasoning_tokens=reasoning_tokens or 0,
estimated_cost_usd=estimated_cost_usd,
actual_cost_usd=None,
cost_status=None,
cost_source=None,
api_call_count=1,
task=task,
)
self._execute_write(_do)
def prune_empty_ghost_sessions(self, sessions_dir: "Optional[Path]" = None) -> int:
"""Remove empty TUI ghost sessions (no messages, no title, >24hr old)."""
cutoff = time.time() - 86400 # Only sessions older than 24 hours
def _do(conn):
rows = conn.execute("""
SELECT id FROM sessions
WHERE source = 'tui'
AND title IS NULL
AND ended_at IS NOT NULL
AND started_at < ?
AND NOT EXISTS (
SELECT 1 FROM messages WHERE messages.session_id = sessions.id
)
""", (cutoff,)).fetchall()
ids = [r[0] if isinstance(r, (tuple, list)) else r["id"] for r in rows]
if ids:
placeholders = ",".join("?" * len(ids))
conn.execute(
f"DELETE FROM sessions WHERE id IN ({placeholders})", ids
)
return ids
removed_ids = self._execute_write(_do) or []
# Clean up any on-disk session files (belt-and-suspenders)
if sessions_dir and removed_ids:
for sid in removed_ids:
self._remove_session_files(sessions_dir, sid)
return len(removed_ids)
def finalize_orphaned_compression_sessions(self) -> int:
"""Mark orphaned compression continuation sessions as ended.
Targets child sessions that were never finalized: parent is ended
with reason='compression', child has messages but no end_reason/ended_at
and api_call_count=0. Non-destructive: preserves all messages and sets
end_reason='orphaned_compression'. Fix for #20001.
"""
cutoff = time.time() - 604800 # 7 days
def _do(conn):
now = time.time()
result = conn.execute(
"""
UPDATE sessions
SET ended_at = ?,
end_reason = 'orphaned_compression'
WHERE api_call_count = 0
AND end_reason IS NULL
AND ended_at IS NULL
AND started_at < ?
AND parent_session_id IS NOT NULL
AND EXISTS (
SELECT 1 FROM sessions p
WHERE p.id = sessions.parent_session_id
AND p.end_reason = 'compression'
AND p.ended_at IS NOT NULL
)
AND EXISTS (
SELECT 1 FROM messages m
WHERE m.session_id = sessions.id
)
""",
(now, cutoff),
)
return result.rowcount
return self._execute_write(_do) or 0
def get_session(self, session_id: str) -> Optional[Dict[str, Any]]:
"""Get a session by ID."""
with self._lock:
cursor = self._conn.execute(
"SELECT * FROM sessions WHERE id = ?", (session_id,)
)
row = cursor.fetchone()
return dict(row) if row else None
def resolve_session_id(self, session_id_or_prefix: str) -> Optional[str]:
"""Resolve an exact or uniquely prefixed session ID to the full ID.
Returns the exact ID when it exists. Otherwise treats the input as a
prefix and returns the single matching session ID if the prefix is
unambiguous. Returns None for no matches or ambiguous prefixes.
"""
exact = self.get_session(session_id_or_prefix)
if exact:
return exact["id"]
escaped = (
session_id_or_prefix
.replace("\\", "\\\\")
.replace("%", "\\%")
.replace("_", "\\_")
)
with self._lock:
cursor = self._conn.execute(
"SELECT id FROM sessions WHERE id LIKE ? ESCAPE '\\' ORDER BY started_at DESC LIMIT 2",
(f"{escaped}%",),
)
matches = [row["id"] for row in cursor.fetchall()]
if len(matches) == 1:
return matches[0]
return None
# Maximum length for session titles
MAX_TITLE_LENGTH = 100
@staticmethod
def sanitize_title(title: Optional[str]) -> Optional[str]:
"""Validate and sanitize a session title.
- Strips leading/trailing whitespace
- Removes ASCII control characters (0x00-0x1F, 0x7F) and problematic
Unicode control chars (zero-width, RTL/LTR overrides, etc.)
- Collapses internal whitespace runs to single spaces
- Normalizes empty/whitespace-only strings to None
- Enforces MAX_TITLE_LENGTH
Returns the cleaned title string or None.
Raises ValueError if the title exceeds MAX_TITLE_LENGTH after cleaning.
"""
if not title:
return None
# Lone surrogates cannot be bound by sqlite3 (UnicodeEncodeError at
# UTF-8 encode time) — scrub them like every other write path here.
title = _sanitize_surrogates(title)
# Remove ASCII control characters (0x00-0x1F, 0x7F) but keep
# whitespace chars (\t=0x09, \n=0x0A, \r=0x0D) so they can be
# normalized to spaces by the whitespace collapsing step below
cleaned = re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]', '', title)
# Remove problematic Unicode control characters:
# - Zero-width chars (U+200B-U+200F, U+FEFF)
# - Directional overrides (U+202A-U+202E, U+2066-U+2069)
# - Object replacement (U+FFFC), interlinear annotation (U+FFF9-U+FFFB)
cleaned = re.sub(
r'[\u200b-\u200f\u2028-\u202e\u2060-\u2069\ufeff\ufffc\ufff9-\ufffb]',
'', cleaned,
)
# Collapse internal whitespace runs and strip
cleaned = re.sub(r'\s+', ' ', cleaned).strip()
if not cleaned:
return None
if len(cleaned) > SessionDB.MAX_TITLE_LENGTH:
raise ValueError(
f"Title too long ({len(cleaned)} chars, max {SessionDB.MAX_TITLE_LENGTH})"
)
return cleaned
def _is_compression_ancestor(
self, conn, *, ancestor_id: str, descendant_id: str
) -> bool:
"""Return True if *ancestor_id* is a compression predecessor of
*descendant_id* (walking parent links up the continuation chain).
The continuation edge is the canonical one shared with
:func:`_ephemeral_child_sql` / :meth:`set_session_archived`
(``_COMPRESSION_CHILD_SQL``): a parent → child edge counts only when the
parent ended with ``end_reason = 'compression'`` and the child started
at or after the parent's ``ended_at``, which distinguishes continuations
from delegate subagents / branch children that also carry a
``parent_session_id``. Expressed as a single recursive CTE rather than a
per-hop Python walk so the edge definition lives in exactly one place.
"""
if not ancestor_id or not descendant_id or ancestor_id == descendant_id:
return False
# Walk parent links up from the descendant, following only compression
# continuation edges, and check whether ancestor_id is reached.
edge = _COMPRESSION_CHILD_SQL.format(a="child")
row = conn.execute(
f"""
WITH RECURSIVE ancestors(id) AS (
SELECT ?
UNION
SELECT parent.id
FROM ancestors a
JOIN sessions child ON child.id = a.id
JOIN sessions parent ON parent.id = child.parent_session_id
WHERE {edge}
)
SELECT 1 FROM ancestors WHERE id = ? AND id != ? LIMIT 1
""",
(descendant_id, ancestor_id, descendant_id),
).fetchone()
return row is not None
def _set_session_title(
self,
session_id: str,
title: str,
*,
only_if_empty: bool,
) -> bool:
title = self.sanitize_title(title)
def _do(conn):
if only_if_empty:
current = conn.execute(
"SELECT title FROM sessions WHERE id = ?",
(session_id,),
).fetchone()
if current is None or current["title"] is not None:
return 0
if title:
# Check uniqueness (allow the same session to keep its own title)
cursor = conn.execute(
"SELECT id FROM sessions WHERE title = ? AND id != ?",
(title, session_id),
)
conflict = cursor.fetchone()
if conflict:
conflict_id = conflict["id"]
# A compression continuation is the live, projected-forward
# head of its conversation; its compressed predecessors are
# ended and hidden from the session list (list_sessions_rich
# projects roots → tip). When the title that "conflicts" is
# held by such a hidden ancestor, the user has no way to free
# it — renaming the visible tip back to the base name would
# dead-end with "already in use by <session they can't see>".
# Treat this as a transfer: move the title off the ancestor
# onto the continuation. Uniqueness is preserved (still only
# one session carries the exact title) and the parent-link
# lineage is untouched.
if self._is_compression_ancestor(
conn, ancestor_id=conflict_id, descendant_id=session_id
):
conn.execute(
"UPDATE sessions SET title = NULL WHERE id = ?",
(conflict_id,),
)
else:
raise ValueError(
f"Title '{title}' is already in use by session {conflict_id}"
)
predicate = " AND title IS NULL" if only_if_empty else ""
cursor = conn.execute(
f"UPDATE sessions SET title = ? WHERE id = ?{predicate}",
(title, session_id),
)
return cursor.rowcount
rowcount = self._execute_write(_do)
return rowcount > 0
def set_session_title(self, session_id: str, title: str) -> bool:
"""Set or update a session's title.
Returns True if session was found and title was set.
Raises ValueError if title is already in use by another session,
or if the title fails validation (too long, invalid characters).
Empty/whitespace-only strings are normalized to None (clearing the title).
"""
return self._set_session_title(session_id, title, only_if_empty=False)
def set_auto_title_if_empty(self, session_id: str, title: str) -> bool:
"""Set an auto-generated title only when the current title is NULL.
The predicate and write run in one transaction so a concurrent manual
rename cannot be overwritten. Validation and uniqueness behavior match
:meth:`set_session_title`.
"""
return self._set_session_title(session_id, title, only_if_empty=True)
def get_session_title(self, session_id: str) -> Optional[str]:
"""Get the title for a session, or None."""
with self._lock:
cursor = self._conn.execute(
"SELECT title FROM sessions WHERE id = ?", (session_id,)
)
row = cursor.fetchone()
return row["title"] if row else None
def set_session_archived(self, session_id: str, archived: bool) -> bool:
"""Archive or unarchive a session.
Archived sessions are hidden from the default session list but keep all
their messages — this is a soft hide, not a delete. For compression
chains, archive the whole logical conversation. Desktop lists compression
roots projected forward to their latest continuation; updating only the
displayed tip lets the still-unarchived root resurrect it on refresh.
Returns True when at least one row was updated.
"""
def _do(conn):
cursor = conn.execute(
"""
WITH RECURSIVE
ancestors(id) AS (
SELECT ?
UNION
SELECT parent.id
FROM ancestors a
JOIN sessions child ON child.id = a.id
JOIN sessions parent ON parent.id = child.parent_session_id
WHERE parent.end_reason = 'compression'
),
descendants(id) AS (
SELECT ?
UNION
SELECT child.id
FROM descendants d
JOIN sessions parent ON parent.id = d.id
JOIN sessions child ON child.parent_session_id = parent.id
WHERE parent.end_reason = 'compression'
),
lineage(id) AS (
SELECT id FROM ancestors
UNION
SELECT id FROM descendants
)
UPDATE sessions
SET archived = ?
WHERE id IN (SELECT id FROM lineage)
""",
(session_id, session_id, 1 if archived else 0),
)
rowcount = cursor.rowcount
if rowcount is None or rowcount < 0:
rowcount = conn.execute("SELECT changes()").fetchone()[0]
return rowcount
rowcount = self._execute_write(_do)
return rowcount > 0
def get_session_by_title(self, title: str) -> Optional[Dict[str, Any]]:
"""Look up a session by exact title. Returns session dict or None."""
with self._lock:
cursor = self._conn.execute(
"SELECT * FROM sessions WHERE title = ?", (title,)
)
row = cursor.fetchone()
return dict(row) if row else None
def resolve_session_by_title(self, title: str) -> Optional[str]:
"""Resolve a title to a session ID, preferring the latest in a lineage.
If the exact title exists, returns that session's ID.
If not, searches for "title #N" variants and returns the latest one.
If the exact title exists AND numbered variants exist, returns the
latest numbered variant (the most recent continuation).
"""
# First try exact match
exact = self.get_session_by_title(title)
# Also search for numbered variants: "title #2", "title #3", etc.
# Escape SQL LIKE wildcards (%, _) in the title to prevent false matches
escaped = title.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
with self._lock:
cursor = self._conn.execute(
"SELECT id, title, started_at FROM sessions "
"WHERE title LIKE ? ESCAPE '\\' ORDER BY started_at DESC",
(f"{escaped} #%",),
)
numbered = cursor.fetchall()
if numbered:
# Return the most recent numbered variant
return numbered[0]["id"]
elif exact:
return exact["id"]
return None
def get_next_title_in_lineage(self, base_title: str) -> str:
"""Generate the next title in a lineage (e.g., "my session""my session #2").
Strips any existing " #N" suffix to find the base name, then finds
the highest existing number and increments.
"""
# Strip existing #N suffix to find the true base
match = re.match(r'^(.*?) #(\d+)$', base_title)
if match:
base = match.group(1)
else:
base = base_title
# Find all existing numbered variants
# Escape SQL LIKE wildcards (%, _) in the base to prevent false matches
escaped = base.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
with self._lock:
cursor = self._conn.execute(
"SELECT title FROM sessions WHERE title = ? OR title LIKE ? ESCAPE '\\'",
(base, f"{escaped} #%"),
)
existing = [row["title"] for row in cursor.fetchall()]
if not existing:
return base # No conflict, use the base name as-is
# Find the highest number
max_num = 1 # The unnumbered original counts as #1
for t in existing:
m = re.match(r'^.* #(\d+)$', t)
if m:
max_num = max(max_num, int(m.group(1)))
return f"{base} #{max_num + 1}"
def get_compression_tip(self, session_id: str) -> Optional[str]:
"""Walk the compression-continuation chain forward and return the tip.
A compression continuation is a child of a session whose
``end_reason = 'compression'``. Older builds tried to distinguish
continuations from branches/subagents by requiring
``child.started_at >= parent.ended_at``. That ordering is too brittle:
gateway + compression races can insert the real continuation row before
the parent row's ``ended_at`` is written, while a stale websocket later
creates/reuses a sibling that *does* satisfy the timestamp test. The
visible symptom is brutal: desktop resume follows the stale sibling and
the user's latest messages look "lost" even though they are persisted in
the real continuation chain.
Instead, only follow children of compression-ended parents, exclude
explicit branch/delegate/tool children, and prefer children that are
themselves continuing the compression chain (``end_reason='compression'``)
or still live over stale closed siblings such as ``ws_orphan_reap``.
Returns the latest continuation tip, or the input id when no
continuation exists.
"""
current = session_id
seen = {current} if current else set()
# Bound the walk defensively — compression chains this deep are
# pathological and shouldn't happen in practice. 100 = plenty.
for _ in range(100):
with self._lock:
cursor = self._conn.execute(
"""
SELECT child.id
FROM sessions parent
JOIN sessions child ON child.parent_session_id = parent.id
WHERE parent.id = ?
AND parent.end_reason = 'compression'
AND json_extract(COALESCE(child.model_config, '{}'), '$._branched_from') IS NULL
AND json_extract(COALESCE(child.model_config, '{}'), '$._delegate_from') IS NULL
AND COALESCE(child.source, '') != 'tool'
ORDER BY
CASE
WHEN child.end_reason = 'compression' THEN 0
WHEN child.ended_at IS NULL THEN 1
ELSE 2
END,
COALESCE(
(SELECT MAX(m.timestamp) FROM messages m WHERE m.session_id = child.id),
child.started_at
) DESC,
child.started_at DESC,
child.id DESC
LIMIT 1
""",
(current,),
)
row = cursor.fetchone()
if row is None:
return current
child_id = row["id"]
if not child_id or child_id in seen:
return current
seen.add(child_id)
current = child_id
return current
# Columns excluded from compact_rows projections: only the payload-heavy
# blob no list consumer renders. Everything else — including gateway
# routing fields and desktop sidebar fields like git_branch — stays, and
# the projection is derived from SCHEMA_SQL so columns added later via
# declarative reconciliation are included automatically instead of
# silently dropping out of list rows.
_SESSION_COMPACT_EXCLUDED = frozenset({"system_prompt"})
_session_compact_cols_sql: Optional[str] = None
@classmethod
def _compact_session_cols(cls) -> str:
"""SELECT list for compact_rows: every ``sessions`` column declared in
SCHEMA_SQL except the ``system_prompt`` blob, aliased with the ``s``
prefix used by list_sessions_rich/_get_session_rich_row queries."""
if cls._session_compact_cols_sql is None:
declared = cls._parse_schema_columns(SCHEMA_SQL)["sessions"]
cls._session_compact_cols_sql = ", ".join(
f"s.{name}" for name in declared
if name not in cls._SESSION_COMPACT_EXCLUDED
)
return cls._session_compact_cols_sql
def distinct_session_cwds(self, include_archived: bool = False) -> List[Dict[str, Any]]:
"""Distinct non-empty session cwds with usage stats, for repo discovery.
Aggregates across ALL session history (not a single page), so the desktop
can surface every git repo the user has worked in — not just the repos
that happen to be in the currently-loaded recents. Children/branches
count: a worktree session is still a real workspace signal.
"""
where = "cwd IS NOT NULL AND TRIM(cwd) != ''"
if not include_archived:
where += " AND archived = 0"
with self._lock:
rows = self._conn.execute(
"SELECT cwd AS cwd, COUNT(*) AS sessions, "
"MAX(COALESCE(ended_at, started_at, 0)) AS last_active "
f"FROM sessions WHERE {where} GROUP BY cwd"
).fetchall()
return [
{
"cwd": r["cwd"],
"sessions": int(r["sessions"] or 0),
"last_active": float(r["last_active"] or 0),
}
for r in rows
]
def list_sessions_rich(
self,
source: str = None,
exclude_sources: List[str] = None,
cwd_prefix: str = None,
limit: int = 20,
offset: int = 0,
include_children: bool = False,
min_message_count: int = 0,
project_compression_tips: bool = True,
order_by_last_active: bool = False,
include_archived: bool = False,
archived_only: bool = False,
id_query: str = None,
search_query: str = None,
compact_rows: bool = False,
) -> List[Dict[str, Any]]:
"""List sessions with preview (first user message) and last active timestamp.
Returns dicts with keys: id, source, model, title, started_at, ended_at,
message_count, preview (first 60 chars of first user message),
last_active (timestamp of last message).
Uses a single query with correlated subqueries instead of N+2 queries.
By default, child sessions (subagent runs, compression continuations)
are excluded. Pass ``include_children=True`` to include them.
With ``project_compression_tips=True`` (default), sessions that are
roots of compression chains are projected forward to their latest
continuation — one logical conversation = one list entry, showing the
live continuation's id/message_count/title/last_active. This prevents
compressed continuations from being invisible to users while keeping
delegate subagents and branches hidden. Pass ``False`` to return the
raw root rows (useful for admin/debug UIs).
Pass ``order_by_last_active=True`` to sort by most-recent activity
instead of original conversation start time. For compression chains,
the "most-recent activity" is taken from the live tip (not the root),
so an old conversation that was compressed and continued recently
surfaces in the correct slot. Ordering is computed at SQL level via
a recursive CTE that walks compression-continuation edges, so LIMIT
and OFFSET still apply efficiently.
``search_query`` matches case-insensitive substrings against each
surfaced row's title and id (and, like ``id_query``, every title/id in
its forward compression chain). A punctuation-stripped variant is also
matched so e.g. ``an94`` finds ``AN-94``. Only honored in the
``order_by_last_active`` path.
Pass ``compact_rows=True`` for dashboard and picker callers that only
need lightweight metadata. This omits the ``system_prompt`` blob from
the SELECT so SQLite never copies it out of the B-tree page — a
significant I/O saving on large databases where the blob routinely
runs to tens of kilobytes per row.
"""
where_clauses = []
params = []
if not include_children:
# Show root sessions and branch sessions, while still hiding
# sub-agent runs and compression continuations (which also carry a
# parent_session_id but were spawned while the parent was still
# live — i.e., started_at < parent.ended_at).
#
# Branch sessions are identified two ways, OR'd for robustness:
# 1. A stable ``_branched_from`` marker in model_config, written
# by /branch at creation time. This survives the parent being
# reopened and re-ended with a different end_reason (e.g.
# tui_shutdown overwriting 'branched'), which otherwise hides
# the branch — see issue #20856.
# 2. The legacy heuristic (parent ended with 'branched' before the
# child started), covering branch sessions created before the
# marker existed.
where_clauses.append(_LISTABLE_CHILD_SQL)
where_clauses.append(f"{_delegate_from_json('s.model_config')} IS NULL")
if source:
where_clauses.append("s.source = ?")
params.append(source)
if exclude_sources:
placeholders = ",".join("?" for _ in exclude_sources)
where_clauses.append(f"s.source NOT IN ({placeholders})")
params.extend(exclude_sources)
if cwd_prefix:
clause, clause_params = _cwd_prefix_clause(cwd_prefix)
where_clauses.append(clause)
params.extend(clause_params)
if min_message_count > 0:
where_clauses.append("s.message_count >= ?")
params.append(min_message_count)
if archived_only:
where_clauses.append("s.archived = 1")
elif not include_archived:
where_clauses.append("s.archived = 0")
where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
# Optional session-id filter, pushed into SQL so callers (Desktop
# session-id search) don't have to fetch every row and filter in
# Python. ``id_query`` is matched as a case-insensitive substring
# against each surfaced row's id AND every id in its forward
# compression chain — so searching a compression *root* id or a *tip*
# id both resolve to the same projected conversation. Only used in the
# order_by_last_active path (which builds the chain CTE); other callers
# pass id_query=None.
id_needle = (id_query or "").strip().lower()
search_needle = (search_query or "").strip().lower()
if order_by_last_active:
# Compute effective_last_active by walking each surfaced session's
# compression-continuation chain forward in SQL and taking the MAX
# timestamp across the chain. This lets us ORDER BY + LIMIT at SQL
# level instead of fetching every row and sorting in Python, while
# still surfacing old compression roots whose live tip is fresh.
#
# The CTE seeds from rows the outer WHERE admits (roots + branch
# children), then recursively joins forward through robust
# compression-continuation edges. Do NOT require
# child.started_at >= parent.ended_at here: real desktop/gateway
# races can insert the continuation row before the parent's
# ended_at is written, while stale websocket siblings may satisfy
# the timestamp test and hijack resume/list projection.
outer_where = where_sql
id_params: List[Any] = []
filter_clauses: List[str] = []
def _like_pattern(needle: str) -> str:
escaped = (
needle.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
)
return f"%{escaped}%"
if id_needle:
# Admit a surfaced row if its own id or any id in its forward
# compression chain matches the needle. LIKE with a leading
# wildcard can't use an index, but the chain membership and
# the small result set keep this bounded — far cheaper than
# fetching every session and scanning in Python.
filter_clauses.append(
"EXISTS (SELECT 1 FROM chain cq"
" WHERE cq.root_id = s.id"
" AND LOWER(cq.cur_id) LIKE ? ESCAPE '\\')"
)
id_params.append(_like_pattern(id_needle))
if search_needle:
# Same chain-membership trick as id_query, but matching either
# the title or the id of any session in the chain. The compact
# (punctuation-stripped) variant lets `an94` match `AN-94`.
compact_needle = re.sub(r"[\W_]+", "", search_needle)
compact_sql = (
"REPLACE(REPLACE(REPLACE(REPLACE(LOWER(COALESCE({0}, '')),"
" '-', ''), '_', ''), '.', ''), ' ', '')"
)
search_clause = (
"EXISTS (SELECT 1 FROM chain cq"
" JOIN sessions cs ON cs.id = cq.cur_id"
" WHERE cq.root_id = s.id"
" AND (LOWER(COALESCE(cs.title, '')) LIKE ? ESCAPE '\\'"
" OR LOWER(cq.cur_id) LIKE ? ESCAPE '\\'"
)
id_params.extend([_like_pattern(search_needle)] * 2)
if compact_needle:
search_clause += (
f" OR {compact_sql.format('cs.title')} LIKE ? ESCAPE '\\'"
)
id_params.append(_like_pattern(compact_needle))
filter_clauses.append(search_clause + "))")
if filter_clauses:
combined = " AND ".join(filter_clauses)
outer_where = (
f"{where_sql} AND {combined}" if where_sql else f"WHERE {combined}"
)
_sel = self._compact_session_cols() if compact_rows else "s.*"
query = f"""
WITH RECURSIVE chain(root_id, cur_id) AS (
SELECT s.id, s.id FROM sessions s {where_sql}
UNION ALL
SELECT c.root_id, child.id
FROM chain c
JOIN sessions parent ON parent.id = c.cur_id
JOIN sessions child ON child.parent_session_id = c.cur_id
WHERE parent.end_reason = 'compression'
AND json_extract(COALESCE(child.model_config, '{{}}'), '$._branched_from') IS NULL
AND json_extract(COALESCE(child.model_config, '{{}}'), '$._delegate_from') IS NULL
AND COALESCE(child.source, '') != 'tool'
),
chain_max AS (
SELECT
root_id,
MAX(COALESCE(
(SELECT MAX(m.timestamp) FROM messages m WHERE m.session_id = cur_id),
(SELECT started_at FROM sessions ss WHERE ss.id = cur_id)
)) AS effective_last_active
FROM chain
GROUP BY root_id
)
SELECT {_sel},
COALESCE(
(SELECT SUBSTR(REPLACE(REPLACE(m.content, X'0A', ' '), X'0D', ' '), 1, 63)
FROM messages m
WHERE m.session_id = s.id AND m.role = 'user' AND m.content IS NOT NULL
ORDER BY m.timestamp, m.id LIMIT 1),
''
) AS _preview_raw,
COALESCE(
(SELECT MAX(m2.timestamp) FROM messages m2 WHERE m2.session_id = s.id),
s.started_at
) AS last_active,
COALESCE(cm.effective_last_active, s.started_at) AS _effective_last_active
FROM sessions s
LEFT JOIN chain_max cm ON cm.root_id = s.id
{outer_where}
ORDER BY _effective_last_active DESC, s.started_at DESC, s.id DESC
LIMIT ? OFFSET ?
"""
# WHERE params apply twice (CTE seed + outer select); the id filter
# only applies to the outer select.
params = params + params + id_params + [limit, offset]
else:
_sel = self._compact_session_cols() if compact_rows else "s.*"
query = f"""
SELECT {_sel},
COALESCE(
(SELECT SUBSTR(REPLACE(REPLACE(m.content, X'0A', ' '), X'0D', ' '), 1, 63)
FROM messages m
WHERE m.session_id = s.id AND m.role = 'user' AND m.content IS NOT NULL
ORDER BY m.timestamp, m.id LIMIT 1),
''
) AS _preview_raw,
COALESCE(
(SELECT MAX(m2.timestamp) FROM messages m2 WHERE m2.session_id = s.id),
s.started_at
) AS last_active
FROM sessions s
{where_sql}
ORDER BY s.started_at DESC
LIMIT ? OFFSET ?
"""
params.extend([limit, offset])
with self._lock:
cursor = self._conn.execute(query, params)
rows = cursor.fetchall()
sessions = []
for row in rows:
s = dict(row)
# Build the preview from the raw substring
raw = s.pop("_preview_raw", "").strip()
if raw:
text = raw[:60]
s["preview"] = text + ("..." if len(raw) > 60 else "")
else:
s["preview"] = ""
# Drop the internal ordering column so callers see a clean dict.
s.pop("_effective_last_active", None)
sessions.append(s)
# Project compression roots forward to their tips. Each row whose
# end_reason is 'compression' has a continuation child; replace the
# surfaced fields (id, message_count, title, last_active, ended_at,
# end_reason, preview) with the tip's values so the list entry acts
# as the live conversation. Keep the root's started_at to preserve
# chronological ordering by original conversation start.
if project_compression_tips and not include_children:
projected = []
for s in sessions:
if s.get("end_reason") != "compression":
projected.append(s)
continue
tip_id = self.get_compression_tip(s["id"])
if tip_id == s["id"]:
projected.append(s)
continue
tip_row = self._get_session_rich_row(tip_id, compact_rows=compact_rows)
if not tip_row:
projected.append(s)
continue
# Preserve the root's started_at for stable sort order, but
# surface the tip's identity and activity data.
merged = dict(s)
for key in (
"id", "ended_at", "end_reason", "message_count",
"tool_call_count", "title", "last_active", "preview",
"model", "system_prompt", "cwd", "git_branch", "git_repo_root",
):
if key in tip_row:
merged[key] = tip_row[key]
merged["_lineage_root_id"] = s["id"]
projected.append(merged)
sessions = projected
return sessions
def list_cron_job_runs(
self,
job_id: str,
limit: int = 20,
offset: int = 0,
) -> List[Dict[str, Any]]:
"""List the run sessions produced by a single cron job, newest first.
Cron runs are flat, independent sessions whose id is
``cron_{job_id}_{timestamp}`` (see ``cron/scheduler.run_job``). They are
never compression roots and never branch, so this deliberately skips the
``list_sessions_rich`` recursive compression-chain CTE / leading-wildcard
``id_query`` path — that path seeds from *every* ``source='cron'`` row in
the DB and only filters to one job's runs after the scan, so it scales
with the whole cron pile (a heavy history makes the desktop run-history
endpoint time out before it eventually populates).
Instead this binds to one job with a ``[prefix, prefix_hi)`` range over
the id (an index range scan, not a ``%...%`` substring), filters
``source='cron'``, and orders by ``started_at DESC``. Work scales with
the requested window, not the total cron history.
Returns the same enriched row shape as ``list_sessions_rich`` (adds
``preview`` + ``last_active``) so callers can reuse it.
"""
prefix = f"cron_{job_id}_"
# Half-open upper bound for an index range scan: increment the final
# byte of the prefix so the range covers exactly the ids that start
# with ``prefix`` and nothing else. ``prefix`` always ends in '_', but
# compute it generically rather than hardcoding the successor char.
prefix_hi = prefix[:-1] + chr(ord(prefix[-1]) + 1)
query = """
SELECT s.*,
COALESCE(
(SELECT SUBSTR(REPLACE(REPLACE(m.content, X'0A', ' '), X'0D', ' '), 1, 63)
FROM messages m
WHERE m.session_id = s.id AND m.role = 'user' AND m.content IS NOT NULL
ORDER BY m.timestamp, m.id LIMIT 1),
''
) AS _preview_raw,
COALESCE(
(SELECT MAX(m2.timestamp) FROM messages m2 WHERE m2.session_id = s.id),
s.started_at
) AS last_active
FROM sessions s
WHERE s.source = 'cron' AND s.id >= ? AND s.id < ?
ORDER BY s.started_at DESC, s.id DESC
LIMIT ? OFFSET ?
"""
with self._lock:
cursor = self._conn.execute(query, (prefix, prefix_hi, limit, offset))
rows = cursor.fetchall()
runs: List[Dict[str, Any]] = []
for row in rows:
s = dict(row)
raw = s.pop("_preview_raw", "").strip()
if raw:
text = raw[:60]
s["preview"] = text + ("..." if len(raw) > 60 else "")
else:
s["preview"] = ""
runs.append(s)
return runs
def _get_session_rich_row(self, session_id: str, compact_rows: bool = False) -> Optional[Dict[str, Any]]:
"""Fetch a single session with the same enriched columns as
``list_sessions_rich`` (preview + last_active). Returns None if the
session doesn't exist.
Pass ``compact_rows=True`` to omit the ``system_prompt`` blob (see
``list_sessions_rich`` for details).
"""
_sel = self._compact_session_cols() if compact_rows else "s.*"
query = f"""
SELECT {_sel},
COALESCE(
(SELECT SUBSTR(REPLACE(REPLACE(m.content, X'0A', ' '), X'0D', ' '), 1, 63)
FROM messages m
WHERE m.session_id = s.id AND m.role = 'user' AND m.content IS NOT NULL
ORDER BY m.timestamp, m.id LIMIT 1),
''
) AS _preview_raw,
COALESCE(
(SELECT MAX(m2.timestamp) FROM messages m2 WHERE m2.session_id = s.id),
s.started_at
) AS last_active
FROM sessions s
WHERE s.id = ?
"""
with self._lock:
cursor = self._conn.execute(query, (session_id,))
row = cursor.fetchone()
if not row:
return None
s = dict(row)
raw = s.pop("_preview_raw", "").strip()
if raw:
text = raw[:60]
s["preview"] = text + ("..." if len(raw) > 60 else "")
else:
s["preview"] = ""
return s
# =========================================================================
# Message storage
# =========================================================================
# Sentinel prefix used to distinguish JSON-encoded structured content
# (multimodal messages: lists of parts like text + image_url) from plain
# string content. The NUL byte is not legal in normal text, so this
# cannot collide with real user content.
_CONTENT_JSON_PREFIX = "\x00json:"
@classmethod
def _encode_content(cls, content: Any) -> Any:
"""Serialize structured (list/dict) message content for sqlite.
sqlite3 can only bind ``str``, ``bytes``, ``int``, ``float``, and ``None``
to query parameters. Multimodal messages have ``content`` as a list of
parts (``[{"type": "text", ...}, {"type": "image_url", ...}]``), which
raises ``ProgrammingError: Error binding parameter N: type 'list' is
not supported`` when bound directly.
Returns the value unchanged when it's already a safe scalar, or a
sentinel-prefixed JSON string for lists/dicts. Paired with
:meth:`_decode_content` on read.
"""
if isinstance(content, str):
# Lone UTF-16 surrogates reach here inside tool results scraped
# from the web/social platforms (the same input that crashed the
# guardrail hasher). The proactive sanitizer upstream only cleans
# the *api_messages* copy, and the recovery sanitizer only runs
# after the API call itself raises — which it no longer does — so
# the canonical history keeps them and this write is where they
# land. Left raw, sqlite3 raises UnicodeEncodeError, the flush is
# abandoned, and the session silently stops persisting for the
# rest of its life. Scrub so persistence never fails.
return _sanitize_surrogates(content)
if content is None or isinstance(content, (bytes, int, float)):
return content
try:
# json.dumps defaults to ensure_ascii=True, which escapes any
# surrogate as \udXXX — already safe to bind.
return cls._CONTENT_JSON_PREFIX + json.dumps(content)
except (TypeError, ValueError):
# Last-resort fallback: stringify so persistence never fails.
return _sanitize_surrogates(str(content))
@classmethod
def _decode_content(cls, content: Any) -> Any:
"""Reverse :meth:`_encode_content`; returns scalars unchanged."""
if isinstance(content, str) and content.startswith(cls._CONTENT_JSON_PREFIX):
try:
return json.loads(content[len(cls._CONTENT_JSON_PREFIX):])
except (json.JSONDecodeError, TypeError):
logger.warning(
"Failed to decode JSON-encoded message content; "
"returning raw string"
)
return content
return content
def append_message(
self,
session_id: str,
role: str,
content: str = None,
tool_name: str = None,
tool_calls: Any = None,
tool_call_id: str = None,
token_count: int = None,
finish_reason: str = None,
reasoning: str = None,
reasoning_content: str = None,
reasoning_details: Any = None,
codex_reasoning_items: Any = None,
codex_message_items: Any = None,
platform_message_id: str = None,
observed: bool = False,
effect_disposition: Optional[str] = None,
timestamp: Any = None,
api_content: Optional[str] = None,
) -> int:
"""
Append a message to a session. Returns the message row ID.
Also increments the session's message_count (and tool_call_count
if role is 'tool' or tool_calls is present).
``platform_message_id`` is the external messaging platform's own
message ID (e.g. Telegram update_id, Yuanbao msg_id). It is
independent of the SQLite autoincrement primary key and is used by
platform-specific flows like yuanbao's recall guard to redact a
message by its platform-side identifier.
``api_content`` is the exact content string sent to the API for this
message when it differs from ``content`` (ephemeral memory/plugin
injections, persist overrides). It is a byte-fidelity sidecar for
prompt-cache-stable replay — stored as sent, except lone surrogates
(which sqlite3 cannot bind and which the conversation loop scrubs
from every outgoing payload anyway, so the scrubbed form IS the
wire bytes).
"""
# Serialize structured fields to JSON before entering the write txn
reasoning_details_json = (
json.dumps(reasoning_details)
if reasoning_details else None
)
codex_items_json = (
json.dumps(codex_reasoning_items)
if codex_reasoning_items else None
)
codex_message_items_json = (
json.dumps(codex_message_items)
if codex_message_items else None
)
# tool_calls may arrive as a Python list (from the live agent) or
# as a JSON string (from import/export). Parse first to avoid
# double-encoding.
if isinstance(tool_calls, str):
try:
tool_calls = json.loads(tool_calls)
except (json.JSONDecodeError, TypeError):
tool_calls = []
tool_calls_json = json.dumps(tool_calls) if tool_calls else None
# Multimodal content (list of parts) must be JSON-encoded: sqlite3
# cannot bind list/dict parameters directly.
stored_content = self._encode_content(content)
message_timestamp = time.time()
if timestamp is not None:
try:
if hasattr(timestamp, "timestamp"):
message_timestamp = float(timestamp.timestamp())
else:
message_timestamp = float(timestamp)
except (TypeError, ValueError):
logger.debug("Ignoring invalid explicit message timestamp: %r", timestamp)
# Pre-compute tool call count
num_tool_calls = 0
if tool_calls is not None:
num_tool_calls = len(tool_calls) if isinstance(tool_calls, list) else 1
def _do(conn):
cursor = conn.execute(
"""INSERT INTO messages (session_id, role, content, tool_call_id,
tool_calls, tool_name, effect_disposition, timestamp, token_count, finish_reason,
reasoning, reasoning_content, reasoning_details, codex_reasoning_items,
codex_message_items, platform_message_id, observed, active, api_content)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
session_id,
role,
stored_content,
tool_call_id,
tool_calls_json,
_scrub_surrogates(tool_name),
effect_disposition,
message_timestamp,
token_count,
finish_reason,
_scrub_surrogates(reasoning),
_scrub_surrogates(reasoning_content),
reasoning_details_json,
codex_items_json,
codex_message_items_json,
platform_message_id,
1 if observed else 0,
1,
_scrub_surrogates(api_content) if isinstance(api_content, str) else None,
),
)
msg_id = cursor.lastrowid
# Update counters
if num_tool_calls > 0:
conn.execute(
"""UPDATE sessions SET message_count = message_count + 1,
tool_call_count = tool_call_count + ? WHERE id = ?""",
(num_tool_calls, session_id),
)
else:
conn.execute(
"UPDATE sessions SET message_count = message_count + 1 WHERE id = ?",
(session_id,),
)
return msg_id
return self._execute_write(_do)
def _insert_message_rows(self, conn, session_id: str, messages: List[Dict[str, Any]]) -> tuple[int, int]:
"""Insert *messages* as fresh active rows for *session_id*.
Shared by :meth:`replace_messages` (delete-then-insert) and
:meth:`archive_and_compact` (soft-archive-then-insert). Runs inside the
caller's write transaction (takes the live ``conn``). Returns
``(inserted_count, tool_call_count)``. Does NOT touch sessions.* counters
— the caller owns that, since the two flows reconcile counts differently.
"""
now_ts = time.time()
inserted = 0
tool_calls_total = 0
for msg in messages:
role = msg.get("role", "unknown")
tool_calls = msg.get("tool_calls")
message_timestamp = now_ts
if msg.get("timestamp") is not None:
try:
ts_value = msg.get("timestamp")
if hasattr(ts_value, "timestamp"):
message_timestamp = float(ts_value.timestamp())
else:
message_timestamp = float(ts_value)
except (TypeError, ValueError):
logger.debug("Ignoring invalid explicit message timestamp: %r", msg.get("timestamp"))
reasoning_details = msg.get("reasoning_details") if role == "assistant" else None
codex_reasoning_items = (
msg.get("codex_reasoning_items") if role == "assistant" else None
)
codex_message_items = (
msg.get("codex_message_items") if role == "assistant" else None
)
reasoning_details_json = (
json.dumps(reasoning_details) if reasoning_details else None
)
codex_items_json = (
json.dumps(codex_reasoning_items) if codex_reasoning_items else None
)
codex_message_items_json = (
json.dumps(codex_message_items) if codex_message_items else None
)
# tool_calls may arrive as a Python list (from the live agent)
# or as a JSON string (from import_sessions / export_session,
# which store it as TEXT). json.dumps on an already-serialized
# string double-encodes it, so parse first.
if isinstance(tool_calls, str):
try:
tool_calls = json.loads(tool_calls)
except (json.JSONDecodeError, TypeError):
tool_calls = []
tool_calls_json = json.dumps(tool_calls) if tool_calls else None
# Accept either `platform_message_id` (new explicit name) or
# `message_id` (yuanbao's existing convention on message dicts).
platform_msg_id = (
msg.get("platform_message_id") or msg.get("message_id")
)
api_content = msg.get("api_content")
conn.execute(
"""INSERT INTO messages (session_id, role, content, tool_call_id,
tool_calls, tool_name, effect_disposition, timestamp, token_count, finish_reason,
reasoning, reasoning_content, reasoning_details, codex_reasoning_items,
codex_message_items, platform_message_id, observed, active, api_content)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
session_id,
role,
self._encode_content(msg.get("content")),
msg.get("tool_call_id"),
tool_calls_json,
_scrub_surrogates(msg.get("tool_name")),
msg.get("effect_disposition"),
message_timestamp,
msg.get("token_count"),
msg.get("finish_reason"),
_scrub_surrogates(msg.get("reasoning")) if role == "assistant" else None,
_scrub_surrogates(msg.get("reasoning_content")) if role == "assistant" else None,
reasoning_details_json,
codex_items_json,
codex_message_items_json,
platform_msg_id,
1 if msg.get("observed") else 0,
1,
_scrub_surrogates(api_content) if isinstance(api_content, str) else None,
),
)
inserted += 1
if tool_calls is not None:
tool_calls_total += (
len(tool_calls) if isinstance(tool_calls, list) else 1
)
now_ts = max(now_ts + 1e-6, message_timestamp + 1e-6)
return inserted, tool_calls_total
def replace_messages(
self,
session_id: str,
messages: List[Dict[str, Any]],
active_only: bool = False,
) -> None:
"""Atomically replace the stored messages for a session.
Used by transcript-rewrite flows such as /retry, /undo, and /compress.
The delete + reinsert sequence must commit as one transaction so a
mid-rewrite failure does not leave SQLite with a partial transcript.
DESTRUCTIVE by default: every row for the session is DELETEd (and drops
out of the FTS index). For compaction that must preserve the
pre-compaction transcript under the same id, use
:meth:`archive_and_compact` instead.
Pass ``active_only=True`` to replace ONLY the live (``active = 1``) rows,
leaving soft-archived rows (``active = 0`` — e.g. the ``compacted = 1``
turns that :meth:`archive_and_compact` keeps on disk for #38763
durability, or rewind/undo rows) untouched. Callers that share a session
id with an agent already running in-place compaction must use this so a
full-history rewrite doesn't wipe the rows the agent deliberately
archived. ``message_count``/``tool_call_count`` then track the live set,
matching :meth:`archive_and_compact`.
"""
active_clause = " AND active = 1" if active_only else ""
def _do(conn):
conn.execute(
f"DELETE FROM messages WHERE session_id = ?{active_clause}",
(session_id,),
)
conn.execute(
"UPDATE sessions SET message_count = 0, tool_call_count = 0 WHERE id = ?",
(session_id,),
)
total_messages, total_tool_calls = self._insert_message_rows(
conn, session_id, messages
)
conn.execute(
"UPDATE sessions SET message_count = ?, tool_call_count = ? WHERE id = ?",
(total_messages, total_tool_calls, session_id),
)
self._execute_write(_do)
def has_archived_messages(self, session_id: str) -> bool:
"""Return True if the session has any soft-archived (``active = 0``) rows.
Used by callers (e.g. the ACP adapter's ``_persist``) that must decide
whether a full-history :meth:`replace_messages` would destroy durable
compaction-archived turns. Cheap existence probe — does not load rows.
"""
with self._lock:
cursor = self._conn.execute(
"SELECT 1 FROM messages WHERE session_id = ? AND active = 0 LIMIT 1",
(session_id,),
)
return cursor.fetchone() is not None
def archive_and_compact(
self, session_id: str, compacted_messages: List[Dict[str, Any]]
) -> int:
"""Non-destructive in-place compaction for a single durable session id.
Soft-archives every currently-active message (``active = 0``) and
inserts *compacted_messages* as fresh active rows — atomically, in one
write transaction. The conversation keeps ONE session id for life
(#38763) WITHOUT destroying history:
- The live-context load (:meth:`get_messages_as_conversation`,
:meth:`get_messages`) filters ``active = 1`` by default, so the model
reloads ONLY the compacted set.
- The archived pre-compaction turns stay on disk (active=0) and stay
DISCOVERABLE: they are marked compacted=1, and search_messages()
includes compacted=1 rows by default — so session_search still finds
them, unlike rewind/undo rows (active=0, compacted=0) which stay
hidden. They remain in the FTS index (the messages_fts* triggers
index on INSERT / drop on DELETE and don't key on active/compacted;
flipping to active=0 is a content-preserving UPDATE) and are
recoverable via get_messages(..., include_inactive=True).
This is the durability-preserving alternative to :meth:`replace_messages`
for compaction. ``message_count`` is set to the ACTIVE (compacted) count,
matching what the live load returns. Returns the new active count.
"""
def _do(conn):
# Soft-archive the live turns: active=0 hides them from the live
# context load, compacted=1 marks them as "summarized away" (vs
# rewind/undo's active=0+compacted=0, which means "user took it
# back"). search_messages includes compacted=1 rows by default so
# the pre-compaction transcript stays discoverable; live-context
# loads (active=1 only) still exclude them.
conn.execute(
"UPDATE messages SET active = 0, compacted = 1 "
"WHERE session_id = ? AND active = 1",
(session_id,),
)
inserted, tool_calls_total = self._insert_message_rows(
conn, session_id, compacted_messages
)
# message_count / tool_call_count reflect the LIVE (active) set —
# the archived rows are still on disk but not part of the live count.
conn.execute(
"UPDATE sessions SET message_count = ?, tool_call_count = ? WHERE id = ?",
(inserted, tool_calls_total, session_id),
)
return inserted
return self._execute_write(_do)
def set_latest_user_api_content(
self, session_id: str, content: Any, api_content: str
) -> int:
"""Backfill the ``api_content`` sidecar onto the newest ACTIVE user row.
In-place preflight compaction (:meth:`archive_and_compact`) inserts the
current turn's user row BEFORE the turn prologue composes the
prefetch/plugin sidecar, and the subsequent crash persist identity-skips
every compacted dict — without this backfill the stamped sidecar would
never land in the DB and any reload would replay clean content,
re-introducing the prompt-cache divergence the sidecar exists to close.
The ``content`` match is a defensive guard: if the newest active user
row is not the message the caller stamped (racing rewrite, unexpected
tail shape), nothing is written. Returns the number of rows updated
(0 or 1).
"""
encoded = self._encode_content(content)
def _do(conn):
cursor = conn.execute(
"UPDATE messages SET api_content = ? WHERE id = ("
"SELECT id FROM messages "
"WHERE session_id = ? AND role = 'user' AND active = 1 "
"ORDER BY id DESC LIMIT 1"
") AND content IS ?",
(_scrub_surrogates(api_content), session_id, encoded),
)
return cursor.rowcount
return self._execute_write(_do)
def get_messages(
self,
session_id: str,
include_inactive: bool = False,
limit: Optional[int] = None,
offset: int = 0,
) -> List[Dict[str, Any]]:
"""Load messages for a session in insertion order.
By default only active messages are returned. Pass
``include_inactive=True`` to load soft-deleted rows (e.g. for
audit / debug views of rewound history). See
:meth:`rewind_to_message` for the soft-delete mechanic.
Ordered by AUTOINCREMENT id (true insertion order) rather than
timestamp — see c03acca50 for the WSL2 clock-regression rationale.
When ``limit`` is provided, returns at most ``limit`` messages
starting from ``offset`` (0-based, in insertion order). Enables
pagination for the API endpoint to avoid loading entire transcripts.
``offset`` alone (without ``limit``) also pages — SQLite requires a
LIMIT clause for OFFSET, so it's emitted as ``LIMIT -1`` (unbounded).
"""
active_clause = "" if include_inactive else " AND active = 1"
sql = (
"SELECT * FROM messages WHERE session_id = ?"
f"{active_clause} ORDER BY id"
)
params: list = [session_id]
if limit is not None or offset:
# SQLite's OFFSET requires LIMIT; -1 means "no limit".
sql += " LIMIT ? OFFSET ?"
params.extend([-1 if limit is None else limit, offset])
with self._lock:
cursor = self._conn.execute(sql, params)
rows = cursor.fetchall()
result = []
for row in rows:
msg = dict(row)
if "content" in msg:
msg["content"] = self._decode_content(msg["content"])
if msg.get("tool_calls"):
try:
msg["tool_calls"] = json.loads(msg["tool_calls"])
except (json.JSONDecodeError, TypeError):
logger.warning("Failed to deserialize tool_calls in get_messages, falling back to []")
msg["tool_calls"] = []
result.append(msg)
return result
def get_messages_around(
self,
session_id: str,
around_message_id: int,
window: int = 5,
) -> Dict[str, Any]:
"""Load a window of messages anchored on a specific message id.
Returns a dict with:
- ``window``: up to ``window`` messages before the anchor, the anchor
itself, and up to ``window`` messages after, ordered by id ascending.
- ``messages_before``: count of messages strictly before the anchor
still in the session (== window unless we hit the start).
- ``messages_after``: count of messages strictly after the anchor
still in the session (== window unless we hit the end).
Used by ``session_search`` for both the discovery shape (anchored on the
FTS5 match) and the scroll shape (anchored on any message id). The
``messages_before`` / ``messages_after`` counts let the caller detect
session boundaries: when either is less than ``window``, the agent has
reached one end of the session.
Returns an empty window when ``around_message_id`` is not a real id in
``session_id`` — callers decide how to surface that.
"""
if window < 0:
window = 0
with self._lock:
# Confirm the anchor exists in this session.
anchor_exists = self._conn.execute(
"SELECT 1 FROM messages WHERE id = ? AND session_id = ? LIMIT 1",
(around_message_id, session_id),
).fetchone()
if not anchor_exists:
return {"window": [], "messages_before": 0, "messages_after": 0}
# Two queries: anchor + before (DESC, take window+1), and after
# (ASC, take window). Final order is id ASC.
before_rows = self._conn.execute(
"SELECT * FROM messages "
"WHERE session_id = ? AND id <= ? "
"ORDER BY id DESC LIMIT ?",
(session_id, around_message_id, window + 1),
).fetchall()
after_rows = self._conn.execute(
"SELECT * FROM messages "
"WHERE session_id = ? AND id > ? "
"ORDER BY id ASC LIMIT ?",
(session_id, around_message_id, window),
).fetchall()
# before_rows is DESC; reverse so it's ASC, then concatenate after_rows.
rows = list(reversed(before_rows)) + list(after_rows)
result = []
for row in rows:
msg = dict(row)
if "content" in msg:
msg["content"] = self._decode_content(msg["content"])
if msg.get("tool_calls"):
try:
msg["tool_calls"] = json.loads(msg["tool_calls"])
except (json.JSONDecodeError, TypeError):
logger.warning(
"Failed to deserialize tool_calls in get_messages_around, falling back to []"
)
msg["tool_calls"] = []
result.append(msg)
# before_rows includes the anchor itself; subtract 1 for the count of
# messages strictly before the anchor in the returned slice.
messages_before = max(0, len(before_rows) - 1)
messages_after = len(after_rows)
return {
"window": result,
"messages_before": messages_before,
"messages_after": messages_after,
}
def get_anchored_view(
self,
session_id: str,
around_message_id: int,
window: int = 5,
bookend: int = 3,
keep_roles: Optional[Tuple[str, ...]] = ("user", "assistant"),
) -> Dict[str, Any]:
"""Return an anchored window plus session bookends.
Built on top of ``get_messages_around``. Three slices:
- ``window``: messages immediately surrounding the anchor. Filtered
to ``keep_roles`` (tool-response noise dropped by default), EXCEPT
the anchor itself is always preserved regardless of role.
- ``bookend_start``: first ``bookend`` user/assistant messages of the
session — but only those whose id is strictly before the window's
first message id. Empty when the window already overlaps the
session head. Empty-content messages (tool-call-only assistant
turns) are skipped so they don't crowd out actual prose openings.
- ``bookend_end``: last ``bookend`` user/assistant messages of the
session, same non-overlap rule at the tail.
Bookends let an FTS5 hit anywhere in a long session yield the goal
(opening) and the resolution (closing) on a single call — without
loading the whole transcript.
Returns ``{"window": [], "messages_before": 0, "messages_after": 0,
"bookend_start": [], "bookend_end": []}`` when the anchor isn't in
the session.
``keep_roles=None`` disables role filtering (raw window + raw
bookends).
"""
if bookend < 0:
bookend = 0
# Reuse the primitive — handles anchor-existence, content decoding,
# tool_calls deserialisation, and boundary counts.
primitive = self.get_messages_around(
session_id, around_message_id, window=window
)
window_rows = primitive["window"]
if not window_rows:
return {
"window": [],
"messages_before": 0,
"messages_after": 0,
"bookend_start": [],
"bookend_end": [],
}
# Apply role filter to the window, but never drop the anchor itself.
if keep_roles is not None:
keep_set = set(keep_roles)
filtered_window = [
m for m in window_rows
if m.get("id") == around_message_id or m.get("role") in keep_set
]
else:
filtered_window = window_rows
window_min_id = window_rows[0]["id"]
window_max_id = window_rows[-1]["id"]
# Fetch bookends only when there's room outside the window. SQL filters
# by id range, role, and non-empty content — tool-call-only assistant
# turns (content='' with tool_calls populated) are excluded so they
# don't crowd out actual prose openings/closings.
bookend_start_rows: List[Any] = []
bookend_end_rows: List[Any] = []
if bookend > 0:
with self._lock:
role_clause = ""
role_params: list = []
if keep_roles is not None:
role_placeholders = ",".join("?" for _ in keep_roles)
role_clause = f" AND role IN ({role_placeholders})"
role_params = list(keep_roles)
bookend_start_rows = self._conn.execute(
f"SELECT * FROM messages "
f"WHERE session_id = ? AND id < ?{role_clause} "
f"AND length(content) > 0 "
f"ORDER BY id ASC LIMIT ?",
(session_id, window_min_id, *role_params, bookend),
).fetchall()
bookend_end_rows = self._conn.execute(
f"SELECT * FROM messages "
f"WHERE session_id = ? AND id > ?{role_clause} "
f"AND length(content) > 0 "
f"ORDER BY id DESC LIMIT ?",
(session_id, window_max_id, *role_params, bookend),
).fetchall()
# End rows came back DESC for the LIMIT cap; flip to ASC.
bookend_end_rows = list(reversed(bookend_end_rows))
def _hydrate(row) -> Dict[str, Any]:
msg = dict(row)
if "content" in msg:
msg["content"] = self._decode_content(msg["content"])
if msg.get("tool_calls"):
try:
msg["tool_calls"] = json.loads(msg["tool_calls"])
except (json.JSONDecodeError, TypeError):
logger.warning(
"Failed to deserialize tool_calls in get_anchored_view, falling back to []"
)
msg["tool_calls"] = []
return msg
return {
"window": filtered_window,
"messages_before": primitive["messages_before"],
"messages_after": primitive["messages_after"],
"bookend_start": [_hydrate(r) for r in bookend_start_rows],
"bookend_end": [_hydrate(r) for r in bookend_end_rows],
}
def resolve_resume_session_id(self, session_id: str) -> str:
"""Redirect a resume target to the descendant session that holds the messages.
Context compression ends the current session and forks a new child session
(linked via ``parent_session_id``). The flush cursor is reset, so the
child is where new messages actually land — the parent ends up with
``message_count = 0`` rows unless messages had already been flushed to
it before compression. See #15000.
This helper walks ``parent_session_id`` forward from ``session_id`` and
returns the descendant in the chain that has the **most recent** messages.
Unlike the original logic, it does NOT short-circuit when the starting
session already has messages — a descendant that was created by
compression may hold the continuation content and should be preferred
by the WebUI and gateway for ``--resume`` and session loading.
If no descendant (including the starting session) has any messages,
the original ``session_id`` is returned unchanged.
The chain is always walked via the child whose ``started_at`` is
latest; that matches the single-chain shape that compression creates.
A depth cap (32) guards against accidental loops in malformed data.
"""
if not session_id:
return session_id
# Follow the compression-continuation chain forward to the live tip
# FIRST. Auto-compression ends the current session and forks a
# continuation child, but a long-lived parent keeps its own flushed
# message rows — so the empty-head walk below never redirects it, and
# resuming the parent id reloads the pre-compression transcript while
# the turns generated *after* compression (and their responses) sit in
# the continuation. ``get_compression_tip`` is lineage-aware: it only
# follows children whose parent ended with ``end_reason='compression'``
# (created after the parent was ended), so delegation / branch children
# never hijack the resume. This is the fix for the desktop "I came back
# and the reply isn't there" report on large sessions.
try:
tip = self.get_compression_tip(session_id)
except Exception:
tip = session_id
if tip and tip != session_id:
session_id = tip
with self._lock:
current = session_id
seen = {current}
best = None # tracks the last (deepest) node with messages
for _ in range(32):
# Check if the current node has messages.
try:
row = self._conn.execute(
"SELECT 1 FROM messages WHERE session_id = ? LIMIT 1",
(current,),
).fetchone()
except Exception:
return session_id
if row is not None:
best = current
# Walk to the most-recently-started child — but skip explicit
# branch (`_branched_from`), delegate/subagent (`_delegate_from`),
# and tool children. They also carry a ``parent_session_id`` yet
# are NOT compression continuations; following them would hijack
# the resume target to an unrelated session (e.g. a subagent
# run). This mirrors the child-exclusion in ``get_compression_tip``.
try:
child_row = self._conn.execute(
"SELECT id FROM sessions "
"WHERE parent_session_id = ? "
" AND json_extract(COALESCE(model_config, '{}'), '$._branched_from') IS NULL "
" AND json_extract(COALESCE(model_config, '{}'), '$._delegate_from') IS NULL "
" AND COALESCE(source, '') != 'tool' "
"ORDER BY started_at DESC, id DESC LIMIT 1",
(current,),
).fetchone()
except Exception:
return session_id
if child_row is None:
break
child_id = child_row["id"] if hasattr(child_row, "keys") else child_row[0]
if not child_id or child_id in seen:
break
seen.add(child_id)
current = child_id
return best if best is not None else session_id
def get_messages_as_conversation(
self,
session_id: str,
include_ancestors: bool = False,
include_inactive: bool = False,
repair_alternation: bool = False,
) -> List[Dict[str, Any]]:
"""
Load messages in the OpenAI conversation format (role + content dicts).
Used by the gateway to restore conversation history.
By default only active messages are returned. Pass
``include_inactive=True`` to load soft-deleted (rewound) rows
as well. See :meth:`rewind_to_message`.
``repair_alternation=True`` runs ``repair_message_sequence`` over the
loaded list before returning it. Callers that restore a session for
LIVE REPLAY should pass it: a durable alternation violation (e.g. a
``user;user`` pair left by a turn that persisted no assistant row)
otherwise re-triggers the pre-request defensive repair on every
single request for the rest of the session's life — the repair
mutates only the per-request list, never the stored transcript.
Inspection/export consumers keep the default and see the transcript
verbatim.
"""
session_ids = [session_id]
if include_ancestors:
session_ids = self._session_lineage_root_to_tip(session_id)
active_clause = "" if include_inactive else " AND active = 1"
with self._lock:
placeholders = ",".join("?" for _ in session_ids)
rows = self._conn.execute(
"SELECT role, content, tool_call_id, tool_calls, tool_name, effect_disposition, "
"finish_reason, reasoning, reasoning_content, reasoning_details, "
"codex_reasoning_items, codex_message_items, platform_message_id, observed, timestamp, "
"api_content "
f"FROM messages WHERE session_id IN ({placeholders})"
# Order by AUTOINCREMENT id (true insertion order), NOT timestamp:
# append_message stamps rows with time.time(), which is not
# monotonic (WSL2, NTP steps, VM/laptop sleep resume). A later
# row can carry an earlier timestamp than its predecessor, and
# ORDER BY timestamp would then sort an assistant tool_calls row
# after its tool response, breaking tool-call/response adjacency
# and triggering an HTTP 400 on replay. This matches get_messages
# — see c03acca50 for the original fix.
f"{active_clause} ORDER BY id",
tuple(session_ids),
).fetchall()
return self._rows_to_conversation(
rows,
session_id=session_id,
include_ancestors=include_ancestors,
repair_alternation=repair_alternation,
)
# Columns every conversation projection decodes. Shared by
# get_messages_as_conversation and get_resume_conversations so a single
# SELECT can feed both the model-fed and display views.
_CONVERSATION_ROW_COLUMNS = (
"role, content, tool_call_id, tool_calls, tool_name, effect_disposition, "
"finish_reason, reasoning, reasoning_content, reasoning_details, "
"codex_reasoning_items, codex_message_items, platform_message_id, observed, timestamp, "
"api_content"
)
def _rows_to_conversation(
self,
rows,
*,
session_id: str,
include_ancestors: bool,
repair_alternation: bool,
) -> List[Dict[str, Any]]:
"""Decode fetched message rows into the OpenAI conversation format.
Extracted from get_messages_as_conversation so get_resume_conversations
can build the model-fed and display views from one SELECT. ``rows`` must
already be ordered by ``id`` (insertion order) and filtered to the
desired session set / active state by the caller.
"""
messages = []
for row in rows:
content = self._decode_content(row["content"])
if row["role"] in {"user", "assistant"} and isinstance(content, str):
content = sanitize_context(content).strip()
msg = {"role": row["role"], "content": content}
# api_content is the byte-fidelity sidecar: the exact string sent
# to the API when it differed from the clean content. Returned
# VERBATIM — no sanitize_context, no strip — because the replay
# path substitutes it for content to keep the provider prompt
# cache prefix byte-stable across turns. Cleaning it here would
# re-introduce the divergence it exists to remove.
if row["api_content"]:
msg["api_content"] = row["api_content"]
if row["timestamp"]:
msg["timestamp"] = row["timestamp"]
if row["tool_call_id"]:
msg["tool_call_id"] = row["tool_call_id"]
if row["tool_name"]:
msg["tool_name"] = row["tool_name"]
if row["effect_disposition"]:
msg["effect_disposition"] = row["effect_disposition"]
if row["tool_calls"]:
try:
msg["tool_calls"] = json.loads(row["tool_calls"])
except (json.JSONDecodeError, TypeError):
logger.warning("Failed to deserialize tool_calls in conversation replay, falling back to []")
msg["tool_calls"] = []
# Surface the platform-side message id (e.g. yuanbao msg_id,
# telegram update_id) so platform-specific flows like recall
# can match by external identifier instead of having to fall
# back to content-match heuristics. Exposed as ``message_id``
# for backward compatibility with the JSONL transcript shape.
if row["platform_message_id"]:
msg["message_id"] = row["platform_message_id"]
if row["observed"]:
msg["observed"] = True
# Restore reasoning fields on assistant messages so providers
# that replay reasoning (OpenRouter, OpenAI, Nous) receive
# coherent multi-turn reasoning context.
if row["role"] == "assistant":
if row["finish_reason"]:
msg["finish_reason"] = row["finish_reason"]
if row["reasoning"]:
msg["reasoning"] = row["reasoning"]
if row["reasoning_content"] is not None:
msg["reasoning_content"] = row["reasoning_content"]
if row["reasoning_details"]:
try:
msg["reasoning_details"] = json.loads(row["reasoning_details"])
except (json.JSONDecodeError, TypeError):
logger.warning("Failed to deserialize reasoning_details, falling back to None")
msg["reasoning_details"] = None
if row["codex_reasoning_items"]:
try:
msg["codex_reasoning_items"] = json.loads(row["codex_reasoning_items"])
except (json.JSONDecodeError, TypeError):
logger.warning("Failed to deserialize codex_reasoning_items, falling back to None")
msg["codex_reasoning_items"] = None
if row["codex_message_items"]:
try:
msg["codex_message_items"] = json.loads(row["codex_message_items"])
except (json.JSONDecodeError, TypeError):
logger.warning("Failed to deserialize codex_message_items, falling back to None")
msg["codex_message_items"] = None
if include_ancestors and self._is_duplicate_replayed_user_message(messages, msg):
continue
messages.append(msg)
# DEFENSE-IN-DEPTH against background-review session pollution: a forked
# skill/memory review that (in older builds, before the _persist_disabled
# fix) shared the parent's session_id wrote its harness turn into this
# real session. The harness is a user/system message instructing the
# agent to "Review the conversation above and update the skill library /
# save to memory" under a hard tool restriction; re-loading it as live
# history makes the agent adopt the curator role and refuse the user's
# actual task. Strip any such harness message AND the curator-mode
# assistant reply immediately following it, so a polluted session
# resumes clean even if stray rows exist.
messages = _strip_background_review_harness(messages)
if repair_alternation and messages:
# Lazy import: hermes_state already depends on agent.* (see
# sanitize_context above), but keep this optional path from
# widening the import surface at module load.
from agent.agent_runtime_helpers import repair_message_sequence
repaired = repair_message_sequence(None, messages)
if repaired:
logger.info(
"Repaired %d message-alternation violation(s) while "
"restoring session %s — durable transcript kept them, "
"see repair_message_sequence",
repaired,
session_id,
)
return messages
def get_resume_conversations(
self, session_id: str
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
"""Return ``(model_history, display_history)`` for a session resume in ONE SELECT.
``session.resume`` needs two projections of the same lineage:
- ``model_history`` — the tip session's active rows, alternation-repaired
(the live-replay working conversation). Equivalent to
``get_messages_as_conversation(session_id, repair_alternation=True)``.
- ``display_history`` — the full lineage (ancestors → tip), verbatim, with
replayed-user dedup. Equivalent to
``get_messages_as_conversation(session_id, include_ancestors=True)``.
The display fetch already reads a superset of the model fetch (the tip
rows are part of the lineage), so serving both from one lineage SELECT
halves the resume's DB work versus two separate calls, with byte-identical
output (see test_get_resume_conversations_matches_separate_reads).
"""
session_ids = self._session_lineage_root_to_tip(session_id)
with self._lock:
placeholders = ",".join("?" for _ in session_ids)
rows = self._conn.execute(
f"SELECT session_id, {self._CONVERSATION_ROW_COLUMNS} "
f"FROM messages WHERE session_id IN ({placeholders}) AND active = 1 "
# ORDER BY id (insertion order) — see get_messages_as_conversation
# for why timestamp ordering is unsafe.
"ORDER BY id",
tuple(session_ids),
).fetchall()
# Tip rows are exactly the model-fed set (get_messages_as_conversation
# with session_ids=[session_id]); filtering the lineage fetch preserves
# their relative id order.
tip_rows = [r for r in rows if r["session_id"] == session_id]
model_history = self._rows_to_conversation(
tip_rows,
session_id=session_id,
include_ancestors=False,
repair_alternation=True,
)
display_history = self._rows_to_conversation(
rows,
session_id=session_id,
include_ancestors=True,
repair_alternation=False,
)
return model_history, display_history
def get_ancestor_display_prefix(self, session_id: str) -> List[Dict[str, Any]]:
"""Return the ancestor-only display messages for a session lineage.
These are messages from parent/grandparent sessions (compression
ancestors) that appear in the display transcript but NOT in the
tip session's model-fed history. Used by ``session.resume`` to
build the ``display_history_prefix`` that ``_live_session_payload``
prepends to the live model history.
Previously the prefix was calculated as
``display_history[:len(display) - len(raw)]``, but that overcounts
when ``repair_message_sequence`` removes messages from the MIDDLE
of the tip history (e.g. verification candidates collapsed by the
consecutive-assistant merge) — the length difference includes both
ancestor messages AND repair-removed tip messages, but the slice
only captures the first N display messages (which are tip messages
when there are no ancestors), causing duplication. This method
returns ONLY the genuine ancestor messages, identified by
``session_id != tip_session_id``. (#65919)
"""
session_ids = self._session_lineage_root_to_tip(session_id)
if len(session_ids) <= 1:
return []
with self._lock:
placeholders = ",".join("?" for _ in session_ids)
rows = self._conn.execute(
f"SELECT session_id, {self._CONVERSATION_ROW_COLUMNS} "
f"FROM messages WHERE session_id IN ({placeholders}) AND active = 1 "
"ORDER BY id",
tuple(session_ids),
).fetchall()
ancestor_rows = [r for r in rows if r["session_id"] != session_id]
if not ancestor_rows:
return []
return self._rows_to_conversation(
ancestor_rows,
session_id=session_id,
include_ancestors=True,
repair_alternation=False,
)
def get_conversation_root(self, session_id: str) -> str:
"""Return the ROOT id of *session_id*'s lineage chain.
The root is the stable "conversation id": context compression
rotates ``session_id`` to a new segment linked via
``parent_session_id``, and delegate subagents hang off their
parent the same way. Walking to the root gives every segment of
one user-facing conversation (and its delegation tree) a single
identifier — used for Nous Portal ``conversation=`` usage tagging.
Returns *session_id* unchanged when it has no recorded parent.
"""
chain = self._session_lineage_root_to_tip(session_id)
return (chain[0] if chain and chain[0] else session_id)
def _session_lineage_root_to_tip(self, session_id: str) -> List[str]:
if not session_id:
return [session_id]
chain = []
current = session_id
seen = set()
with self._lock:
for _ in range(100):
if not current or current in seen:
break
seen.add(current)
chain.append(current)
row = self._conn.execute(
"SELECT parent_session_id FROM sessions WHERE id = ?",
(current,),
).fetchone()
if row is None:
break
current = row["parent_session_id"] if hasattr(row, "keys") else row[0]
return list(reversed(chain)) or [session_id]
@staticmethod
def _is_duplicate_replayed_user_message(messages: List[Dict[str, Any]], msg: Dict[str, Any]) -> bool:
if msg.get("role") != "user":
return False
content = msg.get("content")
if not isinstance(content, str) or not content:
return False
for prev in reversed(messages):
if prev.get("role") == "user" and prev.get("content") == content:
return True
if prev.get("role") == "assistant" and (prev.get("content") or prev.get("tool_calls")):
return False
return False
# =========================================================================
# Rewind (soft-delete) — see /rewind slash command + issue #21910
# =========================================================================
def rewind_to_message(
self, session_id: str, target_message_id: int
) -> Dict[str, Any]:
"""Soft-delete all messages with id >= ``target_message_id`` in *session_id*.
The target message itself becomes inactive as well so the caller
can pre-fill it as the next user prompt without it appearing
twice in the replayed transcript. Rewound rows are kept on
disk with ``active=0`` for audit / forensic inspection — use
:meth:`get_messages` with ``include_inactive=True`` to see them.
Returns a dict::
{
"rewound_count": int, # number of rows newly flipped to active=0
"target_message": dict, # full row dict of the target
"new_head_id": int|None # id of the last still-active row, or None
}
Raises ``ValueError`` if the target message does not exist in
*session_id* or if its role is not ``"user"``.
Always increments ``sessions.rewind_count`` — even when the
target is already inactive — so the counter accurately reflects
the number of rewind operations performed against the session.
Idempotent on the ``active`` flag: re-rewinding past the same
target is a no-op on row state but still bumps the counter.
"""
# 1) Validate target up-front (read-only, outside the write txn).
with self._lock:
row = self._conn.execute(
"SELECT * FROM messages WHERE id = ? AND session_id = ?",
(target_message_id, session_id),
).fetchone()
if row is None:
raise ValueError(
f"message {target_message_id} not found in session {session_id}"
)
target_row = dict(row)
if target_row.get("role") != "user":
raise ValueError(
f"rewind target must be a 'user' message (got role="
f"{target_row.get('role')!r}, id={target_message_id})"
)
# Decode content for callers (prefill the prompt buffer).
target_row["content"] = self._decode_content(target_row.get("content"))
rewound: List[int] = []
def _do(conn):
cursor = conn.execute(
"SELECT id FROM messages "
"WHERE session_id = ? AND id >= ? AND active = 1",
(session_id, target_message_id),
)
ids = [r[0] for r in cursor.fetchall()]
if ids:
placeholders = ",".join("?" for _ in ids)
conn.execute(
f"UPDATE messages SET active = 0 WHERE id IN ({placeholders})",
ids,
)
conn.execute(
"UPDATE sessions SET rewind_count = COALESCE(rewind_count, 0) + 1 "
"WHERE id = ?",
(session_id,),
)
return ids
rewound = self._execute_write(_do)
# 2) Compute new head id (largest still-active row id in session).
with self._lock:
head_row = self._conn.execute(
"SELECT MAX(id) FROM messages WHERE session_id = ? AND active = 1",
(session_id,),
).fetchone()
new_head_id = head_row[0] if head_row and head_row[0] is not None else None
return {
"rewound_count": len(rewound),
"target_message": target_row,
"new_head_id": new_head_id,
}
def restore_rewound(self, session_id: str, since_message_id: int) -> int:
"""Mark inactive messages with id >= *since_message_id* active again.
Returns the number of rows flipped back to ``active=1``.
Intended for undo-of-rewind and test cleanup; not wired to a
slash command in v1.
"""
def _do(conn):
cursor = conn.execute(
"SELECT id FROM messages "
"WHERE session_id = ? AND id >= ? AND active = 0",
(session_id, since_message_id),
)
ids = [r[0] for r in cursor.fetchall()]
if ids:
placeholders = ",".join("?" for _ in ids)
conn.execute(
f"UPDATE messages SET active = 1 WHERE id IN ({placeholders})",
ids,
)
return len(ids)
return self._execute_write(_do)
def list_recent_user_messages(
self,
session_id: str,
limit: int = 20,
include_inactive: bool = False,
) -> List[Dict[str, Any]]:
"""Return the *limit* most-recent user messages, newest first.
Each entry is a dict with keys ``id``, ``timestamp``, ``preview``.
``preview`` is the first 80 characters of the message content
(with line breaks collapsed to spaces). Used by the /rewind
slash command picker.
By default only active messages are returned.
"""
active_clause = "" if include_inactive else " AND active = 1"
with self._lock:
cursor = self._conn.execute(
"SELECT id, timestamp, content FROM messages "
"WHERE session_id = ? AND role = 'user'"
f"{active_clause} "
"ORDER BY id DESC LIMIT ?",
(session_id, int(limit)),
)
rows = cursor.fetchall()
result: List[Dict[str, Any]] = []
for row in rows:
decoded = self._decode_content(row["content"])
if isinstance(decoded, list):
# Multimodal — flatten text parts.
text_parts = [
p.get("text", "") for p in decoded
if isinstance(p, dict) and p.get("type") == "text"
]
preview = " ".join(t for t in text_parts if t).strip()
if not preview:
preview = "[multimodal content]"
elif isinstance(decoded, str):
preview = decoded
else:
preview = ""
preview = " ".join(preview.split()) # collapse whitespace
if len(preview) > 80:
preview = preview[:77] + "..."
result.append(
{
"id": row["id"],
"timestamp": row["timestamp"],
"preview": preview,
}
)
return result
# =========================================================================
# Search
# =========================================================================
@staticmethod
def _sanitize_fts5_query(query: str) -> str:
"""Sanitize user input for safe use in FTS5 MATCH queries.
FTS5 has its own query syntax where characters like ``"``, ``(``, ``)``,
``+``, ``*``, ``{``, ``}``, the column-filter operator ``:`` and bare
boolean operators (``AND``, ``OR``, ``NOT``) have special meaning.
Passing raw user input directly to MATCH can cause
``sqlite3.OperationalError``.
Strategy:
- Preserve properly paired quoted phrases (``"exact phrase"``)
- Strip unmatched FTS5-special characters that would cause errors
- Wrap unquoted hyphenated and dotted terms in quotes so FTS5
matches them as exact phrases instead of splitting on the
hyphen/dot (e.g. ``chat-send``, ``P2.2``, ``my-app.config.ts``)
"""
# Cap user-controlled FTS input before any regex processing. Search
# queries do not need to be arbitrarily large, and bounding them keeps
# sanitizer/runtime behavior predictable under adversarial input.
query = query[:MAX_FTS5_QUERY_CHARS]
# Step 1: Extract balanced double-quoted phrases and protect them
# from further processing via numbered placeholders. Do this with a
# single linear scan rather than a regex so pathological quote runs
# cannot induce backtracking.
_quoted_parts: list = []
pieces: list[str] = []
i = 0
while i < len(query):
ch = query[i]
if ch != '"':
pieces.append(ch)
i += 1
continue
end = query.find('"', i + 1)
if end == -1:
# Unmatched quote: replace with whitespace like the old
# sanitizer's special-char stripping step.
pieces.append(" ")
i += 1
continue
_quoted_parts.append(query[i:end + 1])
pieces.append(f"\x00Q{len(_quoted_parts) - 1}\x00")
i = end + 1
sanitized = "".join(pieces)
# Step 2: Strip remaining (unmatched) FTS5-special characters. ``:`` is
# FTS5's column-filter operator (``col:term``); since the FTS table has a
# single ``content`` column, an unquoted colon query like ``TODO: fix``
# parses as ``column:term`` and raises "no such column" — swallowed at
# the execute site into zero results. Strip it like the others.
sanitized = re.sub(r'[+{}():\"^]', " ", sanitized)
# Step 3: Collapse repeated * (e.g. "***") into a single one,
# and remove leading * (prefix-only needs at least one char before *)
sanitized = re.sub(r"\*+", "*", sanitized)
sanitized = re.sub(r"(^|\s)\*", r"\1", sanitized)
# Step 4: Remove dangling boolean operators at start/end that would
# cause syntax errors (e.g. "hello AND" or "OR world")
sanitized = re.sub(r"(?i)^(AND|OR|NOT)\b\s*", "", sanitized.strip())
sanitized = re.sub(r"(?i)\s+(AND|OR|NOT)\s*$", "", sanitized.strip())
# Step 5: Wrap unquoted dotted and/or hyphenated terms in double
# quotes. FTS5's tokenizer splits on dots and hyphens, turning
# ``chat-send`` into ``chat AND send`` and ``P2.2`` into ``p2 AND 2``.
# Quoting preserves phrase semantics. A single pass avoids the
# double-quoting bug that would occur if dotted, hyphenated and underscored
# patterns were applied sequentially (e.g. ``my-app.config``).
sanitized = re.sub(r"\b(\w+(?:[._-]\w+)+)\b", r'"\1"', sanitized)
# Step 6: Restore preserved quoted phrases
for i, quoted in enumerate(_quoted_parts):
sanitized = sanitized.replace(f"\x00Q{i}\x00", quoted)
return sanitized.strip()
@staticmethod
def _is_cjk_codepoint(cp: int) -> bool:
return (0x4E00 <= cp <= 0x9FFF or # CJK Unified Ideographs
0x3400 <= cp <= 0x4DBF or # CJK Extension A
0x20000 <= cp <= 0x2A6DF or # CJK Extension B
0x3000 <= cp <= 0x303F or # CJK Symbols
0x3040 <= cp <= 0x309F or # Hiragana
0x30A0 <= cp <= 0x30FF or # Katakana
0xAC00 <= cp <= 0xD7AF) # Hangul Syllables
@staticmethod
def _contains_cjk(text: str) -> bool:
"""Check if text contains CJK (Chinese, Japanese, Korean) characters."""
for ch in text:
cp = ord(ch)
if (0x4E00 <= cp <= 0x9FFF or # CJK Unified Ideographs
0x3400 <= cp <= 0x4DBF or # CJK Extension A
0x20000 <= cp <= 0x2A6DF or # CJK Extension B
0x3000 <= cp <= 0x303F or # CJK Symbols
0x3040 <= cp <= 0x309F or # Hiragana
0x30A0 <= cp <= 0x30FF or # Katakana
0xAC00 <= cp <= 0xD7AF): # Hangul Syllables
return True
return False
@classmethod
def _count_cjk(cls, text: str) -> int:
"""Count CJK characters in text."""
return sum(1 for ch in text if cls._is_cjk_codepoint(ord(ch)))
def search_messages(
self,
query: str,
source_filter: List[str] = None,
exclude_sources: List[str] = None,
role_filter: List[str] = None,
limit: int = 20,
offset: int = 0,
sort: str = None,
include_inactive: bool = False,
) -> List[Dict[str, Any]]:
"""
Full-text search across session messages using FTS5.
Supports FTS5 query syntax:
- Simple keywords: "docker deployment"
- Phrases: '"exact phrase"'
- Boolean: "docker OR kubernetes", "python NOT java"
- Prefix: "deploy*"
Returns matching messages with session metadata, content snippet,
and surrounding context (1 message before and after the match).
``sort`` controls temporal ordering:
- ``None`` (default): FTS5 BM25 relevance only. Time-neutral.
- ``"newest"``: order by message timestamp DESC, then by rank.
- ``"oldest"``: order by message timestamp ASC, then by rank.
The short-CJK LIKE fallback already orders by timestamp DESC and
ignores ``sort``. The trigram CJK path honours ``sort`` like the main
FTS5 path.
Rewound (``active=0``, ``compacted=0``) rows are excluded by default —
the user took those back. Compaction-archived rows (``active=0``,
``compacted=1``) ARE included by default: they were summarized away from
the live context but remain part of the conversation's record, so the
pre-compaction transcript stays discoverable after in-place compaction
(#38763). Pass ``include_inactive=True`` to search every row regardless.
"""
if not self._fts_enabled:
return []
if not query or not query.strip():
return []
query = self._sanitize_fts5_query(query)
if not query:
return []
# Normalise sort. Anything not in the allowed set falls back to None
# (FTS5 rank-only) so callers can pass through user input without
# validation.
if isinstance(sort, str):
sort_norm = sort.strip().lower()
if sort_norm not in ("newest", "oldest"):
sort_norm = None
else:
sort_norm = None
# ORDER BY shared across the main FTS5 path and trigram CJK path.
# With sort set, timestamp is primary and rank is the tiebreaker.
if sort_norm == "newest":
order_by_sql = "ORDER BY m.timestamp DESC, rank"
elif sort_norm == "oldest":
order_by_sql = "ORDER BY m.timestamp ASC, rank"
else:
order_by_sql = "ORDER BY rank"
# Build WHERE clauses dynamically
where_clauses = ["messages_fts MATCH ?"]
params: list = [query]
if not include_inactive:
# Live rows (active=1) AND compaction-archived rows (compacted=1)
# are discoverable; only rewind/undo rows (active=0, compacted=0)
# are hidden. See archive_and_compact() / #38763.
where_clauses.append("(m.active = 1 OR m.compacted = 1)")
if source_filter is not None:
source_placeholders = ",".join("?" for _ in source_filter)
where_clauses.append(f"s.source IN ({source_placeholders})")
params.extend(source_filter)
if exclude_sources is not None:
exclude_placeholders = ",".join("?" for _ in exclude_sources)
where_clauses.append(f"s.source NOT IN ({exclude_placeholders})")
params.extend(exclude_sources)
if role_filter:
role_placeholders = ",".join("?" for _ in role_filter)
where_clauses.append(f"m.role IN ({role_placeholders})")
params.extend(role_filter)
where_sql = " AND ".join(where_clauses)
params.extend([limit, offset])
sql = f"""
SELECT
m.id,
m.session_id,
m.role,
snippet(messages_fts, -1, '>>>', '<<<', '...', 40) AS snippet,
m.content,
m.timestamp,
m.tool_name,
s.source,
s.model,
s.started_at AS session_started
FROM messages_fts
JOIN messages m ON m.id = messages_fts.rowid
JOIN sessions s ON s.id = m.session_id
WHERE {where_sql}
{order_by_sql}
LIMIT ? OFFSET ?
"""
# CJK queries bypass the unicode61 FTS5 table. The default tokenizer
# splits CJK characters into individual tokens, so "大别山项目" becomes
# "大 AND 别 AND 山 AND 项 AND 目" — producing false positives and
# missing exact phrase matches.
#
# For queries with 3+ CJK characters, we use the trigram FTS5 table
# (indexed substring matching with ranking and snippets). For shorter
# CJK queries (1-2 chars), trigram can't match (it needs ≥9 UTF-8
# bytes = 3 CJK chars), so we fall back to LIKE.
is_cjk = self._contains_cjk(query)
if is_cjk:
raw_query = query.strip('"').strip()
cjk_count = self._count_cjk(raw_query)
# Per-token CJK length check (#20494): trigram needs >=3 CJK chars
# per token. A query like "广西 OR 桂林 OR 漓江" has cjk_count=6
# (>=3) but each individual token is only 2 chars — trigram returns 0.
# Route to LIKE when any non-operator CJK token is <3 CJK chars.
_tokens_for_check = [
t for t in raw_query.split()
if t.upper() not in {"AND", "OR", "NOT"} and self._contains_cjk(t)
]
_any_short_cjk = any(
self._count_cjk(t) < 3 for t in _tokens_for_check
)
_trigram_succeeded = False
# Tool rows are excluded from the trigram index (they're ~90% of
# message bytes and machine noise — see FTS_TRIGRAM_SQL). A CJK
# 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
if (
cjk_count >= 3
and not _any_short_cjk
and self._trigram_available
and not _wants_tool_rows
):
# Trigram FTS5 path — quote each non-operator token to handle
# FTS5 special chars (%, *, etc.) while preserving boolean
# operators (AND, OR, NOT) for multi-term queries.
tokens = raw_query.split()
parts = []
for tok in tokens:
if tok.upper() in {"AND", "OR", "NOT"}:
parts.append(tok)
else:
parts.append('"' + tok.replace('"', '""') + '"')
trigram_query = " ".join(parts)
tri_where = ["messages_fts_trigram MATCH ?"]
tri_params: list = [trigram_query]
if not include_inactive:
tri_where.append("(m.active = 1 OR m.compacted = 1)")
if source_filter is not None:
tri_where.append(f"s.source IN ({','.join('?' for _ in source_filter)})")
tri_params.extend(source_filter)
if exclude_sources is not None:
tri_where.append(f"s.source NOT IN ({','.join('?' for _ in exclude_sources)})")
tri_params.extend(exclude_sources)
if role_filter:
tri_where.append(f"m.role IN ({','.join('?' for _ in role_filter)})")
tri_params.extend(role_filter)
tri_sql = f"""
SELECT
m.id,
m.session_id,
m.role,
snippet(messages_fts_trigram, -1, '>>>', '<<<', '...', 40) AS snippet,
m.content,
m.timestamp,
m.tool_name,
s.source,
s.model,
s.started_at AS session_started
FROM messages_fts_trigram
JOIN messages m ON m.id = messages_fts_trigram.rowid
JOIN sessions s ON s.id = m.session_id
WHERE {' AND '.join(tri_where)}
{order_by_sql}
LIMIT ? OFFSET ?
"""
tri_params.extend([limit, offset])
try:
with self._lock:
tri_cursor = self._conn.execute(tri_sql, tri_params)
matches = [dict(row) for row in tri_cursor.fetchall()]
_trigram_succeeded = True
except sqlite3.OperationalError:
# Trigram query failed at runtime — fall through to LIKE.
pass
except sqlite3.DatabaseError as exc:
# Same corruption class the main FTS5 MATCH branch
# self-heals above: a corrupt trigram shadow table raises
# malformed / "fts5: corrupt structure record", which is a
# DatabaseError (parent of the OperationalError syntax arm
# caught first). Rebuild once outside the lock — the lock
# is released here so rebuild_fts() can re-acquire it —
# and retry the trigram query. If the rebuild is refused
# (already attempted / FTS disabled / different error
# class) or the retry fails again, fall through to the
# LIKE substring path, which reads only the canonical
# messages table, so CJK search stays available.
if self._try_runtime_fts_rebuild(exc):
try:
with self._lock:
tri_cursor = self._conn.execute(
tri_sql, tri_params
)
matches = [
dict(row) for row in tri_cursor.fetchall()
]
_trigram_succeeded = True
except sqlite3.DatabaseError:
logger.warning(
"Trigram FTS search still failing after "
"in-place rebuild; falling back to LIKE."
)
else:
logger.warning(
"Trigram FTS search hit a corruption error (%s) "
"and no in-place rebuild was possible; falling "
"back to LIKE.", exc,
)
if not _trigram_succeeded:
# Short / mixed CJK query, trigram unavailable, or trigram
# <3 CJK chars. Fall back to LIKE substring search.
# For multi-token OR queries (e.g. "广西 OR 桂林 OR 漓江"),
# build one LIKE condition per non-operator token so each term
# is matched independently (#20494).
non_op_tokens = [
t for t in raw_query.split()
if t.upper() not in {"AND", "OR", "NOT"}
] or [raw_query]
token_clauses = []
like_params: list = []
for tok in non_op_tokens:
esc = tok.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
token_clauses.append(
"(m.content LIKE ? ESCAPE '\\' OR m.tool_name LIKE ? ESCAPE '\\' OR m.tool_calls LIKE ? ESCAPE '\\')"
)
like_params += [f"%{esc}%", f"%{esc}%", f"%{esc}%"]
like_where = [f"({' OR '.join(token_clauses)})"]
if not include_inactive:
# Same visibility rule as the FTS5 paths: live rows and
# compaction-archived rows are discoverable; rewind/undo
# rows (active=0, compacted=0) are hidden (#38763).
like_where.append("(m.active = 1 OR m.compacted = 1)")
if source_filter is not None:
like_where.append(f"s.source IN ({','.join('?' for _ in source_filter)})")
like_params.extend(source_filter)
if exclude_sources is not None:
like_where.append(f"s.source NOT IN ({','.join('?' for _ in exclude_sources)})")
like_params.extend(exclude_sources)
if role_filter:
like_where.append(f"m.role IN ({','.join('?' for _ in role_filter)})")
like_params.extend(role_filter)
like_sql = f"""
SELECT m.id, m.session_id, m.role,
substr(m.content,
max(1, instr(m.content, ?) - 40),
120) AS snippet,
m.content, m.timestamp, m.tool_name,
s.source, s.model, s.started_at AS session_started
FROM messages m
JOIN sessions s ON s.id = m.session_id
WHERE {' AND '.join(like_where)}
ORDER BY m.timestamp DESC
LIMIT ? OFFSET ?
"""
like_params.extend([limit, offset])
# instr() for snippet uses first search token
like_params = [non_op_tokens[0]] + like_params
with self._lock:
like_cursor = self._conn.execute(like_sql, like_params)
matches = [dict(row) for row in like_cursor.fetchall()]
else:
try:
with self._lock:
cursor = self._conn.execute(sql, params)
matches = [dict(row) for row in cursor.fetchall()]
except sqlite3.OperationalError:
# FTS5 query syntax error despite sanitization — return empty
return []
except sqlite3.DatabaseError as exc:
# A corrupt FTS index raises the malformed / "fts5: corrupt
# structure record" class on the MATCH read, the same class the
# write path self-heals (#66296). OperationalError (query
# syntax) is a subclass caught above; this arm is the corruption
# parent. Rebuild the index in place once — the lock is released
# here, so rebuild_fts() can re-acquire it — and retry, so
# search self-heals for read-only sessions (cron/CLI history
# search) that never trigger a write to repair it first.
if not self._try_runtime_fts_rebuild(exc):
raise
with self._lock:
cursor = self._conn.execute(sql, params)
matches = [dict(row) for row in cursor.fetchall()]
# Deferred-rebuild supplement (schema v23): while the background
# backfill is pending, the FTS indexes only cover rows outside the
# (progress, high_water] gap. Top the results up with a bounded LIKE
# scan over just that id range so search never silently loses old
# messages mid-rebuild. The range shrinks as the backfill advances,
# so this cost decays to zero. The CJK LIKE-fallback path above
# already scans the whole base table and needs no supplement.
rebuild_status = self.fts_rebuild_status()
if rebuild_status is not None and len(matches) < limit:
try:
gap_matches = self._search_unindexed_gap(
query,
limit - len(matches),
include_inactive=include_inactive,
source_filter=source_filter,
exclude_sources=exclude_sources,
role_filter=role_filter,
)
seen_ids = {m["id"] for m in matches}
matches.extend(m for m in gap_matches if m["id"] not in seen_ids)
except sqlite3.OperationalError as exc:
logger.debug("Unindexed-gap supplement skipped: %s", exc)
# Add surrounding context (1 message before + after each match).
# Done outside the lock so we don't hold it across N sequential queries.
for match in matches:
try:
with self._lock:
ctx_cursor = self._conn.execute(
"""WITH target AS (
SELECT session_id, timestamp, id
FROM messages
WHERE id = ?
)
SELECT role, content
FROM (
SELECT m.id, m.timestamp, m.role, m.content
FROM messages m
JOIN target t ON t.session_id = m.session_id
WHERE (m.timestamp < t.timestamp)
OR (m.timestamp = t.timestamp AND m.id < t.id)
ORDER BY m.timestamp DESC, m.id DESC
LIMIT 1
)
UNION ALL
SELECT role, content
FROM messages
WHERE id = ?
UNION ALL
SELECT role, content
FROM (
SELECT m.id, m.timestamp, m.role, m.content
FROM messages m
JOIN target t ON t.session_id = m.session_id
WHERE (m.timestamp > t.timestamp)
OR (m.timestamp = t.timestamp AND m.id > t.id)
ORDER BY m.timestamp ASC, m.id ASC
LIMIT 1
)""",
(match["id"], match["id"]),
)
context_msgs = []
for r in ctx_cursor.fetchall():
raw = r["content"]
decoded = self._decode_content(raw)
# Multimodal context: render a compact text-only
# summary for search previews.
if isinstance(decoded, list):
text_parts = [
p.get("text", "") for p in decoded
if isinstance(p, dict) and p.get("type") == "text"
]
text = " ".join(t for t in text_parts if t).strip()
preview = text or "[multimodal content]"
elif isinstance(decoded, str):
preview = decoded
else:
preview = ""
context_msgs.append(
{"role": r["role"], "content": preview[:200]}
)
match["context"] = context_msgs
except Exception:
match["context"] = []
# Remove full content from result (snippet is enough, saves tokens)
for match in matches:
match.pop("content", None)
return matches
def _search_unindexed_gap(
self,
fts_query: str,
limit: int,
*,
include_inactive: bool = False,
source_filter: Optional[List[str]] = None,
exclude_sources: Optional[List[str]] = None,
role_filter: Optional[List[str]] = None,
) -> List[Dict[str, Any]]:
"""LIKE-scan the rows the deferred rebuild hasn't indexed yet.
Only touches ids in (fts_rebuild_progress, fts_rebuild_high_water] —
a range that shrinks to nothing as the backfill advances. The FTS
query is degraded to per-token substring terms (AND-joined; quoted
phrases kept whole), which is deliberately recall-over-precision:
temporary results beat silently missing ones mid-rebuild.
"""
status = self.fts_rebuild_status()
if status is None or limit <= 0:
return []
progress, high_water = status["indexed"], status["total"]
# Degrade the FTS query to LIKE terms: strip operators/wildcards,
# keep quoted phrases intact, AND the rest.
terms: List[str] = []
for raw_tok in re.findall(r'"[^"]+"|\S+', fts_query):
tok = raw_tok.strip('"').strip("*").strip()
if not tok or tok.upper() in {"AND", "OR", "NOT", "NEAR"}:
continue
terms.append(tok)
if not terms:
return []
where = ["m.id > ? AND m.id <= ?"]
params: list = [progress, high_water]
for term in terms:
esc = term.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
where.append(
"(m.content LIKE ? ESCAPE '\\' OR m.tool_name LIKE ? ESCAPE '\\' "
"OR m.tool_calls LIKE ? ESCAPE '\\')"
)
params += [f"%{esc}%"] * 3
if not include_inactive:
where.append("(m.active = 1 OR m.compacted = 1)")
if source_filter is not None:
where.append(f"s.source IN ({','.join('?' for _ in source_filter)})")
params.extend(source_filter)
if exclude_sources is not None:
where.append(f"s.source NOT IN ({','.join('?' for _ in exclude_sources)})")
params.extend(exclude_sources)
if role_filter:
where.append(f"m.role IN ({','.join('?' for _ in role_filter)})")
params.extend(role_filter)
sql = f"""
SELECT m.id, m.session_id, m.role,
substr(m.content,
max(1, instr(m.content, ?) - 40),
120) AS snippet,
m.content, m.timestamp, m.tool_name,
s.source, s.model, s.started_at AS session_started
FROM messages m
JOIN sessions s ON s.id = m.session_id
WHERE {' AND '.join(where)}
ORDER BY m.timestamp DESC
LIMIT ?
"""
params = [terms[0]] + params + [limit]
with self._lock:
rows = self._conn.execute(sql, params).fetchall()
return [dict(r) for r in rows]
def search_sessions_by_id(
self,
query: str,
limit: int = 20,
include_archived: bool = True,
) -> List[Dict[str, Any]]:
"""Search surfaced sessions by exact/prefix/substring session id.
Desktop search uses this alongside FTS message search so users can paste
a session id from logs, CLI output, or another Hermes surface and jump
straight to that conversation. Matching also checks ``_lineage_root_id``
for projected compression-chain tips, so an old root id still resolves to
the live continuation row.
"""
needle = (query or "").strip().lower()
if not needle or limit <= 0:
return []
# SQL-bounded: list_sessions_rich pushes the id LIKE filter into the
# query (matching the row's own id AND any id in its forward
# compression chain), so we only materialize matching rows instead of
# scanning every session. Fetch a small multiple of `limit` so the
# in-Python exact/prefix/substring ranking below has enough candidates
# to order, then truncate.
candidates = self.list_sessions_rich(
limit=max(limit * 4, limit),
offset=0,
include_archived=include_archived,
order_by_last_active=True,
id_query=needle,
)
def score(row: Dict[str, Any]) -> int:
ids = [str(row.get("id") or ""), str(row.get("_lineage_root_id") or "")]
normalized = [value.lower() for value in ids if value]
if any(value == needle for value in normalized):
return 0
if any(value.startswith(needle) for value in normalized):
return 1
return 2
ranked = sorted(
enumerate(candidates),
key=lambda item: (score(item[1]), item[0]),
)
return [row for _, row in ranked[:limit]]
def search_sessions(
self,
source: str = None,
limit: int = 20,
offset: int = 0,
) -> List[Dict[str, Any]]:
"""List sessions, optionally filtered by source.
Returns rows enriched with a computed ``last_active`` column (latest
message timestamp for the session, falling back to ``started_at``),
ordered by most-recently-used first.
"""
select_with_last_active = (
"SELECT s.*, COALESCE(m.last_active, s.started_at) AS last_active "
"FROM sessions s "
"LEFT JOIN ("
"SELECT session_id, MAX(timestamp) AS last_active "
"FROM messages GROUP BY session_id"
") m ON m.session_id = s.id "
)
with self._lock:
if source:
cursor = self._conn.execute(
f"{select_with_last_active}"
"WHERE s.source = ? "
"ORDER BY last_active DESC, s.started_at DESC, s.id DESC LIMIT ? OFFSET ?",
(source, limit, offset),
)
else:
cursor = self._conn.execute(
f"{select_with_last_active}"
"ORDER BY last_active DESC, s.started_at DESC, s.id DESC LIMIT ? OFFSET ?",
(limit, offset),
)
return [dict(row) for row in cursor.fetchall()]
# =========================================================================
# Utility
# =========================================================================
def session_count(
self,
source: str = None,
cwd_prefix: str = None,
min_message_count: int = 0,
include_archived: bool = False,
archived_only: bool = False,
exclude_children: bool = False,
exclude_sources: List[str] = None,
) -> int:
"""Count sessions, optionally filtered by source.
Pass ``exclude_children=True`` to count only the conversations that
``list_sessions_rich`` surfaces (root + branch sessions), hiding
sub-agent runs and compression continuations. Use it whenever the count
is paired with a ``list_sessions_rich`` page (e.g. sidebar "load more"
totals) so the total matches the number of listable rows — otherwise the
raw row count is inflated by children and "load more" never settles.
Pass ``exclude_sources`` to drop whole source classes from the count
(e.g. ``["cron"]`` so the recents "load more" total matches a
cron-excluded ``list_sessions_rich`` page and doesn't keep "load more"
stuck on for buried scheduler sessions).
"""
where_clauses = []
params = []
if exclude_children:
# Mirror list_sessions_rich's child-exclusion clause exactly so the
# count lines up with the rows: roots (no parent) plus branch
# children (parent ended with end_reason='branched').
where_clauses.append(_LISTABLE_CHILD_SQL)
where_clauses.append(f"{_delegate_from_json('s.model_config')} IS NULL")
if source:
where_clauses.append("s.source = ?")
params.append(source)
if exclude_sources:
placeholders = ",".join("?" for _ in exclude_sources)
where_clauses.append(f"s.source NOT IN ({placeholders})")
params.extend(exclude_sources)
if cwd_prefix:
clause, clause_params = _cwd_prefix_clause(cwd_prefix)
where_clauses.append(clause)
params.extend(clause_params)
if min_message_count > 0:
where_clauses.append("s.message_count >= ?")
params.append(min_message_count)
if archived_only:
where_clauses.append("s.archived = 1")
elif not include_archived:
where_clauses.append("s.archived = 0")
where_sql = f" WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
with self._lock:
cursor = self._conn.execute(f"SELECT COUNT(*) FROM sessions s{where_sql}", params)
return cursor.fetchone()[0]
def message_count(self, session_id: str = None) -> int:
"""Count messages, optionally for a specific session."""
with self._lock:
if session_id:
cursor = self._conn.execute(
"SELECT COUNT(*) FROM messages WHERE session_id = ?", (session_id,)
)
else:
cursor = self._conn.execute("SELECT COUNT(*) FROM messages")
return cursor.fetchone()[0]
def has_platform_message_id(
self, session_id: str, platform_message_id: str
) -> bool:
"""Check if a message with the given platform_message_id exists.
Uses the idx_messages_platform_msg_id partial index for efficient
lookup. Used by the gateway's transient-failure dedupe guard (#47237)
to skip re-persisting a user message that was already saved on a
prior retry of the same inbound platform message.
"""
with self._lock:
cursor = self._conn.execute(
"SELECT 1 FROM messages "
"WHERE session_id = ? AND platform_message_id = ? LIMIT 1",
(session_id, platform_message_id),
)
return cursor.fetchone() is not None
# =========================================================================
# Export and cleanup
# =========================================================================
def _is_branch_child_row(self, session: Dict[str, Any]) -> bool:
raw = session.get("model_config")
if not raw:
return False
try:
cfg = json.loads(raw) if isinstance(raw, str) else raw
except (TypeError, json.JSONDecodeError):
return False
return isinstance(cfg, dict) and cfg.get("_branched_from") is not None
def _is_compression_child_row(self, child: Dict[str, Any]) -> bool:
parent_id = child.get("parent_session_id")
if not parent_id or self._is_branch_child_row(child):
return False
parent = self.get_session(parent_id)
return bool(parent and parent.get("end_reason") == "compression")
def get_compression_lineage(self, session_id: str) -> List[str]:
"""Return compression ancestors through tip in chronological order."""
session = self.get_session(session_id)
if not session or self._is_branch_child_row(session):
return [session_id] if session else []
root = session
ancestors = {root["id"]}
while self._is_compression_child_row(root):
parent = self.get_session(root["parent_session_id"])
if not parent or parent["id"] in ancestors:
break
root = parent
ancestors.add(root["id"])
lineage = [root["id"]]
seen = {root["id"]}
current = root
while current.get("end_reason") == "compression":
with self._lock:
rows = self._conn.execute(
"""
SELECT * FROM sessions
WHERE parent_session_id = ?
ORDER BY started_at ASC
""",
(current["id"],),
).fetchall()
next_child = None
for row in rows:
candidate = dict(row)
if not self._is_branch_child_row(candidate):
next_child = candidate
break
if not next_child or next_child["id"] in seen:
break
lineage.append(next_child["id"])
seen.add(next_child["id"])
current = next_child
if current["id"] == session_id:
# Continue to include later compression tips only when the
# requested session itself was compacted.
continue
return lineage if session_id in lineage else [session_id]
def export_session(self, session_id: str) -> Optional[Dict[str, Any]]:
"""Export a single session with all its messages as a dict."""
session = self.get_session(session_id)
if not session:
return None
messages = self.get_messages(session_id)
return {**session, "messages": messages}
def export_session_lineage(self, session_id: str) -> Optional[Dict[str, Any]]:
"""Export a compression lineage as one logical session dict."""
lineage_ids = self.get_compression_lineage(session_id)
if not lineage_ids:
return None
segments = []
for sid in lineage_ids:
segment = self.export_session(sid)
if segment:
segments.append(segment)
if not segments:
return None
base = dict(segments[-1])
total_messages = sum(len(seg.get("messages") or []) for seg in segments)
base["segments"] = segments
base["lineage_session_ids"] = [seg["id"] for seg in segments]
base["message_count"] = total_messages
base["messages"] = [msg for seg in segments for msg in (seg.get("messages") or [])]
return base
def export_all(self, source: str = None) -> List[Dict[str, Any]]:
"""
Export all sessions (with messages) as a list of dicts.
Suitable for writing to a JSONL file for backup/analysis.
"""
sessions = self.search_sessions(source=source, limit=100000)
results = []
for session in sessions:
messages = self.get_messages(session["id"])
results.append({**session, "messages": messages})
return results
@staticmethod
def _import_text_or_none(value: Any, field: str) -> Optional[str]:
if value is None:
return None
if isinstance(value, str):
return value
raise ValueError(f"{field} must be a string")
@staticmethod
def _import_json_object_or_none(value: Any, field: str) -> Optional[str]:
if value is None:
return None
if isinstance(value, str):
try:
parsed = json.loads(value)
except json.JSONDecodeError as exc:
raise ValueError(f"{field} must be valid JSON") from exc
if not isinstance(parsed, dict):
raise ValueError(f"{field} must be a JSON object")
return value
if not isinstance(value, dict):
raise ValueError(f"{field} must be a JSON object")
try:
return json.dumps(value)
except (TypeError, ValueError) as exc:
raise ValueError(f"{field} must be JSON serializable") from exc
@staticmethod
def _float_or_none(value: Any) -> Optional[float]:
if value is None:
return None
try:
return float(value)
except (TypeError, ValueError):
return None
@staticmethod
def _import_int_or_none(value: Any, field: str) -> Optional[int]:
if value is None:
return None
try:
return int(value)
except (TypeError, ValueError) as exc:
raise ValueError(f"{field} must be an integer") from exc
@staticmethod
def _int_or_default(value: Any, default: int = 0) -> int:
if value is None:
return default
try:
return int(value)
except (TypeError, ValueError):
return default
@staticmethod
def _reasoning_json_value(value: Any) -> Any:
if not isinstance(value, str):
return value
try:
return json.loads(value)
except (json.JSONDecodeError, TypeError):
return value
@staticmethod
def _import_error(index: int, session_id: str, error: str) -> Dict[str, Any]:
item: Dict[str, Any] = {"index": index, "error": error}
if session_id:
item["session_id"] = session_id
return item
def import_sessions(self, sessions: List[Dict[str, Any]]) -> Dict[str, Any]:
"""Import sessions exported by :meth:`export_session` or ``export_all``.
Existing session IDs are skipped. Imported child sessions keep their
parent only when that parent already exists or is included in the same
import payload; otherwise the child is detached so partial imports don't
fail foreign-key validation. Gateway routing, handoff, rewind, and other
live runtime state are intentionally reset: this restores conversation
history, not ownership of a live channel or process.
"""
if not isinstance(sessions, list):
raise ValueError("sessions must be a list")
if len(sessions) > self._IMPORT_MAX_SESSIONS:
raise ValueError(
f"sessions must contain at most {self._IMPORT_MAX_SESSIONS} entries"
)
normalized: List[Dict[str, Any]] = []
errors: List[Dict[str, Any]] = []
seen_ids: set[str] = set()
total_messages = 0
total_bytes = 0
session_text_fields = (
"source",
"user_id",
"model",
"system_prompt",
"end_reason",
"cwd",
"git_branch",
"git_repo_root",
"billing_provider",
"billing_base_url",
"billing_mode",
"cost_status",
"cost_source",
"pricing_version",
"title",
)
message_text_fields = (
"role",
"tool_call_id",
"tool_name",
"effect_disposition",
"finish_reason",
"reasoning",
"reasoning_content",
"platform_message_id",
"message_id",
)
for index, raw in enumerate(sessions):
if not isinstance(raw, dict):
errors.append(self._import_error(index, "", "session must be an object"))
continue
session_id = str(raw.get("id") or "").strip()
if not session_id:
errors.append(self._import_error(index, "", "session id is required"))
continue
if session_id in seen_ids:
errors.append(self._import_error(index, session_id, "duplicate session id"))
continue
messages = raw.get("messages") or []
if not isinstance(messages, list):
errors.append(self._import_error(index, session_id, "messages must be a list"))
continue
if len(messages) > self._IMPORT_MAX_MESSAGES_PER_SESSION:
errors.append(
self._import_error(
index,
session_id,
"messages exceeds the per-session import limit",
)
)
continue
if any(not isinstance(msg, dict) for msg in messages):
errors.append(
self._import_error(
index,
session_id,
"messages must contain only objects",
)
)
continue
try:
session_bytes = len(
json.dumps(raw, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
)
except (TypeError, ValueError):
errors.append(
self._import_error(index, session_id, "session must be JSON serializable")
)
continue
if session_bytes > self._IMPORT_MAX_SESSION_BYTES:
errors.append(
self._import_error(index, session_id, "session exceeds the import size limit")
)
continue
total_bytes += session_bytes
if total_bytes > self._IMPORT_MAX_TOTAL_BYTES:
errors.append(
self._import_error(index, session_id, "import exceeds the total size limit")
)
continue
try:
clean_session = dict(raw)
clean_session["id"] = session_id
clean_session["model_config"] = self._import_json_object_or_none(
clean_session.get("model_config"), "model_config"
)
clean_session["parent_session_id"] = self._import_text_or_none(
clean_session.get("parent_session_id"), "parent_session_id"
)
for field in session_text_fields:
clean_session[field] = self._import_text_or_none(
clean_session.get(field), field
)
clean_messages: List[Dict[str, Any]] = []
for message_index, message in enumerate(messages):
clean_message = dict(message)
role = clean_message.get("role")
if not isinstance(role, str) or not role:
raise ValueError(f"messages[{message_index}].role must be a non-empty string")
for field in message_text_fields:
if field == "role":
continue
clean_message[field] = self._import_text_or_none(
clean_message.get(field), field
)
clean_message["token_count"] = self._import_int_or_none(
clean_message.get("token_count"), "token_count"
)
clean_messages.append(clean_message)
except ValueError as exc:
errors.append(self._import_error(index, session_id, str(exc)))
continue
total_messages += len(clean_messages)
if total_messages > self._IMPORT_MAX_TOTAL_MESSAGES:
errors.append(
self._import_error(
index,
session_id,
"messages exceeds the total import limit",
)
)
continue
seen_ids.add(session_id)
normalized.append(
{"index": index, "session": clean_session, "messages": clean_messages}
)
if errors:
return {
"ok": False,
"imported": 0,
"skipped": 0,
"detached": 0,
"errors": errors,
}
def _do(conn):
imported_ids: List[str] = []
skipped_ids: List[str] = []
parent_updates: List[tuple[str, str]] = []
detached = 0
for item in normalized:
raw = item["session"]
messages = item["messages"]
session_id = str(raw.get("id") or "").strip()
exists = conn.execute(
"SELECT 1 FROM sessions WHERE id = ? LIMIT 1",
(session_id,),
).fetchone()
if exists:
skipped_ids.append(session_id)
continue
started_at = self._float_or_none(raw.get("started_at"))
if started_at is None:
started_at = time.time()
archived = 1 if raw.get("archived") else 0
conn.execute(
"""INSERT INTO sessions (
id, source, user_id, model, model_config, system_prompt,
parent_session_id, started_at, ended_at, end_reason,
message_count, tool_call_count, input_tokens, output_tokens,
cache_read_tokens, cache_write_tokens, reasoning_tokens,
cwd, git_branch, git_repo_root,
billing_provider, billing_base_url, billing_mode,
estimated_cost_usd, actual_cost_usd, cost_status, cost_source,
pricing_version, title, api_call_count, archived
)
VALUES (
:id, :source, :user_id, :model, :model_config,
:system_prompt, NULL, :started_at, :ended_at,
:end_reason, 0, 0, :input_tokens, :output_tokens,
:cache_read_tokens, :cache_write_tokens,
:reasoning_tokens, :cwd, :git_branch, :git_repo_root,
:billing_provider, :billing_base_url, :billing_mode,
:estimated_cost_usd, :actual_cost_usd, :cost_status,
:cost_source, :pricing_version, :title,
:api_call_count, :archived
)""",
{
"id": session_id,
"source": str(raw.get("source") or "import"),
"user_id": raw.get("user_id"),
"model": raw.get("model"),
"model_config": raw.get("model_config"),
"system_prompt": raw.get("system_prompt"),
"started_at": started_at,
"ended_at": self._float_or_none(raw.get("ended_at")),
"end_reason": raw.get("end_reason"),
"input_tokens": self._int_or_default(raw.get("input_tokens")),
"output_tokens": self._int_or_default(raw.get("output_tokens")),
"cache_read_tokens": self._int_or_default(
raw.get("cache_read_tokens")
),
"cache_write_tokens": self._int_or_default(
raw.get("cache_write_tokens")
),
"reasoning_tokens": self._int_or_default(
raw.get("reasoning_tokens")
),
"cwd": raw.get("cwd"),
"git_branch": raw.get("git_branch"),
"git_repo_root": raw.get("git_repo_root"),
"billing_provider": raw.get("billing_provider"),
"billing_base_url": raw.get("billing_base_url"),
"billing_mode": raw.get("billing_mode"),
"estimated_cost_usd": self._float_or_none(
raw.get("estimated_cost_usd")
),
"actual_cost_usd": self._float_or_none(
raw.get("actual_cost_usd")
),
"cost_status": raw.get("cost_status"),
"cost_source": raw.get("cost_source"),
"pricing_version": raw.get("pricing_version"),
"title": raw.get("title"),
"api_call_count": self._int_or_default(raw.get("api_call_count")),
"archived": archived,
},
)
sanitized_messages: List[Dict[str, Any]] = []
for msg in messages:
clean = dict(msg)
for key in (
"reasoning_details",
"codex_reasoning_items",
"codex_message_items",
):
clean[key] = self._reasoning_json_value(clean.get(key))
sanitized_messages.append(clean)
total_messages, total_tool_calls = self._insert_message_rows(
conn,
session_id,
sanitized_messages,
)
conn.execute(
"UPDATE sessions SET message_count = ?, tool_call_count = ? WHERE id = ?",
(total_messages, total_tool_calls, session_id),
)
parent_id = str(raw.get("parent_session_id") or "").strip()
if parent_id:
parent_updates.append((session_id, parent_id))
imported_ids.append(session_id)
parent_by_child = dict(parent_updates)
def _would_create_cycle(session_id: str, parent_id: str) -> bool:
seen = {session_id}
current = parent_id
while current:
if current in seen:
return True
seen.add(current)
if current in parent_by_child:
current = parent_by_child[current]
continue
row = conn.execute(
"SELECT parent_session_id FROM sessions WHERE id = ? LIMIT 1",
(current,),
).fetchone()
if row is None:
return False
current = row["parent_session_id"]
return False
for session_id, parent_id in parent_updates:
parent_exists = conn.execute(
"SELECT 1 FROM sessions WHERE id = ? LIMIT 1",
(parent_id,),
).fetchone()
if parent_exists and not _would_create_cycle(session_id, parent_id):
conn.execute(
"UPDATE sessions SET parent_session_id = ? WHERE id = ?",
(parent_id, session_id),
)
else:
# Drop only the closing edge. Later entries can still attach
# to this now-root session, preserving the acyclic portion
# of a malformed imported lineage.
parent_by_child.pop(session_id, None)
detached += 1
return {
"ok": True,
"imported": len(imported_ids),
"skipped": len(skipped_ids),
"detached": detached,
"imported_ids": imported_ids,
"skipped_ids": skipped_ids,
"errors": [],
}
return self._execute_write(_do)
def clear_messages(self, session_id: str) -> None:
"""Delete all messages for a session and reset its counters."""
def _do(conn):
conn.execute(
"DELETE FROM messages WHERE session_id = ?", (session_id,)
)
conn.execute(
"UPDATE sessions SET message_count = 0, tool_call_count = 0 WHERE id = ?",
(session_id,),
)
self._execute_write(_do)
@staticmethod
def _remove_session_files(sessions_dir: Optional[Path], session_id: str) -> None:
"""Remove on-disk transcript files for a session.
Cleans up ``{session_id}.json``, ``{session_id}.jsonl``, and any
``request_dump_{session_id}_*.json`` files left by the gateway.
Silently skips files that don't exist and swallows OSError so a
filesystem hiccup never blocks a DB operation.
"""
if sessions_dir is None:
return
for suffix in (".json", ".jsonl"):
p = sessions_dir / f"{session_id}{suffix}"
try:
p.unlink(missing_ok=True)
except OSError:
pass
# request_dump files use session_id as a prefix component
try:
for p in sessions_dir.glob(f"request_dump_{session_id}_*.json"):
try:
p.unlink(missing_ok=True)
except OSError:
pass
except OSError:
pass
def delete_session(
self,
session_id: str,
sessions_dir: Optional[Path] = None,
) -> bool:
"""Delete a session and all its messages.
Delegate subagent children (``model_config._delegate_from``) are
cascade-deleted with the parent so they never resurface in session
pickers as orphaned rows. Branch / compression children are orphaned
(``parent_session_id → NULL``) so they remain accessible independently.
When *sessions_dir* is provided, also removes on-disk transcript
files (``.json`` / ``.jsonl`` / ``request_dump_*``) for every deleted
session. Returns True if the session was found and deleted.
"""
removed_delegate_ids: List[str] = []
def _do(conn):
cursor = conn.execute(
"SELECT COUNT(*) FROM sessions WHERE id = ?", (session_id,)
)
if cursor.fetchone()[0] == 0:
return False
removed_delegate_ids.extend(_delete_delegate_children(conn, [session_id]))
# Orphan remaining child sessions (branches, etc.) so FK is satisfied.
conn.execute(
"UPDATE sessions SET parent_session_id = NULL "
"WHERE parent_session_id = ?",
(session_id,),
)
conn.execute("DELETE FROM messages WHERE session_id = ?", (session_id,))
conn.execute("DELETE FROM sessions WHERE id = ?", (session_id,))
return True
deleted = self._execute_write(_do)
if deleted:
for delegate_id in removed_delegate_ids:
self._remove_session_files(sessions_dir, delegate_id)
self._remove_session_files(sessions_dir, session_id)
return bool(deleted)
def delete_session_if_empty(
self,
session_id: str,
sessions_dir: Optional[Path] = None,
) -> bool:
"""Delete *session_id* only when it never gained resumable content.
A session is considered empty when it has no messages and no
user-assigned title. Used by CLI exit / session-rotation paths so
immediately-started-and-quit sessions don't pile up in ``/resume``
and ``hermes sessions list`` output. (Pattern ported from
google-gemini/gemini-cli#27770.)
The emptiness check and delete run in one transaction, so a message
flushed concurrently by another writer can't be lost. Sessions with
children (delegate subagent runs) are preserved — a parent that
spawned work is not "empty" even if its own transcript never
flushed. Returns True if the session was deleted.
"""
def _do(conn):
cursor = conn.execute(
"""
DELETE FROM sessions
WHERE id = ?
AND title IS NULL
AND NOT EXISTS (
SELECT 1 FROM messages WHERE messages.session_id = sessions.id
)
AND NOT EXISTS (
SELECT 1 FROM sessions child
WHERE child.parent_session_id = sessions.id
)
""",
(session_id,),
)
return cursor.rowcount > 0
deleted = self._execute_write(_do)
if deleted:
self._remove_session_files(sessions_dir, session_id)
return bool(deleted)
def delete_sessions(
self,
session_ids: List[str],
sessions_dir: Optional[Path] = None,
) -> int:
"""Delete every session in *session_ids* in a single transaction.
Backs the dashboard's bulk-select-then-delete flow on the
sessions page (``POST /api/sessions/bulk-delete``). Mirrors the
single-session :meth:`delete_session` contract per row:
* Unknown IDs are silently skipped (no 404) — selection state
in the UI can race against another tab's delete, and we'd
rather succeed-on-the-rest than fail-the-whole-batch.
* Delegate subagent children (``model_config._delegate_from``) are
cascade-deleted with their parent; branch children are orphaned
(``parent_session_id → NULL``) so they stay accessible.
* Messages and the session row both go in one
``_execute_write`` call so a partial failure can't leave the
DB in a "messages gone but session row still there" state.
* On-disk transcript / ``request_dump_*`` files are cleaned up
outside the DB transaction when *sessions_dir* is provided,
matching :meth:`prune_sessions` and
:meth:`delete_empty_sessions`.
Returns the count of sessions that actually existed and were
deleted (may be less than ``len(session_ids)`` if some IDs were
already gone).
"""
if not session_ids:
return 0
# Dedup + drop any non-string entries up-front. Avoids
# double-counting in the WHERE-IN list and protects against
# callers that pass a list with stray ``None`` values.
unique_ids = list({sid for sid in session_ids if isinstance(sid, str) and sid})
if not unique_ids:
return 0
removed_ids: list[str] = []
removed_delegate_ids: list[str] = []
def _do(conn):
placeholders = ",".join("?" * len(unique_ids))
# First, filter to IDs that actually exist — we want to
# return the real deleted count, not the input length.
cursor = conn.execute(
f"SELECT id FROM sessions WHERE id IN ({placeholders})",
unique_ids,
)
existing = [row["id"] for row in cursor.fetchall()]
if not existing:
return 0
existing_placeholders = ",".join("?" * len(existing))
removed_delegate_ids.extend(_delete_delegate_children(conn, existing))
# Orphan remaining children whose parent is in the kill list so the
# FK constraint stays satisfied. Pin children whose parent
# is itself in the kill list rather than NULL-ing parents
# of survivors — the IN list on ``parent_session_id`` does
# exactly this.
conn.execute(
f"UPDATE sessions SET parent_session_id = NULL "
f"WHERE parent_session_id IN ({existing_placeholders})",
existing,
)
conn.execute(
f"DELETE FROM messages WHERE session_id IN ({existing_placeholders})",
existing,
)
conn.execute(
f"DELETE FROM sessions WHERE id IN ({existing_placeholders})",
existing,
)
removed_ids.extend(existing)
return len(existing)
count = self._execute_write(_do)
for sid in removed_delegate_ids:
self._remove_session_files(sessions_dir, sid)
for sid in removed_ids:
self._remove_session_files(sessions_dir, sid)
return count
def count_empty_sessions(self) -> int:
"""Return the count of empty, non-active, non-archived sessions.
"Empty" = ``message_count = 0`` AND the session has ended
(``ended_at IS NOT NULL``) AND is not archived. The ``ended_at``
guard matches the safety contract used by :meth:`prune_sessions`:
only ended sessions are candidates for bulk deletion, so a freshly
spawned session whose first message hasn't landed yet — or one
held open by the live agent — is never sniped out from under
the runtime.
Backs the ``GET /api/sessions/empty/count`` endpoint that lets the
web dashboard hide its "Delete empty" button when there's nothing
to clean up, and pre-populate the confirm dialog with the actual
count.
"""
with self._lock:
cursor = self._conn.execute(
"SELECT COUNT(*) FROM sessions "
"WHERE message_count = 0 "
"AND ended_at IS NOT NULL "
"AND archived = 0"
)
return cursor.fetchone()[0]
def delete_empty_sessions(
self,
sessions_dir: Optional[Path] = None,
) -> int:
"""Delete every empty, ended, non-archived session.
Mirrors :meth:`prune_sessions`' transactional shape:
* Selects candidate IDs first (``message_count = 0`` AND
``ended_at IS NOT NULL`` AND ``archived = 0``) so we never
touch a live session or one the user deliberately archived.
* Orphans any child whose parent is in the kill list — children
of an empty parent are kept and re-parented to ``NULL`` rather
than cascade-deleted, matching ``delete_session`` /
``prune_sessions`` semantics so branch/subagent transcripts
survive an inadvertent parent cleanup.
* Deletes the rows in a single ``_execute_write`` callback so
the operation is atomic — a partial failure (e.g. SIGKILL
mid-loop) doesn't leave the DB in a "messages-deleted but
session-row-still-there" half-state.
* Cleans up on-disk transcript files (``.json`` / ``.jsonl`` /
``request_dump_*``) outside the DB transaction when
``sessions_dir`` is provided. Empty sessions don't typically
have transcript files, but the gateway can leave a stub
``request_dump_*`` if it crashed before the first reply —
so we still sweep, matching ``prune_sessions``.
Returns the number of sessions deleted.
"""
removed_ids: list[str] = []
def _do(conn):
cursor = conn.execute(
"SELECT id FROM sessions "
"WHERE message_count = 0 "
"AND ended_at IS NOT NULL "
"AND archived = 0"
)
session_ids = {row["id"] for row in cursor.fetchall()}
if not session_ids:
return 0
placeholders = ",".join("?" * len(session_ids))
conn.execute(
f"UPDATE sessions SET parent_session_id = NULL "
f"WHERE parent_session_id IN ({placeholders})",
list(session_ids),
)
for sid in session_ids:
# DELETE FROM messages is paranoia — by construction
# these rows have ``message_count = 0`` — but if a
# bookkeeping bug ever lets the counter drift below the
# real row count, we still leave a clean FK state.
conn.execute(
"DELETE FROM messages WHERE session_id = ?", (sid,)
)
conn.execute("DELETE FROM sessions WHERE id = ?", (sid,))
removed_ids.append(sid)
return len(session_ids)
count = self._execute_write(_do)
for sid in removed_ids:
self._remove_session_files(sessions_dir, sid)
return count
@staticmethod
def _prune_filter_where(
*,
started_before: Optional[float] = None,
started_after: Optional[float] = None,
source: Optional[str] = None,
title_like: Optional[str] = None,
end_reason: Optional[str] = None,
cwd_prefix: Optional[str] = None,
min_messages: Optional[int] = None,
max_messages: Optional[int] = None,
archived: Optional[bool] = None,
model_like: Optional[str] = None,
provider: Optional[str] = None,
user_id: Optional[str] = None,
chat_id: Optional[str] = None,
chat_type: Optional[str] = None,
branch_like: Optional[str] = None,
min_tokens: Optional[int] = None,
max_tokens: Optional[int] = None,
min_cost: Optional[float] = None,
max_cost: Optional[float] = None,
min_tool_calls: Optional[int] = None,
max_tool_calls: Optional[int] = None,
) -> Tuple[str, list]:
"""Build the shared WHERE clause for bulk prune/archive selection.
All filters AND together. Only ended sessions are ever candidates
(``ended_at IS NOT NULL``) so a live session is never selected.
``archived`` is a tri-state: ``None`` = both, ``True`` = only
archived rows, ``False`` = only unarchived rows.
String matching conventions: ``model_like`` / ``branch_like`` /
``title_like`` are case-insensitive substring matches (model slugs
and branch names vary in prefix format); ``provider`` / ``user_id``
/ ``chat_id`` / ``chat_type`` / ``source`` / ``end_reason`` are
exact (case-insensitive for provider). Token bounds apply to
``input_tokens + output_tokens``; cost bounds apply to
``COALESCE(actual_cost_usd, estimated_cost_usd)``.
The clause references the ``s`` table alias — callers must select
``FROM sessions s``.
"""
clauses = ["s.ended_at IS NOT NULL"]
params: list = []
if started_before is not None:
clauses.append("s.started_at < ?")
params.append(started_before)
if started_after is not None:
clauses.append("s.started_at >= ?")
params.append(started_after)
if source:
clauses.append("s.source = ?")
params.append(source)
if title_like:
clauses.append("LOWER(COALESCE(s.title, '')) LIKE ?")
params.append(f"%{title_like.lower()}%")
if end_reason:
clauses.append("s.end_reason = ?")
params.append(end_reason)
if cwd_prefix:
clause, clause_params = _cwd_prefix_clause(cwd_prefix)
clauses.append(clause)
params.extend(clause_params)
if min_messages is not None:
clauses.append("s.message_count >= ?")
params.append(min_messages)
if max_messages is not None:
clauses.append("s.message_count <= ?")
params.append(max_messages)
if model_like:
clauses.append("LOWER(COALESCE(s.model, '')) LIKE ?")
params.append(f"%{model_like.lower()}%")
if provider:
clauses.append("LOWER(COALESCE(s.billing_provider, '')) = ?")
params.append(provider.lower())
if user_id:
clauses.append("s.user_id = ?")
params.append(user_id)
if chat_id:
clauses.append("s.chat_id = ?")
params.append(chat_id)
if chat_type:
clauses.append("s.chat_type = ?")
params.append(chat_type)
if branch_like:
clauses.append("LOWER(COALESCE(s.git_branch, '')) LIKE ?")
params.append(f"%{branch_like.lower()}%")
if min_tokens is not None:
clauses.append(
"(COALESCE(s.input_tokens, 0) + COALESCE(s.output_tokens, 0)) >= ?"
)
params.append(min_tokens)
if max_tokens is not None:
clauses.append(
"(COALESCE(s.input_tokens, 0) + COALESCE(s.output_tokens, 0)) <= ?"
)
params.append(max_tokens)
if min_cost is not None:
clauses.append(
"COALESCE(s.actual_cost_usd, s.estimated_cost_usd, 0) >= ?"
)
params.append(min_cost)
if max_cost is not None:
clauses.append(
"COALESCE(s.actual_cost_usd, s.estimated_cost_usd, 0) <= ?"
)
params.append(max_cost)
if min_tool_calls is not None:
clauses.append("COALESCE(s.tool_call_count, 0) >= ?")
params.append(min_tool_calls)
if max_tool_calls is not None:
clauses.append("COALESCE(s.tool_call_count, 0) <= ?")
params.append(max_tool_calls)
if archived is True:
clauses.append("s.archived = 1")
elif archived is False:
clauses.append("s.archived = 0")
return " AND ".join(clauses), params
def list_prune_candidates(
self,
older_than_days: Optional[float] = None,
source: str = None,
**filters,
) -> List[Dict[str, Any]]:
"""Return the sessions a matching :meth:`prune_sessions` /
:meth:`archive_sessions` call would touch, without modifying anything.
Backs ``--dry-run`` and pre-confirmation counts. Accepts the same
keyword filters as :meth:`_prune_filter_where` (unknown names raise
``TypeError`` there). Rows are ordered oldest-first and carry
``id, source, title, model, started_at, ended_at, message_count,
archived``.
"""
if filters.get("started_before") is None and older_than_days is not None:
filters["started_before"] = time.time() - (older_than_days * 86400)
where, params = self._prune_filter_where(source=source, **filters)
with self._lock:
cursor = self._conn.execute(
f"""SELECT s.id, s.source, s.title, s.model, s.started_at,
s.ended_at, s.message_count, s.archived
FROM sessions s WHERE {where}
ORDER BY s.started_at ASC""",
params,
)
return [dict(row) for row in cursor.fetchall()]
def archive_sessions(
self,
older_than_days: Optional[float] = None,
source: str = None,
**filters,
) -> int:
"""Bulk-archive (soft-hide) every session matching the filters.
Same filter surface as :meth:`prune_sessions`, but instead of deleting
rows it flips ``archived = 1`` via :meth:`set_session_archived` so
each match's compression lineage is archived as a unit (an unarchived
compression root would otherwise resurrect the conversation in
Desktop's projected list). Nothing is deleted; messages and transcript
files are untouched. Returns the number of sessions matched.
``archived`` defaults to ``False`` here (only select rows not yet
archived) so repeat runs are idempotent no-ops.
"""
filters.setdefault("archived", False)
rows = self.list_prune_candidates(
older_than_days=older_than_days, source=source, **filters
)
for row in rows:
self.set_session_archived(row["id"], True)
return len(rows)
def prune_sessions(
self,
older_than_days: Optional[float] = 90,
source: str = None,
sessions_dir: Optional[Path] = None,
**filters,
) -> int:
"""Delete sessions matching the filters. Returns count deleted.
Default behavior (no keyword filters) is unchanged: delete ended
sessions older than ``older_than_days`` days, optionally restricted
to ``source``. Additional keyword filters AND together — the full
set is defined by :meth:`_prune_filter_where`:
* ``started_before`` / ``started_after`` — epoch bounds on
``started_at``. ``started_before`` overrides ``older_than_days``;
pass ``older_than_days=None`` for no upper age bound (e.g. when
only pruning a recent window via ``started_after``).
* ``title_like`` / ``model_like`` / ``branch_like`` —
case-insensitive substring matches.
* ``end_reason`` / ``provider`` / ``user_id`` / ``chat_id`` /
``chat_type`` — exact matches (provider case-insensitive, against
``billing_provider``).
* ``cwd_prefix`` — session cwd equals or is under this path.
* ``min_messages`` / ``max_messages`` — bounds on message_count.
* ``min_tokens`` / ``max_tokens`` — bounds on input+output tokens.
* ``min_cost`` / ``max_cost`` — bounds on USD cost
(actual, falling back to estimated).
* ``min_tool_calls`` / ``max_tool_calls`` — bounds on tool_call_count.
* ``archived`` — tri-state: None = both (default), True = only
archived, False = only unarchived.
Only prunes ended sessions (not active ones). Child sessions outside
the prune window are orphaned (parent_session_id set to NULL) rather
than cascade-deleted. When *sessions_dir* is provided, also removes
on-disk transcript files (``.json`` / ``.jsonl`` /
``request_dump_*``) for every pruned session, outside the DB
transaction.
"""
if filters.get("started_before") is None and older_than_days is not None:
filters["started_before"] = time.time() - (older_than_days * 86400)
where, where_params = self._prune_filter_where(source=source, **filters)
removed_ids: list[str] = []
def _do(conn):
cursor = conn.execute(
f"SELECT s.id FROM sessions s WHERE {where}", where_params
)
session_ids = {row["id"] for row in cursor.fetchall()}
if not session_ids:
return 0
# Orphan any sessions whose parent is about to be deleted
placeholders = ",".join("?" * len(session_ids))
conn.execute(
f"UPDATE sessions SET parent_session_id = NULL "
f"WHERE parent_session_id IN ({placeholders})",
list(session_ids),
)
for sid in session_ids:
conn.execute("DELETE FROM messages WHERE session_id = ?", (sid,))
conn.execute("DELETE FROM sessions WHERE id = ?", (sid,))
removed_ids.append(sid)
return len(session_ids)
count = self._execute_write(_do)
# Clean up on-disk files outside the DB transaction
for sid in removed_ids:
self._remove_session_files(sessions_dir, sid)
return count
# ── Meta key/value (for scheduler bookkeeping) ──
def get_meta(self, key: str) -> Optional[str]:
"""Read a value from the state_meta key/value store."""
with self._lock:
row = self._conn.execute(
"SELECT value FROM state_meta WHERE key = ?", (key,)
).fetchone()
if row is None:
return None
return row["value"] if isinstance(row, sqlite3.Row) else row[0]
def set_meta(
self, key: str, value: str, *, cursor: Optional[sqlite3.Cursor] = None
) -> None:
"""Write a value to the state_meta key/value store.
When ``cursor`` is provided the write is issued on that cursor
inline (used during ``_init_schema``, which already holds an open
transaction — routing through ``_execute_write`` there would nest
BEGIN IMMEDIATE and deadlock). Otherwise a normal write transaction
is used.
"""
if cursor is not None:
cursor.execute(
"INSERT INTO state_meta (key, value) VALUES (?, ?) "
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
(key, value),
)
return
def _do(conn):
conn.execute(
"INSERT INTO state_meta (key, value) VALUES (?, ?) "
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
(key, value),
)
self._execute_write(_do)
def apply_telegram_topic_migration(self) -> None:
"""Create Telegram DM topic-mode tables on explicit /topic opt-in.
This migration is deliberately not part of automatic SessionDB startup
reconciliation. Operators must be able to upgrade Hermes, keep the old
Telegram bot behavior running, and only mutate topic-mode state when the
user executes /topic to opt into the feature.
Schema versions:
v1 — initial shape (no ON DELETE CASCADE on session_id FK)
v2 — session_id FK gets ON DELETE CASCADE so session pruning
automatically clears bindings.
"""
def _do(conn):
conn.executescript(
"""
CREATE TABLE IF NOT EXISTS telegram_dm_topic_mode (
chat_id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
activated_at REAL NOT NULL,
updated_at REAL NOT NULL,
has_topics_enabled INTEGER,
allows_users_to_create_topics INTEGER,
capability_checked_at REAL,
intro_message_id TEXT,
pinned_message_id TEXT
);
CREATE TABLE IF NOT EXISTS telegram_dm_topic_bindings (
chat_id TEXT NOT NULL,
thread_id TEXT NOT NULL,
user_id TEXT NOT NULL,
session_key TEXT NOT NULL,
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
managed_mode TEXT NOT NULL DEFAULT 'auto',
linked_at REAL NOT NULL,
updated_at REAL NOT NULL,
PRIMARY KEY (chat_id, thread_id)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_telegram_dm_topic_bindings_session
ON telegram_dm_topic_bindings(session_id);
CREATE INDEX IF NOT EXISTS idx_telegram_dm_topic_bindings_user
ON telegram_dm_topic_bindings(user_id, chat_id);
"""
)
# v1 → v2: rebuild telegram_dm_topic_bindings if its session_id FK
# lacks ON DELETE CASCADE. SQLite can't ALTER a foreign key, so we
# rebuild the table. Only runs once per DB (version gate).
current = conn.execute(
"SELECT value FROM state_meta WHERE key = ?",
("telegram_dm_topic_schema_version",),
).fetchone()
current_version = int(current[0]) if current and str(current[0]).isdigit() else 0
if current_version < 2:
fk_rows = conn.execute(
"PRAGMA foreign_key_list('telegram_dm_topic_bindings')"
).fetchall()
needs_rebuild = any(
row[2] == "sessions" and (row[6] or "") != "CASCADE"
for row in fk_rows
)
if needs_rebuild:
conn.executescript(
"""
CREATE TABLE telegram_dm_topic_bindings_new (
chat_id TEXT NOT NULL,
thread_id TEXT NOT NULL,
user_id TEXT NOT NULL,
session_key TEXT NOT NULL,
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
managed_mode TEXT NOT NULL DEFAULT 'auto',
linked_at REAL NOT NULL,
updated_at REAL NOT NULL,
PRIMARY KEY (chat_id, thread_id)
);
INSERT INTO telegram_dm_topic_bindings_new
SELECT chat_id, thread_id, user_id, session_key,
session_id, managed_mode, linked_at, updated_at
FROM telegram_dm_topic_bindings;
DROP TABLE telegram_dm_topic_bindings;
ALTER TABLE telegram_dm_topic_bindings_new
RENAME TO telegram_dm_topic_bindings;
CREATE UNIQUE INDEX idx_telegram_dm_topic_bindings_session
ON telegram_dm_topic_bindings(session_id);
CREATE INDEX idx_telegram_dm_topic_bindings_user
ON telegram_dm_topic_bindings(user_id, chat_id);
"""
)
conn.execute(
"INSERT INTO state_meta (key, value) VALUES (?, ?) "
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
("telegram_dm_topic_schema_version", "2"),
)
self._execute_write(_do)
def enable_telegram_topic_mode(
self,
*,
chat_id: str,
user_id: str,
has_topics_enabled: Optional[bool] = None,
allows_users_to_create_topics: Optional[bool] = None,
) -> None:
"""Enable Telegram DM topic mode for one private chat/user.
This method intentionally owns the explicit topic migration. Ordinary
SessionDB startup must not create these side tables.
"""
self.apply_telegram_topic_migration()
now = time.time()
def _to_int(value: Optional[bool]) -> Optional[int]:
if value is None:
return None
return 1 if value else 0
def _do(conn):
conn.execute(
"""
INSERT INTO telegram_dm_topic_mode (
chat_id, user_id, enabled, activated_at, updated_at,
has_topics_enabled, allows_users_to_create_topics,
capability_checked_at
) VALUES (?, ?, 1, ?, ?, ?, ?, ?)
ON CONFLICT(chat_id) DO UPDATE SET
user_id = excluded.user_id,
enabled = 1,
updated_at = excluded.updated_at,
has_topics_enabled = excluded.has_topics_enabled,
allows_users_to_create_topics = excluded.allows_users_to_create_topics,
capability_checked_at = excluded.capability_checked_at
""",
(
str(chat_id),
str(user_id),
now,
now,
_to_int(has_topics_enabled),
_to_int(allows_users_to_create_topics),
now,
),
)
self._execute_write(_do)
def disable_telegram_topic_mode(
self,
*,
chat_id: str,
clear_bindings: bool = True,
) -> None:
"""Disable Telegram DM topic mode for one private chat.
When ``clear_bindings`` is True (default) the (chat_id, thread_id)
bindings for this chat are also cleared so re-enabling later
starts from a clean slate. Set to False if the operator wants to
preserve bindings for a later re-enable.
Never creates the topic-mode tables from scratch; if they don't
exist there is nothing to disable and the call is a no-op.
"""
def _do(conn):
try:
conn.execute(
"UPDATE telegram_dm_topic_mode SET enabled = 0, updated_at = ? "
"WHERE chat_id = ?",
(time.time(), str(chat_id)),
)
if clear_bindings:
conn.execute(
"DELETE FROM telegram_dm_topic_bindings WHERE chat_id = ?",
(str(chat_id),),
)
except sqlite3.OperationalError:
# Tables don't exist yet — nothing to disable.
return
self._execute_write(_do)
def is_telegram_topic_mode_enabled(self, *, chat_id: str, user_id: str) -> bool:
"""Return whether Telegram DM topic mode is enabled for this chat/user."""
with self._lock:
try:
row = self._conn.execute(
"""
SELECT enabled FROM telegram_dm_topic_mode
WHERE chat_id = ? AND user_id = ?
""",
(str(chat_id), str(user_id)),
).fetchone()
except sqlite3.OperationalError:
return False
if row is None:
return False
enabled = row["enabled"] if isinstance(row, sqlite3.Row) else row[0]
return bool(enabled)
def get_telegram_topic_binding(
self,
*,
chat_id: str,
thread_id: str,
) -> Optional[Dict[str, Any]]:
"""Return the session binding for a Telegram DM topic, if present."""
with self._lock:
try:
row = self._conn.execute(
"""
SELECT * FROM telegram_dm_topic_bindings
WHERE chat_id = ? AND thread_id = ?
""",
(str(chat_id), str(thread_id)),
).fetchone()
except sqlite3.OperationalError:
return None
return dict(row) if row else None
def list_telegram_topic_bindings_for_chat(
self,
*,
chat_id: str,
) -> List[Dict[str, Any]]:
"""All Telegram DM topic bindings for one chat, newest first.
Read-only; returns [] if the bindings table doesn't exist yet
(does not trigger the topic-mode migration).
"""
with self._lock:
try:
rows = self._conn.execute(
"SELECT * FROM telegram_dm_topic_bindings "
"WHERE chat_id = ? ORDER BY updated_at DESC",
(str(chat_id),),
).fetchall()
except sqlite3.OperationalError:
return []
return [dict(row) for row in rows]
def get_telegram_topic_binding_by_session(
self,
*,
session_id: str,
) -> Optional[Dict[str, Any]]:
"""Return the Telegram DM topic binding for a given session_id, if present.
Uses the UNIQUE INDEX on telegram_dm_topic_bindings(session_id) for an
efficient reverse lookup. Returns None when the session has no binding or
the table does not exist yet.
"""
with self._lock:
try:
row = self._conn.execute(
"""
SELECT * FROM telegram_dm_topic_bindings
WHERE session_id = ?
""",
(str(session_id),),
).fetchone()
except sqlite3.OperationalError:
return None
return dict(row) if row else None
def delete_telegram_topic_binding(
self,
*,
chat_id: str,
thread_id: str,
) -> int:
"""Remove the binding row for a single (chat, thread) pair.
Called when the Telegram Bot API confirms a topic was deleted
externally (``Thread not found`` after the same-thread retry
already failed). Without this prune, the stale row keeps
living in ``telegram_dm_topic_bindings`` and the
recovery logic in ``gateway.run._recover_telegram_topic_thread_id``
cheerfully redirects future inbound messages to the deleted
topic, causing tool progress, approvals, and replies to land
in the wrong place. Issue #31501.
When this prune removes the chat's *last* remaining binding,
the chat's row in ``telegram_dm_topic_mode`` is also flipped to
``enabled = 0`` in the same transaction. Otherwise the chat
would be left in topic mode with zero lanes — and
``gateway.run._recover_telegram_topic_thread_id`` keeps treating
the chat as topic-enabled, lobby messages keep hunting for a
binding that no longer exists, and a user who disabled topics in
the Telegram client (rather than via ``/topic off``) stays stuck
until the next send happens to fail. Clearing the flag makes
recovery fully stand down once the dead topics are gone.
Returns the number of binding rows deleted (0 when the binding
was already absent or the topic-mode tables haven't been
migrated yet — both are silent no-ops; we never raise from
a cleanup hot path).
"""
chat_id = str(chat_id)
thread_id = str(thread_id)
deleted = {"count": 0}
def _do(conn):
try:
cursor = conn.execute(
"""
DELETE FROM telegram_dm_topic_bindings
WHERE chat_id = ? AND thread_id = ?
""",
(chat_id, thread_id),
)
deleted["count"] = cursor.rowcount or 0
except sqlite3.OperationalError:
# Tables don't exist yet — nothing to prune.
deleted["count"] = 0
return
if not deleted["count"]:
return
# If that was the chat's last binding, disable topic mode for
# the chat so recovery stops steering lobby messages at a now
# empty lane set. Same transaction → no read-after-prune race.
try:
remaining = conn.execute(
"""
SELECT 1 FROM telegram_dm_topic_bindings
WHERE chat_id = ? LIMIT 1
""",
(chat_id,),
).fetchone()
if remaining is None:
conn.execute(
"UPDATE telegram_dm_topic_mode "
"SET enabled = 0, updated_at = ? WHERE chat_id = ?",
(time.time(), chat_id),
)
except sqlite3.OperationalError:
# telegram_dm_topic_mode absent — binding prune still stands.
pass
self._execute_write(_do)
return deleted["count"]
def bind_telegram_topic(
self,
*,
chat_id: str,
thread_id: str,
user_id: str,
session_key: str,
session_id: str,
managed_mode: str = "auto",
) -> None:
"""Bind one Telegram DM topic thread to one Hermes session.
A Hermes session may only be linked to one Telegram topic in MVP.
Rebinding the same topic to the same session is idempotent; trying to
link the same session to a different topic raises ValueError.
"""
self.apply_telegram_topic_migration()
now = time.time()
chat_id = str(chat_id)
thread_id = str(thread_id)
user_id = str(user_id)
session_key = str(session_key)
session_id = str(session_id)
def _do(conn):
existing_session = conn.execute(
"""
SELECT chat_id, thread_id FROM telegram_dm_topic_bindings
WHERE session_id = ?
""",
(session_id,),
).fetchone()
if existing_session is not None:
linked_chat = existing_session["chat_id"] if isinstance(existing_session, sqlite3.Row) else existing_session[0]
linked_thread = existing_session["thread_id"] if isinstance(existing_session, sqlite3.Row) else existing_session[1]
if str(linked_chat) != chat_id or str(linked_thread) != thread_id:
raise ValueError("session is already linked to another Telegram topic")
conn.execute(
"""
INSERT INTO telegram_dm_topic_bindings (
chat_id, thread_id, user_id, session_key, session_id,
managed_mode, linked_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(chat_id, thread_id) DO UPDATE SET
user_id = excluded.user_id,
session_key = excluded.session_key,
session_id = excluded.session_id,
managed_mode = excluded.managed_mode,
updated_at = excluded.updated_at
""",
(
chat_id,
thread_id,
user_id,
session_key,
session_id,
managed_mode,
now,
now,
),
)
self._execute_write(_do)
def is_telegram_session_linked_to_topic(self, *, session_id: str) -> bool:
"""Return True if a Hermes session is already bound to any Telegram DM topic.
Read-only: does NOT trigger the telegram-topic migration. If the
topic-mode tables have not been created yet (i.e. nobody has run
``/topic`` in this profile), the session is by definition unbound
and we return False.
"""
with self._lock:
try:
row = self._conn.execute(
"""
SELECT 1 FROM telegram_dm_topic_bindings
WHERE session_id = ?
LIMIT 1
""",
(str(session_id),),
).fetchone()
except sqlite3.OperationalError:
return False
return row is not None
def list_unlinked_telegram_sessions_for_user(
self,
*,
chat_id: str,
user_id: str,
limit: int = 10,
) -> List[Dict[str, Any]]:
"""List previous Telegram sessions for this user that are not bound to a topic.
Read-only: does NOT trigger the telegram-topic migration. If the
topic-mode tables are absent, fall back to a simpler query that
just returns this user's Telegram sessions — there can't be any
bindings yet.
"""
with self._lock:
try:
rows = self._conn.execute(
"""
SELECT s.*,
COALESCE(
(SELECT SUBSTR(REPLACE(REPLACE(m.content, X'0A', ' '), X'0D', ' '), 1, 63)
FROM messages m
WHERE m.session_id = s.id AND m.role = 'user' AND m.content IS NOT NULL
ORDER BY m.timestamp, m.id LIMIT 1),
''
) AS _preview_raw,
COALESCE(
(SELECT MAX(m2.timestamp) FROM messages m2 WHERE m2.session_id = s.id),
s.started_at
) AS last_active
FROM sessions s
WHERE s.source = 'telegram'
AND s.user_id = ?
AND NOT EXISTS (
SELECT 1 FROM telegram_dm_topic_bindings b
WHERE b.session_id = s.id
)
ORDER BY last_active DESC, s.started_at DESC
LIMIT ?
""",
(str(user_id), int(limit)),
).fetchall()
except sqlite3.OperationalError:
# telegram_dm_topic_bindings doesn't exist yet — no bindings
# means every telegram session for this user is "unlinked".
rows = self._conn.execute(
"""
SELECT s.*,
COALESCE(
(SELECT SUBSTR(REPLACE(REPLACE(m.content, X'0A', ' '), X'0D', ' '), 1, 63)
FROM messages m
WHERE m.session_id = s.id AND m.role = 'user' AND m.content IS NOT NULL
ORDER BY m.timestamp, m.id LIMIT 1),
''
) AS _preview_raw,
COALESCE(
(SELECT MAX(m2.timestamp) FROM messages m2 WHERE m2.session_id = s.id),
s.started_at
) AS last_active
FROM sessions s
WHERE s.source = 'telegram'
AND s.user_id = ?
ORDER BY last_active DESC, s.started_at DESC
LIMIT ?
""",
(str(user_id), int(limit)),
).fetchall()
sessions: List[Dict[str, Any]] = []
for row in rows:
session = dict(row)
raw = str(session.pop("_preview_raw", "") or "").strip()
session["preview"] = raw[:60] + ("..." if len(raw) > 60 else "") if raw else ""
sessions.append(session)
return sessions
# ── 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")
def _fts_table_exists(self, name: str) -> bool:
"""True if an FTS5 virtual table is queryable in this DB."""
try:
self._conn.execute(f"SELECT 1 FROM {name} LIMIT 0")
return True
except sqlite3.DatabaseError:
# OperationalError ("no such table") or the broader
# DatabaseError class ("vtable constructor failed", raised when
# e.g. a required tokenizer is missing or the table is mid-
# teardown) — in every case the table is not queryable.
return False
def optimize_fts(self) -> int:
"""Merge fragmented FTS5 b-tree segments into one per index.
FTS5 indexes grow as a series of incremental segments — one per
``INSERT`` batch driven by the message triggers. Over tens of
thousands of messages these segments accumulate, which both bloats
the ``*_data`` shadow tables and slows ``MATCH`` queries that must
scan every segment. The special ``'optimize'`` command rewrites each
index as a single merged segment.
This is purely a maintenance operation — it changes neither search
results nor ``snippet()`` output, only on-disk layout and query
speed. It is complementary to VACUUM: ``optimize`` compacts the FTS
index internally, then VACUUM returns the freed pages to the OS.
Skips any FTS table that does not exist (e.g. the trigram index when
disabled via ``HERMES_DISABLE_FTS_TRIGRAM`` or not yet created), so
it is safe to call unconditionally.
Returns the number of FTS indexes that were optimized.
"""
optimized = 0
with self._lock:
for tbl in self._FTS_TABLES:
if not self._fts_table_exists(tbl):
continue
try:
# The column name in the INSERT must match the table name
# for FTS5 special commands.
self._conn.execute(
f"INSERT INTO {tbl}({tbl}) VALUES('optimize')"
)
optimized += 1
except sqlite3.OperationalError as exc:
logger.warning(
"FTS optimize failed for %s: %s", tbl, exc
)
return optimized
def rebuild_fts(self) -> int:
"""Rebuild FTS5 indexes from the canonical ``messages`` table.
Uses the FTS5 ``'rebuild'`` command, which rewrites the internal
b-tree segments from the content rows. This is the documented
recovery for a corrupt FTS index that rejects message writes while
reads still succeed (issue #50502). Unlike ``optimize_fts`` (which
merges existing segments), ``rebuild`` discards and recreates the
index data entirely.
Safe to call when FTS tables don't exist (skips them).
Returns the number of FTS indexes that were rebuilt.
"""
rebuilt = 0
with self._lock:
for tbl in self._FTS_TABLES:
if not self._fts_table_exists(tbl):
continue
try:
self._conn.execute(
f"INSERT INTO {tbl}({tbl}) VALUES('rebuild')"
)
self._conn.commit()
rebuilt += 1
except sqlite3.OperationalError as exc:
self._conn.rollback()
logger.warning(
"FTS rebuild failed for %s: %s", tbl, exc
)
return rebuilt
def vacuum(self) -> int:
"""Run VACUUM to reclaim disk space after large deletes.
SQLite does not shrink the database file when rows are deleted —
freed pages just get reused on the next insert. After a prune that
removed hundreds of sessions, the file stays bloated unless we
explicitly VACUUM.
VACUUM rewrites the entire DB, so it's expensive (seconds per
100MB) and cannot run inside a transaction. It also acquires an
exclusive lock, so callers must ensure no other writers are
active. Safe to call at startup before the gateway/CLI starts
serving traffic.
FTS5 segments are merged first via :meth:`optimize_fts` so the
subsequent VACUUM reclaims the pages freed by the merge. This is a
layout-only optimization — search results are unchanged.
Returns the number of FTS indexes that were optimized (0 if the
merge step failed or no FTS tables exist).
"""
# Merge FTS5 segments before VACUUM so the freed pages are returned
# to the OS in the same pass. optimize_fts() manages its own lock.
optimized = 0
try:
optimized = self.optimize_fts()
except Exception as exc:
logger.warning("FTS optimize before VACUUM failed: %s", exc)
# VACUUM cannot be executed inside a transaction.
with self._lock:
# Best-effort WAL checkpoint first, then VACUUM.
try:
self._conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
except Exception as exc:
logger.debug("WAL checkpoint (TRUNCATE) before VACUUM failed: %s", exc)
self._conn.execute("VACUUM")
return optimized
def maybe_auto_prune_and_vacuum(
self,
retention_days: int = 90,
min_interval_hours: int = 24,
vacuum: bool = True,
sessions_dir: Optional[Path] = None,
) -> Dict[str, Any]:
"""Idempotent auto-maintenance: prune old sessions + optional VACUUM.
Records the last run timestamp in state_meta so subsequent calls
within ``min_interval_hours`` no-op. Designed to be called once at
startup from long-lived entrypoints (CLI, gateway, cron scheduler).
When *sessions_dir* is provided, on-disk transcript files
(``.json`` / ``.jsonl`` / ``request_dump_*``) for pruned sessions
are removed as part of the same sweep (issue #3015).
Never raises. On any failure, logs a warning and returns a dict
with ``"error"`` set.
Returns a dict with keys:
- ``"skipped"`` (bool) — true if within min_interval_hours of last run
- ``"pruned"`` (int) — number of sessions deleted
- ``"vacuumed"`` (bool) — true if VACUUM ran
- ``"error"`` (str, optional) — present only on failure
"""
result: Dict[str, Any] = {"skipped": False, "pruned": 0, "vacuumed": False}
try:
# Skip if another process/call did maintenance recently.
last_raw = self.get_meta("last_auto_prune")
now = time.time()
if last_raw:
try:
last_ts = float(last_raw)
if now - last_ts < min_interval_hours * 3600:
result["skipped"] = True
return result
except (TypeError, ValueError):
pass # corrupt meta; treat as no prior run
pruned = self.prune_sessions(
older_than_days=retention_days,
sessions_dir=sessions_dir,
)
result["pruned"] = pruned
# Only VACUUM if we actually freed rows — VACUUM on a tight DB
# is wasted I/O. Threshold keeps small DBs from paying the cost.
if vacuum and pruned > 0:
try:
self.vacuum()
result["vacuumed"] = True
except Exception as exc:
logger.warning("state.db VACUUM failed: %s", exc)
# Record the attempt even if pruned == 0, so we don't retry
# every startup within the min_interval_hours window.
self.set_meta("last_auto_prune", str(now))
if pruned > 0:
logger.info(
"state.db auto-maintenance: pruned %d session(s) older than %d days%s",
pruned,
retention_days,
" + VACUUM" if result["vacuumed"] else "",
)
except Exception as exc:
# Maintenance must never block startup. Log and return error marker.
logger.warning("state.db auto-maintenance failed: %s", exc)
result["error"] = str(exc)
return result
# ── Handoff (cross-platform session transfer) ──────────────────────────
#
# State machine:
# None — no handoff in flight
# "pending" — CLI requested handoff, gateway hasn't picked it up yet
# "running" — gateway is processing (session switch + synthetic turn)
# "completed"— gateway successfully delivered the synthetic turn
# "failed" — gateway hit an error; reason in handoff_error
#
# The CLI writes "pending" then poll-waits for terminal state. The gateway
# watcher transitions pending→running→{completed,failed}.
def request_handoff(self, session_id: str, platform: str) -> bool:
"""Mark a session as pending handoff to the given platform.
Returns True if the row was found and not already in flight; False if
the session is already in a non-terminal handoff state.
"""
def _do(conn):
cur = conn.execute(
"UPDATE sessions "
"SET handoff_state = 'pending', "
" handoff_platform = ?, "
" handoff_error = NULL "
"WHERE id = ? AND (handoff_state IS NULL "
" OR handoff_state IN ('completed', 'failed'))",
(platform, session_id),
)
return cur.rowcount > 0
return self._execute_write(_do)
def get_handoff_state(self, session_id: str) -> Optional[Dict[str, Any]]:
"""Read the current handoff state for a session.
Returns ``{"state", "platform", "error"}`` or None if the session has
no handoff record.
"""
try:
cur = self._conn.execute(
"SELECT handoff_state, handoff_platform, handoff_error "
"FROM sessions WHERE id = ?",
(session_id,),
)
row = cur.fetchone()
if not row:
return None
return {
"state": row["handoff_state"],
"platform": row["handoff_platform"],
"error": row["handoff_error"],
}
except Exception:
return None
def list_pending_handoffs(self) -> List[Dict[str, Any]]:
"""Return all sessions in handoff_state='pending', oldest first.
Used by the gateway's handoff watcher.
"""
try:
cur = self._conn.execute(
"SELECT * FROM sessions "
"WHERE handoff_state = 'pending' "
"ORDER BY started_at ASC"
)
return [dict(r) for r in cur.fetchall()]
except Exception:
return []
def claim_handoff(self, session_id: str) -> bool:
"""Atomically transition pending → running. Returns True if claimed."""
def _do(conn):
cur = conn.execute(
"UPDATE sessions SET handoff_state = 'running' "
"WHERE id = ? AND handoff_state = 'pending'",
(session_id,),
)
return cur.rowcount > 0
return self._execute_write(_do)
def complete_handoff(self, session_id: str) -> None:
"""Mark a handoff as completed."""
def _do(conn):
conn.execute(
"UPDATE sessions SET handoff_state = 'completed', "
"handoff_error = NULL WHERE id = ?",
(session_id,),
)
self._execute_write(_do)
def fail_handoff(self, session_id: str, error: str) -> None:
"""Mark a handoff as failed and record the reason."""
def _do(conn):
conn.execute(
"UPDATE sessions SET handoff_state = 'failed', "
"handoff_error = ? WHERE id = ?",
(error[:500], session_id),
)
self._execute_write(_do)
class AsyncSessionDB:
"""Async door onto SessionDB: offloads each call via asyncio.to_thread so a blocking SQLite call never freezes the event loop. Generic forwarder — the audit confirms no method returns a live cursor/generator."""
def __init__(self, db: "SessionDB") -> None:
self._db = db
def __getattr__(self, name: str):
attr = getattr(self._db, name)
if not callable(attr):
return attr
async def _offloaded(*args, **kwargs):
return await asyncio.to_thread(attr, *args, **kwargs)
return _offloaded