
    epj\             	       @   U d Z ddlZddlZddlZddlZddlZddlZddlZddlZddl	Z	ddl
Z
ddlZddlZddlZddlmZ ddlmZ ddlmZ ddlmZ ddlmZ ddlmZ dd	lmZmZmZ dd
lmZ ddl m!Z" ddl#m$Z$m%Z%m&Z&m'Z'm(Z(m)Z)m*Z* ddl+m,Z,m-Z-m.Z.m/Z/m0Z0m1Z1m2Z2m3Z3m4Z4m5Z5m6Z6m7Z7m8Z8m9Z9m:Z:m;Z;m<Z<m=Z=m>Z>m?Z?m@Z@mAZAmBZBmCZCmDZD ddlEmFZF ddlGmHZH ddlImJZJ 	 ddlKZKn# eL$ r dZKY nw xY w ejM        eN          ZO e	jP        d          ZQdeRdeRfdZSdeRdeTfdZUde$de$fdZVde&eRe$f         de(eR         fdZWdsdeRdeRfdZXdeRde)eRe'eR         f         fdZYd eRde)eRe'eR         f         fd!ZZd"e'eR         de'eR         fd#Z[d"e'eR         de'eR         fd$Z\ e*d%          Z] e            d&z  Z^e^Z_defd'Z`d(Zadabe(eR         ecd)<    ejd                    Ze ef            ZgefeR         ecd*<    ejd                    Zh ef            ZiefeR         ecd+<    ejd                    Zjd,e(eR         ddfd-Zkde(eR         fd.Zld/Zmd,e&eRe$f         deTfd0Znd1e'e&eRe$f                  de'e&eRe$f                  fd2Zodtd4eRdeRfd5Zpd6e
jq        de(eR         fd7Zrd6e
jq        ddfd8Zsd6e
jq        ddfd9Zt	 dud:e(eu         deTfd;Z!deRfd<ZvdeRfd=Zw G d> d?e
jx                  Zyd&d@dAd6e
jq        dBeRdCeTdeRfdDZzd@dEd6e
jq        dBeRdFeTdeRfdGZ{dBeRdHeTddfdIZ|dBeRdJe}ddfdKZ~d&dLd6e
jq        dBeRddfdMZdNZ ef            ZefeR         ecdO<    ejd                    ZdJedeTfdPZdQZdJeeRz  dz  deTfdRZdSedeTfdTZdSede(e         fdUZd&dLdSedBeRddfdVZdSede(eR         fdWZdXdYdSedZeTde&eRe$f         fd[Zd\Zd]Zdefd^ZdeTfd_Zd6e
jq        deTfd`Z G da dbe          Z G dc dde          Z G de dfe          ZdudgZdhd@didjedkedleTdeTfdmZdjede(e         fdnZ G do dpeJeHeF          Z G dq dr          ZdS )va}  
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
    N)deque)contextmanager)Path)sanitize_contextActivityProvenance)_sanitize_surrogates)SKILL_EXCERPT_JOINTSKILL_SCAFFOLD_SQL_LIKEdescribe_skill_invocation)get_hermes_home)is_sqlite_wal_reset_vulnerable)AnyCallableDictListOptionalTupleTypeVar)_BRANCH_CHILD_SQL_COMPRESSION_CHILD_SQL_FTS_CJK_TRIGGERS_FTS_TRIGGERS_LISTABLE_CHILD_SQL_PREVIEW_RAW_SELECT_ephemeral_child_sql_shape_preview_sql_session_last_active_sql_session_last_active_by_idDEFERRED_INDEX_SQLFTS_CJK_STALE_KEYFTS_SQLFTS_STORAGE_VERSIONFTS_TRIGRAM_SQLLEGACY_FTS_SQLLEGACY_FTS_TRIGRAM_SQLMAX_FTS5_QUERY_CHARS
SCHEMA_SQLSCHEMA_VERSION_PREVIEW_CONTENT_SQL_PREVIEW_HEAD_CHARS_PREVIEW_MAX_CHARS_PREVIEW_SCAFFOLD_WINDOW_PREVIEW_SCAFFOLDED_SQL)SessionPortabilityMixin)SessionSchemaMixin)SessionSearchMixinz(?:^|:)pid=(\d+)(?::|$)system_promptreturnc                 t    t          j        |                     d                                                    S )Nzutf-8)hashlibsha256encode	hexdigest)r2   s    2/home/thesage/.hermes/hermes-agent/hermes_state.py_system_prompt_hashr:   Y   s,    >-..w7788BBDDD    holderc                    t                               | pd          }|dS 	 t          |                    d                    }n# t          t
          f$ r Y dS w xY w|dk    rdS |t          j                    k    rdS t          '	 t          j	        |           S # t          $ r Y dS w xY wt          j        dk    rdS 	 t          j        |d           n*# t          $ r Y dS t          t          t           f$ r Y dS w xY wdS )a  Return True only when a structured lock holder's local PID is gone.

    Compression locks are stored in a host-local SQLite database and holder
    IDs created by ``conversation_compression`` start with ``pid=<n>``. A
    process killed during gateway shutdown cannot release its lease, so waiting
    for the full TTL makes every new turn repeatedly attempt compaction. Reclaim
    only when the kernel proves that PID no longer exists; legacy/unstructured
    holders, same-process holders, permission errors, and any probe doubt
    remain protected until normal TTL expiry (conservative: PID reuse must
    never steal a live lease, and a wrongly-kept lease self-heals via TTL).
     NF   r   ntT)_COMPRESSION_LOCK_HOLDER_PID_REsearchintgroup	TypeError
ValueErrorosgetpidpsutil
pid_exists	ExceptionnamekillProcessLookupErrorPermissionErrorOSErrorOverflowError)r<   matchpids      r9   (_compression_lock_holder_process_is_deadrT   ]   sB    ,226<R@@E}u%++a..!!z"   uu
axxu
bikk u	 (---- 	 	 	55	 
w$u
Q   ttWm4   uu5s;   "A AAB 
B)(B)?C 
C<"C<;C<valuec                 N    t          | t                    rt          |           n| S )a>  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.
    )
isinstancestrr	   rU   s    r9   _scrub_surrogatesrZ      s'     +5UC*@*@K&&&eKr;   rowc                     |                      d          pd                                }|r|S |                      d          pd                                }|pdS )uv  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.
    git_repo_rootr>   cwdN)getstrip)r[   rootr^   s      r9   workspace_keyrb      s]     GGO$$*1133D 775>>R
&
&
(
(C;$r;   model_configcolc                     d|  dS )Nzjson_extract(COALESCE(z, '{}'), '$._delegate_from') )rd   s    r9   _delegate_from_jsonrg      s    GCGGGGr;   
cwd_prefixc                 J    |                      d          p| }d|| d| dgfS )N/\z+(s.cwd = ? OR s.cwd LIKE ? OR s.cwd LIKE ?)/%\%)rstrip)rh   prefixs     r9   _cwd_prefix_clausero      s<    u%%3F86f===U[R`R`R`:aaar;   keyc                 j    |                      d          p| }t          |          \  }}d| d|g|fS )u  Match sessions whose ``workspace_key(row)`` equals ``key``.

    Mirrors :func:`workspace_key`: a session belongs to workspace ``key``
    when its recorded ``git_repo_root`` equals ``key``, or — for rows that
    predate per-session git metadata — when its ``cwd`` is at or under
    ``key`` (so a session started in ``repo/src`` still groups with ``repo``).
    Used by ``hermes -c``/``--resume`` to continue the most recent session in
    the *current* workspace rather than the global MRU.
    rj   z@(s.git_repo_root = ? OR (COALESCE(s.git_repo_root, '') = '' AND )))rm   ro   )rp   rn   
cwd_clause
cwd_paramss       r9   _workspace_key_clauseru      sO     ZZ%#F/77J
Y:YYY	* r;   
parent_idsc                    t                      }d |D             t                    t                    }|rd                    dt	          |          z            }|                     d| d| d| d| d	||z             }fd	|                                D             }                    |           |fd
D             S )ue  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).
    c                     h | ]}||S rf   rf   .0sids     r9   	<setcomp>z._collect_delegate_child_ids.<locals>.<setcomp>   s    ...S#.S...r;   ,?zSELECT id FROM sessions WHERE z IN (z) OR (parent_session_id IN (z) AND z IS NOT NULL)c                 4    g | ]}|d          v|d          S idrf   )rz   r[   founds     r9   
<listcomp>z/_collect_delegate_child_ids.<locals>.<listcomp>   s+    UUU#c$iu>T>TCI>T>T>Tr;   c                     g | ]}|v|	S rf   rf   )rz   r{   seedss     r9   r   z/_collect_delegate_child_ids.<locals>.<listcomp>   s#    555CCu$4$4C$4$4$4r;   )rg   setlistjoinlenexecutefetchallupdate)connrv   dffrontierphcursorr   r   s         @@r9   _collect_delegate_child_idsr      s    
		B..J...E %jjEE{{H
 XXcCMM)**ER E Eb E E)+E E35E E Ex
 

 VUUU):):UUUX   655555555r;   c                    t          | |          }|rsd                    dt          |          z            }|                     d| d|           |                     d| d|           |                     d| d|           |S )Nr}   r~   *DELETE FROM messages WHERE session_id IN ()IUPDATE sessions SET parent_session_id = NULL WHERE parent_session_id IN ("DELETE FROM sessions WHERE id IN ()r   r   r   r   )r   rv   idsr   s       r9   _delete_delegate_childrenr      s    
%dJ
7
7C
 	FXXcCHHn%%G"GGGMMM1+-1 1 1	
 	
 	

 	?"???EEEJr;   Tstate.dbc                  R    t           t          k    rt           S t                      dz  S )u  Resolve the default state DB path at call time.

    ``DEFAULT_DB_PATH`` is computed when this module is first imported, which
    freezes the developer's real ``~/.hermes`` even when a test fixture later
    redirects ``HERMES_HOME`` — importing this module during collection was
    enough to point every default ``SessionDB()`` at the real state.db.

    Precedence:

    1. A deliberately re-pointed ``DEFAULT_DB_PATH`` (differs from the
       import-time snapshot — the established test escape hatch) wins.
    2. Otherwise resolve ``get_hermes_home()`` fresh so a runtime
       ``HERMES_HOME`` redirect takes effect regardless of import order.
    r   )DEFAULT_DB_PATH_IMPORT_DEFAULT_DB_PATHr   rf   r;   r9   _default_db_pathr      s'     111z))r;   )zlocking protocolznot authorizeddisk i/o error_last_init_error_wal_fallback_warned_paths_wal_reset_bug_warned_pathsmsgc                 J    t           5  | addd           dS # 1 swxY w Y   dS )uo  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.
    N)_last_init_error_lockr   )r   s    r9   _set_last_init_errorr   E  st     
                   s   c                      t           S )aK  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).
    )r   rf   r;   r9   get_last_init_errorr   V  s
     r;   )z:Review the conversation above and update the skill libraryz;Review the conversation above and consider saving to memoryc                 $   t          | t                    sdS |                     d          dvrdS |                     d          }t          |t                    sdS |                                t          fdt          D                       S )ay  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.
    Frole>   usersystemcontentc              3   B   K   | ]}                     |          V  d S N)
startswith)rz   pheads     r9   	<genexpr>z8_is_background_review_harness_message.<locals>.<genexpr>z  s/      DDatq!!DDDDDDr;   )rW   dictr_   rX   lstripany_REVIEW_HARNESS_PREFIXES)r   r   r   s     @r9   %_is_background_review_harness_messager   j  s     c4   u
wwv000uggi  Ggs## u>>DDDDD+CDDDDDDr;   messagesc                     | s| S g }d}| D ]\}t          |          rd}|r1d}t          |t                    r|                    d          dk    rG|                    |           ]|S )aS  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.
    FTr   	assistant)r   rW   r   r_   append)r   outskip_next_assistantr   s       r9    _strip_background_review_harnessr   }  s       "C 	 	055 	"& 	"'#t$$ K)G)G

3Jr;   Session database not availablern   c                     t                      s|  dS d}t          fdt          D                       rd}|  d | dS )u~  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).
    .r>   c              3   D   K   | ]}|                                 v V  d S r   )lower)rz   markercauses     r9   r   z0format_session_db_unavailable.<locals>.<genexpr>  s0      
G
Gv6U[[]]"
G
G
G
G
G
Gr;   uN    (state.db may be on NFS/SMB/FUSE/ZFS — see https://www.sqlite.org/wal.html): )r   r   _WAL_INCOMPAT_MARKERS)rn   hintr   s     @r9   format_session_db_unavailabler     sq      !!E |||D

G
G
G
G1F
G
G
GGG `_&&&t&&&&r;   r   c                    	 |                      d                                          }n# t          j        $ r Y dS w xY w|dS |d         }t	          |t
                    r(	 |                    d          }n# t          $ r Y dS w xY w|3t          |          	                                
                                ndS )zRead 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).
    PRAGMA journal_modeNr   ascii)r   fetchonesqlite3OperationalErrorrW   bytesdecodeUnicodeDecodeErrorrX   r`   r   )r   r[   modes      r9   _on_disk_journal_moder     s    ll011::<<#   tt
{tq6D$ 	;;w''DD! 	 	 	44	(,(83t99??""$$$dBs   '* =="A8 8
BBc                     t           j        dk    rdS 	 |                     d           dS # t          j        $ r Y dS w xY w)u  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.
    darwinNzPRAGMA checkpoint_fullfsync=1sysplatformr   r   r   r   s    r9   _apply_macos_checkpoint_barrierr     sY    6 |x455555#      + >>c                     t           j        dk    rdS 	 |                     d           dS # t          j        $ r Y dS w xY w)uS  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.
    r   NzPRAGMA synchronous=FULLr   r   s    r9   _enforce_macos_synchronous_fullr     sY    , |x./////#   r   version_infoc                 @    | | nt           j        }t          |          S )a:  Return True when the linked SQLite library has the WAL-reset bug.

    Upstream documents the bug in versions 3.7.0 through 3.51.2, fixed in
    3.51.3+, with backports 3.50.7 and 3.44.6:
    https://sqlite.org/wal.html#walresetbug

    Pre-WAL libraries (< 3.7.0) cannot hit the race and are treated as safe.
    )r   sqlite_version_info_is_sqlite_wal_reset_vulnerable)r   infos     r9   r   r     s#     (3<<9TD*4000r;   c                  H   	 t          j        d          } 	 |                     d                                          }|                                  n# |                                  w xY wn# t           j        $ r Y dS w xY w|r|d         dS t          |d                   S )zCReturn ``sqlite_source_id()``, or an empty string when unavailable.:memory:zSELECT sqlite_source_id()r>   r   )r   connectr   r   closeErrorrX   )r   r[   s     r9   sqlite_source_idr     s    z**	,,:;;DDFFCJJLLLLDJJLLLLL=   rr #a&.rs1v;;s'   A- 'A A- A))A- -B ?B c                  \   	 ddl m}   |             pi }|                    di           }t          |t                    sdS |                    dd          }n# t
          $ r Y dS w xY wt          |t                    sdS |                                                                }|dv r|ndS )az  Return the configured journal mode (``wal`` or ``delete``).

    ``database.journal_mode`` in config.yaml is the canonical operator
    setting. ``wal`` remains the default; use ``delete`` when the backing
    filesystem does not provide WAL-safe durability (for example macOS
    virtiofs, NFS, or SMB). Invalid or malformed values fail safely to the
    existing default.
    r   )load_config_readonlydatabasewaljournal_mode)r   delete)	hermes_cli.configr   r_   rW   r   rK   rX   r`   r   )r   configr   rawr   s        r9   resolve_journal_moder   #  s    	::::::%%''-2::j"--(D)) 	5ll>511   uu c3 u99;;D,,,44%7s   =A A 
A&%A&c                       e Zd ZdZdS )WalUnsupportedErrora  Raised by :func:`apply_wal_with_fallback` when ``require_wal=True`` and
    the filesystem cannot provide WAL journal mode.

    Covers both shapes of WAL refusal on network filesystems (NFS / SMB / FUSE
    / the AgentFS NFS overlay): SQLite *raising* ``SQLITE_PROTOCOL`` ("locking
    protocol"), and the quieter macOS-NFS case where ``PRAGMA journal_mode=WAL``
    silently returns the still-effective mode without raising.  Subclasses
    ``sqlite3.OperationalError`` so existing ``except sqlite3.OperationalError``
    DB-init handling still catches it, while callers that specifically mandate
    WAL can catch this narrower type.
    N__name__
__module____qualname____doc__rf   r;   r9   r   r   =  s        
 
 
 
r;   r   F)db_labelrequire_walr   r   c                \   t                      }t                      rt          | ||dk              S 	 |                     d                                          }|r,|d         dk    r t          |            t          |            dS n# t          j        $ r Y nw xY w|dk    rt|                     d                                          }|r't          |d                   
                                nd}|dk    rt          j        d|pd	 d
          |S 	 |                     d                                          }|rA|d         9t          |d                                                   
                                nd}|dk    r t          |            t          |            dS t          d|d
          }|r|t          ||           |pdS # t          j        $ r}	t          |	t                    r t          |	          
                                t          fdt           D                       s dv rt#          d          D ]}
t%          j        d           	 |                     d                                          }nB# t          j        $ r0}dt          |          
                                vr |}	Y d}~yd}~ww xY w|rA|d         9t          |d                                                   
                                nd}|dk    r%t          |            t          |             Y d}	~	dS  t)          |           }|dk    r |rt          t          |	                    |	t          ||	           |                     d           Y d}	~	dS d}	~	ww xY w)u
  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, ZFS), SQLite either
    raises ``OperationalError("locking protocol")`` /
    ``OperationalError("disk I/O error")`` or — on macOS NFS / SMB /
    the AgentFS NFS overlay — silently refuses the switch and leaves the DB in
    DELETE.  Either way the degradation is logged at ERROR level (it is a real
    loss of concurrency — a write blocks concurrent readers — not a cosmetic
    warning) and, by default, the function falls back to DELETE (the pre-WAL
    default, which works on NFS and ZFS) so the feature keeps working.

    On SQLite builds that still contain the WAL-reset corruption bug
    (issue #69784), refuse to enable WAL on fresh / non-WAL databases
    (prefer DELETE).  If the on-disk DB is already WAL, keep WAL and warn
    — never live-downgrade under possible concurrent openers.

    This gate (#70055) is deliberately RETAINED. An earlier revision of the
    lock-cancellation fix (#71724) reverted it on the theory that DELETE was
    "the mode that corrupts", but that comparison was confounded: the clean
    WAL result came from SQLite 3.53.1, which carries BOTH the WAL-reset fix
    AND 3.51.0's defenses against close()-broken POSIX locks, so it says
    nothing about 3.50.4.  Re-measured on the actually-bundled 3.50.4 with
    the lock fix in place, WAL and DELETE are both clean (0/3 each) — i.e.
    there is no evidence that WAL is safer here, and upstream still documents
    the WAL-reset bug as real through 3.51.2 with serious consequences.  Until
    a fixed runtime is delivered, keep new databases out of WAL.

    Callers that genuinely require WAL concurrency (and would rather fail loudly
    than run silently degraded) pass ``require_wal=True``; the function then
    raises :class:`WalUnsupportedError` instead of returning ``"delete"``.  All
    current callers deliberately keep the default ``require_wal=False`` so
    NFS-homed installs keep working.

    The ERROR 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 error
    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.  That holds for both the NFS path and the
    WAL-reset vulnerability path.
    r   )r   require_deleter   r   r   PRAGMA journal_mode=DELETEr>   2could not set configured journal_mode=delete (got 	no resultr   zPRAGMA journal_mode=WALNz0journal_mode=WAL refused without raising (still c              3       K   | ]}|v V  	d S r   rf   )rz   r   r   s     r9   r   z*apply_wal_with_fallback.<locals>.<genexpr>  s'      EEV6S=EEEEEEr;   r      g?)r   r   _apply_delete_for_wal_reset_bugr   r   r   r   r   r   rX   r   r`   r   _log_wal_fallback_oncerW   r   r   rangetimesleepr   )r   r   r   
configuredcurrent_moder[   actualr   
silent_excexc_	retry_excexistingr   s                @r9   apply_wal_with_fallbackr  K  s,   l &''J &'' 
.%1
 
 
 	
||$9::CCEE 	LOu44+D111+D1115#   
 Xll788AACC(+3SV""$$$X*]VEZ{]]]   J ll455>>@@.1Pc!f6Hs3q6{{  ""((***b5==+D111+D1115 )HtHHH
 

  	x444x# 1 1 1 c.// 	#hhnnEEEE/DEEEEE 	 s""1XX  
4   ,,'@AAJJLLCC/   's9~~/C/C/E/EEE#CHHHH	 "1v1 AKK%%''--/// 
 5==3D9993D999 555555(..u 	9%c#hh//S8x---1222xxxxxc1sd   AB BBBG -*G N+(BN&+'JN&K"&KN&KA-N&AN&&N+)r   r   c                   d}	 |                      d                                          }|rA|d         9t          |d                                                                                   }n# t
          j        $ r d}Y nw xY w|dk    r1t          |d           t          |            t          |            dS d}	 |                      d                                          }|rA|d         9t          |d                                                                                   }n# t
          j        $ r |r Y nw xY w|r |d	k    rt          j        d
|pd d          t          |d           d	S )zAvoid enabling WAL when the linked SQLite has the WAL-reset bug.

    - Already-WAL on disk: leave WAL alone (no live downgrade) and warn.
    - Otherwise: set DELETE and warn.
    - For an explicit operator request, verify SQLite accepted DELETE.
    r>   r   r   Nr   T)kept_walr  r   r  r  r   F)
r   r   rX   r`   r   r   r   _log_wal_reset_bug_oncer   r   )r   r   r   currentr[   r  s         r9   r  r    s    Gll011::<< 	23q6%#a&kk''))//11G#    % 	 48888'---'---uFll788AACC 	13q6%Q[[&&((..00F#    		 	
  
&H,,&-)k- - -
 
 	
 Hu55558s%   A*A/ /BB A*D+ +E ?E r  c                    t           5  | t          v r	 ddd           dS t                              |            ddd           n# 1 swxY w Y   |rdnd}t                              d| t
          j        |           dS )zHLog once per (process, db_label) about the WAL-reset vulnerability path.Nu\   is already in WAL mode — leaving WAL in place (no live downgrade under concurrent openers)z1using journal_mode=DELETE instead of enabling WALuC  %s: linked SQLite %s is vulnerable to the WAL-reset corruption bug (https://sqlite.org/wal.html#walresetbug) — %s. Upgrade to SQLite 3.51.3+ (or backports 3.50.7 / 3.44.6); Hermes-managed installs can repair the embedded runtime with `hermes update`. See `hermes doctor`. This warning fires once per process per database.)_wal_reset_bug_warned_lockr   addloggerwarningr   sqlite_version)r   r  actions      r9   r  r     s    
$ 2 22222 2 2 2 2 2 2 2 	$''1112 2 2 2 2 2 2 2 2 2 2 2 2 2 2 	A 	. 	. A	  NN	  	
 
 
 
 
   AAA
A
r  c                     t           5  | t          v r	 ddd           dS t                              |            ddd           n# 1 swxY w Y   t                              d| |           dS )u  Log a single ERROR per (process, db_label) about WAL fallback.

    ERROR (not WARNING): a DB silently dropped to DELETE means a real loss of
    concurrency — under the kanban dispatcher + workers a write blocks readers,
    surfacing as SQLITE_BUSY/lock contention — so it must be loud, not cosmetic.

    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 errors per hour.
    Nu  %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/ZFS). See https://www.sqlite.org/wal.html for details. This message fires once per process per database.)_wal_fallback_warned_lockr   r  r  error)r   r  s     r9   r  r  =  s     
# 1 11111 1 1 1 1 1 1 1 	#&&x0001 1 1 1 1 1 1 1 1 1 1 1 1 1 1 LL	/
 	    r  r   c                   	 ddl m}m}  |            }n# t          $ r Y dS w xY wdD ]} ||d|d          }|	 t	          t          |                                                    }n4# t          t          f$ r  t          
                    d|||           Y tw xY w	 |                     d| d	|            # t          j        $ r Y w xY wdS )
u+  Apply optional performance and WAL-sizing PRAGMAs from ``config.yaml``.

    Reads the ``database:`` section and applies configurable PRAGMAs when set
    to integer values.  The journal mode itself is NOT handled here —
    ``database.journal_mode`` is owned by :func:`resolve_journal_mode` inside
    :func:`apply_wal_with_fallback`, which layers the operator setting under
    all the safety guards (never live-downgrading an on-disk WAL DB,
    filesystem fallback, WAL-reset-bug gating).

    Supported keys under ``database:`` in config.yaml:

    * ``cache_size`` — negative value = KiB, positive = pages
      (e.g. ``-262144`` = 256 MB page cache)
    * ``mmap_size`` — max bytes for memory-mapped I/O (0 = disabled)
    * ``temp_store`` — 0=DEFAULT(file), 1=FILE, 2=MEMORY, 3=ALWAYS
    * ``wal_autocheckpoint`` — WAL auto-checkpoint threshold in pages
    * ``journal_size_limit`` — max journal/WAL size in bytes

    Best-effort: config load or pragma failures are ignored so DB init
    never breaks on a malformed ``database:`` section.
    r   )cfg_getr   N)
cache_size	mmap_size
temp_storewal_autocheckpointjournal_size_limitr   )defaultz'%s: ignoring non-integer database.%s=%rzPRAGMA =)r   r%  r   rK   rC   rX   r`   rE   rF   r  r  r   r   r   )r   r   r%  r   cfgpragma_name	raw_valuerU   s           r9   apply_database_pragmasr0  Z  sL   4CCCCCCCC""$$   
   GC[$GGG			I,,..//EE:& 	 	 	NN9	   H		LL8;88889999' 	 	 	D	- s/    
##.A,,.BB!B==CC)zmalformed database schemaz database disk image is malformed_repair_attempted_pathsc                 |     t           t          j                  sdS t           fdt          D                       S )zTrue 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.
    Fc              3   ^   K   | ]'}|t                                                    v V  (d S r   rX   r   )rz   r   r  s     r9   r   z(is_malformed_db_error.<locals>.<genexpr>  s8      RRfvS)))RRRRRRr;   )rW   r   DatabaseErrorr   _MALFORMED_SCHEMA_MARKERSr  s   `r9   is_malformed_db_errorr8    sC     c7011 uRRRR8QRRRRRRr;   )zno space left on deviceznot enough spacezdatabase or disk is fullz	disk fullz	full diskenospcc                 ,   | dS t          | t                    r!t          | dd          t          j        k    rdS t          | t
                    r| nt          |           }|                                t          fdt          D                       S )a  True when *exc* (or a stringified error) is a disk-full / ENOSPC failure.

    Covers:
      * ``OSError`` with ``errno.ENOSPC``
      * SQLite ``OperationalError: database or disk is full`` (SQLITE_FULL)
      * Plain English / errno strings that survive RPC wrapping
    NFerrnoTc              3       K   | ]}|v V  	d S r   rf   )rz   r   lowereds     r9   r   z%is_disk_full_error.<locals>.<genexpr>  s(      BBVv BBBBBBr;   )	rW   rP   getattrr;  ENOSPCrX   r   r   _DISK_FULL_MARKERS)r  textr=  s     @r9   is_disk_full_errorrB    s     {u#w GC$$?$?5<$O$OtS#&&433CHHDjjllGBBBB/ABBBBBBr;   db_pathc                     t          |           }t          5  |t          v r	 ddd           dS t                              |           	 ddd           dS # 1 swxY w Y   dS )a  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.
    NFT)rX   _repair_attempt_lockr1  r  )rC  rp   s     r9   _claim_repair_attemptrF    s     g,,C	  )))        	 ##C(((	                 s   AAAAc                    ddl }ddl}	 ddlm} n# t          $ r d}Y nw xY w|( ||           rt
                              d|            dS |j                                                             d          }| 	                    | j
         d|           }	 |                    | |           dD ]d}| 	                    | j
        |z             }|                                r1|                    ||	                    |j
        |z                        e|S # t          $ r'}t
                              d| |           Y d}~dS d}~ww xY w)	a  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.

    Refuses when a connection to this database is still live in the process:
    reading the file would ``close()`` a descriptor for it and cancel that
    connection's POSIX advisory locks (see ``hermes_cli.sqlite_safe_read``).
    The repair path can be entered by one SessionDB while the gateway holds
    others, so this is a real possibility rather than a theoretical one.
    r   N)has_live_connectionzRefusing to raw-copy %s for backup: a connection to it is still open in this process and the copy would cancel that connection's POSIX locks. Close all SessionDB handles first.z%Y%m%d_%H%M%Sz.malformed-backup--wal-shmz%Could not back up malformed DB %s: %s)datetimeshutilhermes_cli.sqlite_safe_readrH  ImportErrorr  r"  nowstrftime	with_namerL   copy2existsrK   r  )	rC  rL  rM  rH  stampbackup_pathsuffixsidecarr  s	            r9   _backup_db_filerY    s    OOOMMM#CCCCCCC # # #"# &+>+>w+G+G&> 		
 	
 	
 t!!##,,_==E##w|$N$Nu$N$NOOK	Wk***& 	X 	XF''v(=>>G~~ XWk&;&;K<Lv<U&V&VWWW   >MMMttttts%      A>D 
E	"EE	c                   t          |           }|dk    s|                    d          rdS 	 t          t                                                                n# t
          $ r dY nw xY wdt          dt          ffddddt          d	t          ddffd
}| j        }|                                r ||d           dD ]B}|r| 	                    | j
        |z             n| }|                                r ||           CdS )u5  Refuse-or-repair read-only DB files BEFORE the first connection opens.

    Port of Kilo-Org/kilocode#12508's startup preflight. A stray read-only
    ``state.db`` / ``-wal`` / ``-shm`` (sudo run, restored backup, copied
    dotfiles) previously surfaced as an opaque
    ``sqlite3.OperationalError: attempt to write a readonly database`` raised
    from deep inside ``_init_schema`` — naming no file and no fix — and the
    obvious wrong "fix" (deleting the ``-wal``) silently loses committed
    transactions. This preflight:

    - **Repairs** permissions with ``chmod u+rw`` when the file lives inside
      the Hermes home tree (``get_hermes_home()``) — the safe repair scope:
      Hermes owns those files, and the OS makes ``chmod`` fail on files the
      user doesn't own, which bounds the repair exactly.
    - **Fails fast with an actionable error** naming the exact file and the
      exact ``chmod`` command for anything else (root-owned files, read-only
      mounts, custom paths outside the home tree).
    - Never deletes or truncates a WAL sidecar — once writable, the normal
      open path checkpoints its committed frames into the DB as intended.

    ``:memory:`` and ``file:`` URI paths are skipped (no plain on-disk files
    to check). Shared by :class:`SessionDB` and ``hermes_cli.kanban_db``.
    r   file:Nr   r3   c                     dS 	 |                                                                S # t          t          f$ r Y dS w xY wNF)resolveis_relative_torP   rF   )r   homes    r9   _in_repair_scopez2preflight_db_writability.<locals>._in_repair_scopeD  sU    <5	99;;--d333$ 	 	 	55	s   &. AAF)is_dirrb  c                n   dd l }t          j        | t          j        t          j        z            rd S  |           r	 |j        |j        z  |r|j        ndz  }t          j        | |                                  j	        |z             n# t          $ r Y nw xY wt          j        | t          j        t          j        z            r#t                              d| |rdnd           d S |rdnd}| j                            d          rdnd}t          j         d	| d
|  d|rdnd d|  d|           )Nr   z2%s preflight: repaired read-only %s (chmod u+rw%s)xr>   	directoryfilerJ  uv    Do NOT delete the -wal file — it contains committed data that will be merged into the database once it is writable.z is not writable:  zf is read-only for this user. Hermes needs read-write access to open the database. Fix with: chmod u+rwz 'z4' (files owned by another user may need sudo/chown).)statrG   accessR_OKW_OKS_IRUSRS_IWUSRS_IXUSRchmodst_moderP   r  r   rL   endswithr   r   )r   rb  _statr  kindwal_notera  r   s         r9   _ensure_writablez2preflight_db_writability.<locals>._ensure_writableL  s   9Q"')** 	FA 	mem37Uu}}TUVAFFHH,s23333   yBGbg-.. H!)CCr	   $0{{& vv&& D D 	 	 & M M4 M M! M M*0#833bM M<=M M CKM M
 
 	
s    A
B 
BBT)r>   rJ  rK  )rX   r   r   r   r^  rK   boolparentrb  rR  rL   is_file)	rC  r   r   ru  rw  rW  r   ra  r`  s	    `     @@r9   preflight_db_writabilityry    s   8 g,,C
jCNN733#O$5$566>>@@   D T       5: 
 
 
D 
T 
d 
 
 
 
 
 
 
B ^F}} . 	----&    8>KGglV3444G99;; 	 Q   s   -A A.-A.c                 T   t          j        t          |           d          }	 t          |           |                    d                                           |                    d                                          }d |D             }|r1d                    |dd                   |                                 S |                    d                                           d	D ]}	 |                    d
| d| d                                           2# t           j	        $ ru}t                              |          rY d}~`t          |                                          }d|v sd|v rY d}~d| d| cY d}~c |                                 S d}~wt           j        $ r(}d| d| cY d}~c |                                 S d}~ww xY wdt          j                     }	 |                    d           |                    d|dt          j                    f           |                    d|ddt          j                    f           |                    d           n# t           j	        $ r}	 |                    d           n# t           j        $ r Y nw xY wt          |                                          }d|v sd|v rY d}~|                                 dS d|v rY d}~|                                 dS t          |          cY d}~|                                 S d}~ww xY w	 |                                 dS # t           j        $ r-}t          |          cY d}~|                                 S d}~ww xY w# |                                 w xY w)u/  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).
    Nisolation_levelr   zPRAGMA integrity_checkc                     g | ]D}|t          |d                                                    dk    /t          |d                    ES )r   okr4  rz   rs     r9   r   z%_db_opens_cleanly.<locals>.<listcomp>  sE    OOO!qOS1YY__5F5F$5N5NC!II5N5N5Nr;   z;    zSELECT COUNT(*) FROM sessionsmessages_ftsmessages_fts_trigrammessages_fts_cjkzSELECT 1 FROM  WHERE z MATCH '""' LIMIT 1zno such tablezno such columnzfts5 read probe failed on r   _hermes_fts_health_probe_BEGIN IMMEDIATEz>INSERT INTO sessions (id, source, started_at) VALUES (?, ?, ?)_health_probezOINSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)r   _fts_health_probeROLLBACK no such tokenizer: cjk_unicode61)r   r   rX   load_fts5_cjk_extensionr   r   r   r   r   r   	SessionDB_is_fts5_unavailable_errorr   r5  r	  time_nsr   )rC  r   rowsproblems	fts_tabler  r   probe_session_ids           r9   _db_opens_cleanlyr  y  sG    ?3w<<>>>Do 	 %%%*++44666||455>>@@OOtOOO 	+99Xbqb\**F 	

E 	455>>@@@  V )	G )	GI(G WYWWyWWW (****+ G G G 77<< HHHH#hhnn&&"c))-=-D-D HHHHFIFFFFFFFFFFF^ 	

] ( G G G
 GIFFFFFFFFFFFR 	

]G Ht|~~GG	LL*+++LLP!?DIKK@   LL&!6+>	L  
 LL$$$$' 	 	 	Z((((=   c((..""C#%%)9S)@)@ttt 	

 2S88
 ttt 	

 s88OOOOO
 	

)	   	

     3xx

 	

s   BM +M -.DM G+FM 
)F3M 8F?G M G-G4G5M GM /A?I/ .M /L8?JL3J'$L3&J'',L3M -L31M L3L8M 3L88M N!N	/N0N 	NN N'T)backupr  c                

   ddddd}t          |           } |                                 s
|  d|d<   |S t          |           d|d<   d|d	<   |S |r%t          |           }|rt	          |          nd|d
<   	 t          j        t	          |           d          }	 t          |           dD ]4}	 |                    d| d| d            # t
          j	        $ r Y 1w xY w	 |
                                 n# |
                                 w xY wt          |           'd|d<   d|d	<   t                              d|            |S n7# t
          j        $ r%}t                              d|           Y d}~nd}~ww xY w	 t          j        t	          |           d          }	 |                    d           |                                 |
                                 n# |
                                 w xY wt          |           'd|d<   d|d	<   t                              d|            |S n7# t
          j        $ r%}t                              d|           Y d}~nd}~ww xY w	 t          j        t	          |           d          }	 |                    d           |                    d                                          }|D ] \  }}	}
}|                    d||	|f           !|                    d           |                                 |
                                 n# |
                                 w xY wt          |           'd|d<   d|d	<   t                              d|            |S n7# t
          j        $ r%}t                              d|           Y d}~nd}~ww xY w	 t          j        t	          |           d          }	 |                    d           |                    d           |                    d           |                                 |                    d           |
                                 n# |
                                 w xY wt          |           }|'d|d<   d |d	<   t                              d!|            |S ||d<   n.# t
          j        $ r}t	          |          |d<   Y d}~nd}~ww xY w|d         s"t                              d"| |d
                    |S )#a  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}``.
    FN)repairedstrategyrV  r"  z does not existr"  Tr  already_healthyr  rV  r{  r  zINSERT INTO (z) VALUES('rebuild')rebuild_ftsz<state.db FTS indexes rebuilt in place (schema preserved): %sz-state.db FTS in-place rebuild pass failed: %sREINDEXreindex_btreez/state.db B-tree indexes rebuilt via REINDEX: %sz state.db REINDEX pass failed: %szPRAGMA writable_schema=ONzhSELECT type, name, COUNT(*) AS c, MIN(rowid) AS keep FROM sqlite_master GROUP BY type, name HAVING c > 1zFDELETE FROM sqlite_master WHERE type IS ? AND name IS ? AND rowid <> ?zPRAGMA writable_schema=OFFdedup_schemazRstate.db schema repaired by de-duplicating sqlite_master (FTS index preserved): %sz%state.db dedup repair pass failed: %sz9DELETE FROM sqlite_master WHERE name LIKE 'messages_fts%'VACUUMdrop_fts_rebuildzdstate.db schema repaired by dropping FTS schema; indexes will rebuild from messages on next open: %szsstate.db schema repair could not recover %s automatically (backup: %s); manual restore from backup may be required.)r   rT  r  rY  rX   r   r   r  r   r   r   r  r  r5  commitr   r"  )rC  r  reportbpathr   
table_namer  dupestype_rL   _countkeepreasons                r9   repair_state_db_schemar    s   8 	 F 7mmG>> $555w!!)!z.z >((.3 =E


}Ms7||TBBB	 $D))) 
 

LLSzSSJSSS    /    H
 JJLLLLDJJLLLLW%%-!%F:!.F:NNN   M .   M M MFLLLLLLLLM@s7||TBBB	LL###KKMMMJJLLLLDJJLLLLW%%-!%F:!0F:NNA7   M .   @ @ @93????????@Es7||TBBB	LL4555LLF  hjj  .3  )tVTCD$'   
 LL5666KKMMMJJLLLLDJJLLLLW%%-!%F:!/F:NN,-4   M .   E E E>DDDDDDDDE#s7||TBBB	LL4555LLTUUULL5666KKMMMLL"""JJLLLLDJJLLLL"7++>!%F:!3F:NN>?F   M w  # # #c((w# * 
HVM*	
 	
 	

 Ms   8#E C8 0CC8 CC8 CC8 #E 8D8E E<E77E< #H2 $)G" H2 "G888H2 2I&I!!I&*#M; BL+ M; +M8M; ;N/
N**N/3#R+ A(Q ?R+ Q**:R+ %R+ +S:SSa\  
CREATE VIEW IF NOT EXISTS messages_fts_cjk_src AS
    SELECT id, role, content, tool_name, tool_calls
    FROM messages
    WHERE role <> 'tool';

CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts_cjk USING fts5(
    content,
    tool_name,
    tool_calls,
    content='messages_fts_cjk_src',
    content_rowid='id',
    tokenize='cjk_unicode61'
);
a	  
CREATE TRIGGER IF NOT EXISTS messages_fts_cjk_insert AFTER INSERT ON messages
WHEN new.role <> 'tool'
   AND (new.id > COALESCE((SELECT CAST(value AS INTEGER) FROM state_meta
                           WHERE key = 'fts_cjk_rebuild_high_water'), -1)
     OR new.id <= COALESCE((SELECT CAST(value AS INTEGER) FROM state_meta
                            WHERE key = 'fts_cjk_rebuild_progress'), -1))
BEGIN
    INSERT INTO messages_fts_cjk(rowid, content, tool_name, tool_calls)
    VALUES (new.id, new.content, new.tool_name, new.tool_calls);
END;

CREATE TRIGGER IF NOT EXISTS messages_fts_cjk_delete AFTER DELETE ON messages
WHEN old.role <> 'tool'
   AND (old.id > COALESCE((SELECT CAST(value AS INTEGER) FROM state_meta
                           WHERE key = 'fts_cjk_rebuild_high_water'), -1)
     OR old.id <= COALESCE((SELECT CAST(value AS INTEGER) FROM state_meta
                            WHERE key = 'fts_cjk_rebuild_progress'), -1))
BEGIN
    INSERT INTO messages_fts_cjk(messages_fts_cjk, rowid, content, tool_name, tool_calls)
    VALUES ('delete', old.id, old.content, old.tool_name, old.tool_calls);
END;

CREATE TRIGGER IF NOT EXISTS messages_fts_cjk_update
AFTER UPDATE OF content, tool_name, tool_calls, role ON messages
WHEN (old.content IS NOT new.content
    OR old.tool_name IS NOT new.tool_name
    OR old.tool_calls IS NOT new.tool_calls
    OR old.role IS NOT new.role)
   AND (old.id > COALESCE((SELECT CAST(value AS INTEGER) FROM state_meta
                           WHERE key = 'fts_cjk_rebuild_high_water'), -1)
     OR old.id <= COALESCE((SELECT CAST(value AS INTEGER) FROM state_meta
                            WHERE key = 'fts_cjk_rebuild_progress'), -1))
BEGIN
    INSERT INTO messages_fts_cjk(messages_fts_cjk, rowid, content, tool_name, tool_calls)
    SELECT 'delete', old.id, old.content, old.tool_name, old.tool_calls
    WHERE old.role <> 'tool';
    INSERT INTO messages_fts_cjk(rowid, content, tool_name, tool_calls)
    SELECT new.id, new.content, new.tool_name, new.tool_calls
    WHERE new.role <> 'tool';
END;
c                      t          j        d          } | r!t          |                                           S t	                      dz  dz  S )z1Location of the cjk_unicode61 loadable extension.HERMES_FTS5_CJK_SOlibzlibfts5_cjk.so)rG   getenvr   
expanduserr   )envs    r9   fts5_cjk_so_pathr    sI    
)(
)
)C
 &Cyy##%%%u$'777r;   c                  x    t          j        dd                                                                          dvS )zBconfig.yaml ``sessions.cjk_fts`` (default on), via its env bridge.HERMES_CJK_FTS1)0falseoffno)rG   r  r`   r   rf   r;   r9   _cjk_fts_config_enabledr    s:    9%s++113399;; D  r;   c                    t                      sdS t                      }|                                sdS 	 |                     d           	 |                     t          |                     |                     d           n# |                     d           w xY wdS # t          $ r! t                              d|d           Y dS w xY w)uF  Best-effort load of the cjk_unicode61 tokenizer into ``conn``.

    Returns False (never raises) when the .so is absent, the feature is
    disabled via ``sessions.cjk_fts``, or this Python build has extension
    loading compiled out — every caller treats False as "behave exactly as
    before the cjk index existed".
    FTz#fts5_cjk extension load failed (%s)exc_info)	r  r  rT  enable_load_extensionload_extensionrX   rK   r  r  )r   paths     r9   r  r    s     #$$ uD;;== u	""4(((	.D		***&&u----D&&u----t   <dTRRRuus)   B  "B .B  BB   'C
Cc                   (     e Zd ZdZdef fdZ xZS )CompressionSessionClosedErrorz@A durable write targeted a parent already closed by compression.
session_idc                 `    || _         t                                          d|d           d S )NSession zP is closed by compression; adopt its live continuation before appending messages)r  super__init__)selfr  	__class__s     r9   r  z&CompressionSessionClosedError.__init__'  sJ    $Dz D D D	
 	
 	
 	
 	
r;   )r   r   r   r   rX   r  __classcell__)r  s   @r9   r  r  $  sJ        JJ
3 
 
 
 
 
 
 
 
 
 
r;   r  c                       e Zd ZdZdS )CompressionSessionBusyErrorz>A non-owner tried to write while compression owns the session.Nr   rf   r;   r9   r  r  /  s        HHHHr;   r  c                       e Zd ZdZdS )!SessionCompressionInProgressErrora  A concurrent writer collided with a *live* compression lock.

    Split out from :class:`CompressionSessionBusyError` because the two
    conditions that class covers need opposite handling. This one is
    transient: a healthy compressor holds the session for a few seconds and
    the lock row carries its own ``expires_at``, so the write can simply wait
    (see ``_execute_write``'s patience loop). The other case, a compressor
    discovering its own lease is gone, is permanent and must fail fast rather
    than spin out the whole patience budget.

    Subclassing keeps every existing ``except CompressionSessionBusyError``
    handler working unchanged.
    Nr   rf   r;   r9   r  r  3  s           r;   r  c                     	 ddl m} nJ# t          $ r= t                              d|            t          j        t          |           fi |cY S w xY w || f|t
          j        d|S )a  ``sqlite3.connect`` that registers the open fd for lock-safety.

    While a connection is live, byte-level probes of the same file are
    refused: an ``open()``/``close()`` cancels every POSIX advisory lock this
    process holds on it -- including a running VACUUM's EXCLUSIVE lock.
    Released automatically on ``close()``.

    The ONLY tolerated fallback is the helper being absent entirely
    (scaffold/embed installs that ship hermes_state without hermes_cli). A
    real connection failure must propagate: silently retrying an *untracked*
    connect would disable the guard for the lifetime of that connection,
    which is precisely the failure mode this module exists to prevent.
    r   )connect_trackedzihermes_cli.sqlite_safe_read unavailable; opening %s untracked (byte-probe guard inactive in this install))tracking_path
connect_fn)rN  r  rO  r  debugr   r   rX   )r  r  kwargsr  s       r9   _connect_tracked_dbr  C  s    4??????? 4 4 4:	
 	
 	

 s4yy33F333334 ?#?  	  s   	 AAAd   probe_bytesforcer  r  r  c                \   	 ddl m}  || ||          S # t          $ r Y nw xY w	 |                                 j        }n# t
          $ r Y dS w xY w|dk    rdS ddlm}  || t          d|          |          }|r|	                    d          rdS t          d	 |D                       S )
a  Detect the #68474 zeroed state.db signature (size>0, NUL header).

    Byte-level probe, so it is only safe BEFORE any connection to *path*
    exists in this process: ``close()`` cancels every POSIX advisory lock the
    process holds on the file, which can pull the EXCLUSIVE lock out from
    under a running VACUUM and corrupt the database. The read is routed
    through ``read_header_bytes_preopen``, which refuses (returning False
    here) once a connection is live. Pass ``force=True`` only for offline
    files -- quarantined copies, snapshots, archives.

    Prefer ``hermes_cli.backup.is_zeroed_sqlite_file`` when available; this
    local copy keeps SessionDB openable without importing the CLI package
    in constrained embed paths.
    r   )is_zeroed_sqlite_filer  F)read_header_bytes_preopen   )lengthr  s   SQLite format 3c              3   "   K   | ]
}|d k    V  dS )r   Nrf   )rz   bytes     r9   r   z%is_zeroed_state_db.<locals>.<genexpr>  s&      **Ttqy******r;   )hermes_cli.backupr  rK   rh  st_sizerP   rN  r  maxr   all)r  r  r  r  sizer  r   s          r9   is_zeroed_state_dbr  f  s   ";;;;;;$$T{%PPPP   yy{{"   uuqyyuEEEEEE$$S[))  D  4??#566 u**T******s    
##A 
AAc           
      r   ddl }|                     | j        dz             }|j                            dd           |                    d          }d}	 t          j                    dz   }|                                d	k    rddl	}	 	 |
                    d           |                    |                                |j        d
           d}n># t          $ r0 t          j                    |k    rY nt          j        d           Y nw xY wnddl}	 	 |                    |                                |j        |j        z             d}nE# t(          t          f$ r0 t          j                    |k    rY nt          j        d           Y nw xY w~|st*                              d|            	 	 |r|                                d	k    rHddl	}|
                    d           |                    |                                |j        d
           n1ddl}|                    |                                |j                   n# t          t2          f$ r Y nw xY w|                                 dS # |                                 w xY w|                                 st*                              d|            	 	 |r|                                d	k    rHddl	}|
                    d           |                    |                                |j        d
           n1ddl}|                    |                                |j                   n# t          t2          f$ r Y nw xY w|                                 dS # |                                 w xY wt;          |           st*                              d|            	 	 |r|                                d	k    rHddl	}|
                    d           |                    |                                |j        d
           n1ddl}|                    |                                |j                   n# t          t2          f$ r Y nw xY w|                                 dS # |                                 w xY w	 t          j        d          }n# t>          $ r d}Y nw xY w|                     | j         d| dtA          j!                     d          }	d}
|	                                rP|
d
z  }
|                     | j         d| dtA          j!                     d|
 d          }	|	                                P	 | "                    |	           n# t          $ r }t*                              d| |           Y d}~	 |r|                                d	k    rHddl	}|
                    d           |                    |                                |j        d
           n1ddl}|                    |                                |j                   n# t          t2          f$ r Y nw xY w|                                 dS # |                                 w xY wd}~ww xY wdD ]y}tG          tI          |           |z             }|                                rD	 |"                    tG          tI          |	          |z                        i# t          $ r Y uw xY wz|		 |r|                                d	k    rHddl	}|
                    d           |                    |                                |j        d
           n1ddl}|                    |                                |j                   n# t          t2          f$ r Y nw xY w|                                 S # |                                 w xY w# 	 |r|                                d	k    rHddl	}|
                    d           |                    |                                |j        d
           n1ddl}|                    |                                |j                   n# t          t2          f$ r Y nw xY w|                                 w # |                                 w xY wxY w)a`  Move a zeroed state.db aside (preserve bytes) and return quarantine path.

    Uses a cross-process lock (``#68805``) so two concurrent startups cannot
    race: the first process moves the zeroed file and the second re-checks
    under the lock, finding the file already gone (or a fresh DB in its place)
    instead of clobbering the quarantine.
    r   Nz.quarantine.lockTparentsexist_okza+bF      @Windowsr?   {Gz?u   quarantine lock for %s not acquired within 5s — refusing to quarantine without the cross-process lock. The zeroed file is left in place. If sessions fail to load, restore from state-snapshots via `hermes snapshot list` / `hermes snapshot restore <id>`.z?quarantine_zeroed_state_db: %s already moved by another processznquarantine_zeroed_state_db: %s is no longer zeroed (another process quarantined it and a fresh DB was created)z%Y%m%d-%H%M%Sunknownz.zeroed--z.bakz"Failed to quarantine zeroed %s: %srI  )%r   rR  rL   rw  mkdiropenr	  	monotonicr   msvcrtseeklockingfilenoLK_NBLCKrP   r
  fcntlflockLOCK_EXLOCK_NBBlockingIOErrorr  r"  LK_UNLCKLOCK_UNAttributeErrorr   rT  r   r  rQ  rK   rG   rH   renamer   rX   )r  r   	lock_pathhandleacquireddeadliner  r  tsdestnr  rW  sides                 r9   quarantine_zeroed_state_dbr	    s 	    OOOty+==>>I4$777^^E""FHa>##c)??	))MMM	&&KKNNNNN6==??FOQGGG#H & & &~''833Ju%%%%%&	& LLL&&KK1NOOO#H'1 & & &~''833Ju%%%%%&&  	 LL2
    `	 @??$$	11!MMMKKNNNNN6==??FOQGGGG LLLKK???( 	 	 	D	 LLNNNNNFLLNNNNs {{}} 	KKQ   P	 @??$$	11!MMMKKNNNNN6==??FOQGGGG LLLKK???( 	 	 	D	 LLNNNNNFLLNNNNg "$'' 	KKE  
 B	 @??$$	11!MMMKKNNNNN6==??FOQGGGG LLLKK???( 	 	 	D	 LLNNNNNFLLNNNNW	//BB 	 	 	BBB	 ~~y88"88ry{{888
 
 kkmm 	FA>>9@@b@@29;;@@@@@ D kkmm 	
	KK 	 	 	LL=tSIII444	 @??$$	11!MMMKKNNNNN6==??FOQGGGG LLLKK???( 	 	 	D	 LLNNNNNFLLNNNN3	 ' 	 	FD		F*++D{{}} KKSYY%7 8 89999   D
 	 @??$$	11!MMMKKNNNNN6==??FOQGGGG LLLKK???( 	 	 	D	 LLNNNNFLLNNNN	 @??$$	11!MMMKKNNNNN6==??FOQGGGG LLLKK???( 	 	 	D	 LLNNNNFLLNNNNs  3_ 
AC _ !D
1_ 3D
_ 	D


_ 7E _ (F5_ 7F_ F!_ 1BI I2 II2 II2 2J/_ <BM M= M$!M= #M$$M= =N*_ BQ R Q*'R )Q**R RR2 1_ 2S>_  SB_ U6 4_ 6ZY<_ "BX6 5Y# 6Y
Y# 	Y

Y# #Y9<Z:_ <2[/._ /
[<9_ ;[<<_ B^_^*'_)^**__b6Ba10b1bbbbb6b33b6c            4          e Zd ZU dZdZdZdZdZdZdZ	dZ
d	Zd
ZdZdZdZdZdZdZdZdZdZedee         dee         fd            Zedd            Zedej        deeef         fd            Zdde de!fdZ"deej#                 fdZ$e%d             Z&ed ej'        de!fd!            Z(ed ej'        de!fd"            Z)ed#ej*        de!fd$            Z+d ej'        ddfd%Z,d ej'        ddfd&Z-dd'Z.ed#ej*        ddfd(            Z/d#ej*        d)ed*ede!fd+Z0	 dd,e1ej#        ge2f         d-ee3         de2fd.Z4d/e3d-e3de!fd0Z5ed ej6        de!fd1            Z7d ej6        de!fd2Z8dd3Z9d4 Z:dZ;d5Z<d6Z=d7Z>de!fd8Z?	 	 	 	 	 	 	 	 	 	 	 	 dd9ed:ed;ed<eeef         ded=ed>ee         d?ed@edAedBedCedDedEeddfdFZ@d9ed:edefdGZAdddddddddHd9ed:ed=ed>ed?ed@edAedIedJedKe!ddfdLZBdd9edNe!ddfdOZCdPdQd>edRedSeddfdTZDdPdQdUeeef         dSeddfdVZEdPdQdSedeeef         fdWZFdPdQdXeGe         dSeddfdYZHddMdZd[ee         d\e!deGeeef                  fd]ZIddd^d[ed?edAee         d=ee         dee         f
d_ZJdddddd`d:ed=ee         d>ee         d?ee         d@ee         dAee         deeeef                  fdaZKdBedeeeef                  fdbZLdddddddMdcdBedded:edeeGeeef                  d;ed<eeef         dedCedDedfedge!ddfdhZMd9edieddfdjZNd9eddfdkZO	 dd9edmede!fdnZP	 dd9edCedoedEeddf
dpZQdqeeef         ddfdrZR	 dd9edse3dtee         ddfduZSd9edeeeef                  fdvZTd9edeeef         fdwZUd9edxeeef         ddfdyZVd9eddfdzZWd9edeXfd{ZYd9ed|eXddfd}ZZd9edeXfd~Z[d9edeXddfdZ\	 dd9edede3de!fdZ]	 dd9edede3de!fdZ^d9ededdfdZ_d9edee         fdZ`	 ddddd9edee3         dee         deea         ddf
dZbd9eddfdZcd9edeeeef                  fdZd	 dd9eded;ee         ddfdZed9edee         ddfdZfd9ed;eddfdZgddddddd9ed;ee         dee         deeeef                  dee         de!ddfdZhd9ede!ddfdZiedeeeef                  de!fd            Zjddd9edededee         ddf
dZkdZldZmdZnd9eddfdZodde3de!fdZpddZqdeGereeeef         f                  ddfdZsdeGereeeef         f                  deGereeeef         f                  fdZtdde3ddfdZuddZv	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 dd9edeXdeXd;edeXdeXdeXdee3         dee3         dee         dee         dee         dee         dee         dee         deXde!ddf$dZwdPdd9ed;ee         dee         dee         dee         deXdeXdeXdeXdeXdee3         dee3         dee         dee         deXdeddf"dZx	 	 dd9ed:ed;edefdÄZyddddddddddĜ	d9eded;ee         dee         dee         deXdeXdeXdeXdeXdee3         ddfdńZzddddeXfdȄZ{deXfdɄZ|d9edeeeef                  fdʄZ}dedee         fd̄Z~dZedee         dee         fdτ            Zdedede!fd҄Zd9edede!de!fdԄZd9edede!fdՄZd9edede!fdքZd9edee         fdׄZd9ede!de!fdلZd9ede!de!fdۄZdedeeeef                  fd܄Zdedee         fd݄Zdedefd߄Zd9edee         fdZ eddh          ZdZee         ed<   	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 dd:edeGe         deGe         dedeXdeXde!deXde!de!de!de!dedede!de!d>edeGeeef                  f$dZdZededefd            Zededefd            Zededee         fd            Zd9edfee         ddfdZededeeeef                  fd            Z	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 dd9ededededed edeXdededededededed	e!d
ee         dedee         dee         deeeef                  dfee         deXf,dZ	 	 dd9edeeGeeef                  dfee         deeX         deXf
dZddd9ededededeeeef                  de!fdZdZddd9edeXdee         dedeeGeeef                           f
dZd9edeXdeGeeef                  fdZddd9ededeGeeef                  fdZdddMdd9ededeXde!deeX         f
dZd9edeeX         fdZd9ed eXdee         fd!Zd9edeeGeeef                  deeXeXf         fd"Z	 dd9edeeGeeef                  d\e!ddfd#Zd9ede!fd$Zd9ed%eGeeef                  deXfd&Zd9edededeXfd'Z	 	 	 dd9ed(e!deeX         deXdeGeeef                  f
d)Z	 dd9ed+eXd,eXdeeef         fd-Zd9edefd.Z	 	 	 	 dd9ed/e!d(e!d0e!d1e!deGeeef                  fd2Zd3Zdd4d9ed/e!d0e!d1e!deGeeef                  f
d5Zd9edereGeeef                  eGeeef                  f         fd6Zd9edeGeeef                  fd7Zd9edefd8Zd9edeGe         fd9ZedeeGeeef                  d:eeef         de!fd;            Zd9ed<eXdeeef         fd=Zd9ed>eXdeXfd?Z	 	 	 	 dd:edeXdeXd@edeGeeef                  f
dAZ	 	 	 	 	 	 	 	 dd:edeGe         dedeXde!de!dBe!deGe         deXfdCZddEeXde!fdFZddddGde!de!dBe!deeeXf         fdHZdd9edeXfdIZd9edede!fdJZdKeeef         de!fdLZdMeeef         de!fdNZd9edeGe         fdOZd9eddfdPZedee          d9eddfdQ            Zd9edeGe         fdRZ	 	 dd9edee          dSeeGe                  de!fdTZ	 dd9edee          de!fdUZ	 ddVeGe         dee          deXfdWZdeXfdXZ	 ddee          deXfdYZeddddddddddddddddddddddddZd[ee3         d\ee3         d]ee3         d^ee3         d:ee         d_ee         diee         dee         d`eeX         daeeX         dee!         dbee         dee         d=ee         d?ee         d@ee         dcee         ddeeX         deeeX         dfee3         dgee3         dheeX         dieeX         dereef         f0dj            Z	 	 ddkee3         d:edeGeeef                  fdlZ	 	 ddkee3         d:edeXfdmZdMdndoe3dpe!deXfdqZ	 	 	 ddkee3         d:edee          deXfdsZːdtedee         fduZddvdtedwed#eej*                 ddfdxZ͐dyedeXfdzZΐdd{Zddd|d?ed=ed}ee!         d~ee!         ddf
dZdMdd?ede!ddfdZd?ed=ede!fdZd?edAedeeeef                  fdZd?edeGeeef                  fdZd9edeeeef                  fdZd?edAedeXfdZ֐ddd?edAed=ed>ed9ededdfdZd9ede!fdZؐddd?ed=edeXdeGeeef                  fdZِdZdeeX         fdZdeXfdZ	 	 	 	 	 ddeXdeXde!dee          deXdeeef         fdZ	 	 	 ddoe3deXdpe!deeef         fdZd9ed[ede!fdZd9edeeeef                  fdZdeGeeef                  fdZd9ede!fdZd9eddfdZd9edteddfdZdS (  r  z
    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.
    g      4@g      N@g      ?r  r  g333333?g       @g      ?      ?2   i       i'  iP  i  P i  r2   r3   c                 \    |d S t          |          }|                     d||f           |S )NzAINSERT OR IGNORE INTO system_prompts (hash, prompt) VALUES (?, ?))r:   r   )r   r2   prompt_hashs      r9   _store_system_promptzSessionDB._store_system_promptT  sC     4)-88O-(	
 	
 	
 r;   Nc                 0    |                      d           d S )Nz|DELETE FROM system_prompts WHERE NOT EXISTS (SELECT 1 FROM sessions WHERE sessions.system_prompt_hash = system_prompts.hash)r   r   s    r9   #_delete_unreferenced_system_promptsz-SessionDB._delete_unreferenced_system_prompts_  s'    	
 	
 	
 	
 	
r;   r[   c                 h    t          |           }d|v r|                    d          }d|v r||d<   |S )N_system_prompt_resolvedr2   )r   pop)r[   dataresolveds      r9   _session_row_dictzSessionDB._session_row_dicti  sD    Cyy$,,xx 9::H$&&(0_%r;   FrC  	read_onlyc                     |pt                       _        | _        t          j                     _        t          j                     _        t                       _	        t          j                     _
        d _        d _        d _        d _        d _        d _        d _        d _        d _        d _        d  _        t-                       _        t          j        t          j                               _        d  _        d _        d _        	 |rt;          d j         d j        dddd            _        t<          j         j        _         	 tC           j        d	            j        "                                } #                    |d
          du  _         j        r #                    |d          du  _        nD# tH          $ r7  j        d c} _        	 |%                                 n# tL          $ r Y nw xY w w xY wd S  j        j'        (                    dd           |stS           j        d	           |s j        *                                rtW           j                  r	  j        ,                                j-        }n# t\          $ r d}Y nw xY wt_           j                  } j        j'        dz  }d| d|pd d| d}t`          1                    |           te          |           |A j        *                                r(tW           j                  rt=          j3        |           fd fd}		  |	             d S # t<          j3        $ r}
ti          |
          rtk           j                  s t`          1                    d|
           	  j         j        %                                 n# tL          $ r Y nw xY wtm           j                  }|7                    d          s  |	             Y d }
~
d S d }
~
ww xY w# tL          $ r,}
te          tq          |
          j9         d|
             d }
~
ww xY w)NFr   r[  ?mode=roTr  )r  uricheck_same_threadtimeoutr|  r   r#  r  r  r  zstate-snapshotszstate.db looks ZEROED (z( bytes, no SQLite header). Preserved at u*   (quarantine failed — file left in place)z. Restore from z via `hermes snapshot list` / `hermes snapshot restore <id>` if available. Opening a fresh empty database so the agent can start.c                     t          t           j                  ddd            _        t          j         j        _        t           j        d          dk     _        t           j        d            j        
                    d           t           j                   _                                          d S )NFr  )r  r   r|  r   r#  r   zPRAGMA foreign_keys=ON)r  rX   rC  _connr   Rowrow_factoryr  _wal_activer0  r   r  _fts_cjk_loaded_init_schemar  s   r9   _connect_and_initz-SessionDB.__init__.<locals>._connect_and_init  s    0%%&+   %)  
 *1
&+DJLLLPUU   'tzJGGGG
""#;<<<'>tz'J'J$!!#####r;   c            
         t          j                    j        z   } 	 	               d S # t          j        $ r}t          |                                          }d|vrd|vr 	 j        j                                         n# t          $ r Y nw xY wt          j                    }|| k    r t          j
        t          t          j        j        j                  t!          | |z
  d                               Y d }~nd }~ww xY w)NTlockedbusyMbP?)r	  r  _WRITE_PATIENCE_Sr   r   rX   r   r#  r   rK   r
  minrandomuniform_WRITE_RETRY_SLOW_MIN_S_WRITE_RETRY_SLOW_MAX_Sr  )r  r  errrP  r*  r  s       r9   $_connect_and_init_with_lock_patiencez@SessionDB.__init__.<locals>._connect_and_init_with_lock_patience  sO     >++d.DD))+++"3   !#hhnn..#3..63D3D!!#z5 $
 0 0 2 2 2( ! ! ! D!"n..(??!
 &$($@$($@!" !" !$HsNE : :        	s9   
+ D
*D% BD
BDBA-DD
u`   state.db schema is malformed (%s) — attempting automatic repair (a backup copy is made first).r  r   ):r   rC  r  	threadingLock_locklocal_read_localr   _read_conns_read_conns_lock_read_conns_closedr&  _write_count_fts_runtime_rebuild_attempted_fts_usermerge_floor_applied_fts_enabled_trigram_availabler'  _fts_cjk_available_fts_unavailable_warnedr#  r   _token_queue	Condition_token_queue_cond_token_writer_thread_token_writer_stop_token_writer_busyr  r   r$  r%  r0  r   _fts_table_probeBaseExceptionr   rK   rw  r  ry  rT  r  rh  r  rP   r	  r  r"  r   r5  r8  rF  r  r_   typer   )r  rC  r  r   r   zsizeqpathsnapsr   r6  r  r  r*  s   `           @r9   r  zSessionDB.__init__r  sB   4"2"4"4"^%%
 %?,,
 7:ee ) 0 0 #(  /4+ -2)!"'
  %"'',$
 $)77!*!4Y^5E5E!F!F@D!"'"'D	 0 12DL222"&,&+$(  
 *1
&*4:
KKKK!Z..00F--fnEEM % (  11 & 6   $	$ / %   '+z4$D$*

$    L%%dT%BBB  L(
KKKK 5L''))5 't|445
 L--//7EE   EEE24<@@+.??Me M M$)$Y-YM M$)M M M  S!!!$S))) =T\%8%8%:%:=?QRVR^?_?_=!/444$ $ $ $ $,$ $ $ $ $ $L74466666( 7 7 7 -S11 9Nt|9\9\ <=@  z-
((***    D/==zz*-- 44666666666-7>  	 	 	 !DII$6!?!?#!?!?@@@	s   A P# A0G P# H&G;:H;
HHHHP# A(P# ;J P# J)&P# (J))B+P# 
M! !P 0?P0 OP
OPO7PP# P  P# #
Q-'QQc                    | j         r| j        rdS t          | j        dd          }||S t          | j        dd          rdS 	 t	          d| j         d| j        ddd	          }t          j        |_        t          |d
           | j
        rt          |           | j        5  | j        r.|                                 d| j        _        	 ddd           dS | j                            |           ddd           n# 1 swxY w Y   nD# t          j        $ r2 d| j        _        t&                              d| j        d           Y dS w xY w|| j        _        |S )uw  Per-thread read-only connection, or None when unavailable.

        Only used under WAL: WAL readers see a consistent snapshot and never
        block on (or get blocked by) the writer, so recall/browse queries can
        skip self._lock entirely. Under DELETE journal mode (NFS fallback) a
        reader can hit SQLITE_BUSY storms during writes, so we keep the
        legacy locked single-connection path there.

        Fresh read transactions begin per statement (autocommit), so each
        query observes everything committed so far — read-your-writes holds
        for the flush-then-search patterns in a turn.
        Nr   failedFr[  r  Tr  )r  r  r   r|  r   r#  z'read-only connection open failed for %sr  )r&  r  r>  r;  r  rC  r   r$  r%  r0  r'  r  r=  r>  r   rS  r<  r  r   r  r  r   r  r   s     r9   _get_read_connzSessionDB._get_read_connj  s     	4> 	4t'66K4#Xu55 	4	&...."l $  D  '{D"4*====
 # .'---& + +*   JJLLL.2D$++ + + + + + + +  $$T***+ + + + + + + + + + + + + + + } 	 	 	 '+D#LLBDL[_L```44	 !%sC   A!D %)DD D5D DD D	D =EEc              #      K   |                                  }||V  dS | j        5  | j        V  ddd           dS # 1 swxY w Y   dS )u  Yield a connection for read-only statements.

        WAL: a per-thread read-only connection with NO lock — recall queries
        never convoy behind writer flushes (the gateway shares one SessionDB
        across every agent, so this lock was a global choke point).
        Non-WAL or read-conn failure: the shared writer connection under
        self._lock, byte-for-byte the legacy behavior.
        N)rU  r9  r#  rT  s     r9   	_read_ctxzSessionDB._read_ctx  s       ""$$JJJFZ 	 	*	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	s   
=AAr  c                 t    t          |                                           }d|v rd|v rdS d|v rdS d|v rdS dS )Nzno such modulefts5Tno such tokenizer: trigramr  Fr4  r  r5  s     r9   r  z$SessionDB._is_fts5_unavailable_error  sX    #hhnns""v}}4 (3..4 .444ur;   c                 T    t          |                                           }d|v pd|v S )u  True when only an optional tokenizer is missing (FTS5 itself works).

        Covers the built-in trigram tokenizer (needs SQLite >= 3.34) and the
        loadable cjk_unicode61 tokenizer — both mean "this one index can't be
        served here", never "disable FTS".
        rZ  r  r4  r[  s     r9   _is_trigram_unavailable_errorz'SessionDB._is_trigram_unavailable_error  s3     #hhnn(C/ 91S8	
r;   r   c                     |                      d                                          }|dS t          |t          j                  s|d         n|d         pd}d|vS )u  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.
        zLSELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'messages_fts'NFr   sqlr>   	tool_name)r   r   rW   r   r$  )r   r[   r_  s      r9   _db_has_legacy_inline_ftsz#SessionDB._db_has_legacy_inline_fts  sj     nn=
 
 (** 	 ;5'W[99Is1vvs5zPb #%%r;   c                     t          | dd          rdS d| _        t                              d| j        t
          j        |           dS )zHLog once that the trigram tokenizer is missing; base FTS5 stays enabled._trigram_unavailable_warnedFNTzSQLite trigram tokenizer unavailable for %s (requires SQLite >= 3.34, this build is %s); CJK/substring search will fall back to LIKE: %s)r>  rc  r  r   rC  r   r  r  r  s     r9   _warn_trigram_unavailablez#SessionDB._warn_trigram_unavailable  sY    46>> 	F+/(> L"	
 	
 	
 	
 	
r;   c                 v    d| _         | j        rd S d| _        t                              d| j        |           d S )NFTzSQLite 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))rB  rE  r  r  rC  rd  s     r9   _warn_fts5_unavailablez SessionDB._warn_fts5_unavailable  sQ    !' 	F'+$% L	
 	
 	
 	
 	
r;   c                    t          |                    d                                                    }| j        s|rd |                    dd                    d t
          D                        dt
                                                    D             }|r`t                              dt                                 |                    dt          f           |D ]}|                    d	|            d
| _        dS 	 |                    t                     |s|                    dt          f           |                    d                                          d         }|dk    r^|                    d                                          d         }dt          |          fdfD ]\  }}|                    d||f           |                    dt          f                                          }	|	r	d
| _        dS |                    t                     |                    d                                          }
|
 | _        dS # t           j        $ r' t                              dd           d
| _        Y dS w xY w)u  Create / repair / self-heal the CJK-bigram index surface.

        ``cursor`` may be a Cursor or a Connection (both expose execute /
        executescript). Called only for v23-shape DBs with the base FTS
        surface healthy. Sets ``self._fts_cjk_available``. Never raises;
        every failure mode degrades to "no cjk index" (trigram/LIKE routing
        keeps working).

        Cases:
          tokenizer loaded, table absent  → create. Empty DB: index is
              complete by construction (triggers cover everything). Populated
              DB: set the cjk backfill markers so the id-gated triggers stay
              correct and `optimize-storage` can backfill; the index is NOT
              served until the backfill completes.
          tokenizer loaded, table present → ensure triggers (recreates any
              dropped by a tokenizer-less process), honour the stale
              breadcrumb (serve only when absent and no backfill pending).
          tokenizer NOT loaded, table present with live triggers → drop the
              cjk triggers so message INSERTs don't fail at trigger time,
              and leave the stale breadcrumb (#self-heal). The table itself
              stays for a later capable open to rebuild.
        zNSELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'messages_fts_cjk'c                     g | ]
}|d          S r   rf   r  s     r9   r   z4SessionDB._ensure_fts_cjk_schema.<locals>.<listcomp>$	  s)       AaD  r;   zCSELECT name FROM sqlite_master WHERE type = 'trigger' AND name IN (r}   c              3      K   | ]}d V  dS r~   Nrf   rz   r  s     r9   r   z3SessionDB._ensure_fts_cjk_schema.<locals>.<genexpr>'	  s"      0P0P0P0P0P0P0P0Pr;   r   u
  messages_fts_cjk triggers present but the cjk_unicode61 tokenizer is unavailable (%s) — dropping the cjk triggers so message writes keep working. CJK search falls back to trigram/LIKE; run `hermes sessions optimize-storage` on a host with the extension to rebuild.z^INSERT INTO state_meta (key, value) VALUES (?, '1') ON CONFLICT(key) DO UPDATE SET value = '1'DROP TRIGGER IF EXISTS FNz$DELETE FROM state_meta WHERE key = ?z2SELECT COUNT(*) FROM messages WHERE role <> 'tool'r   z)SELECT COALESCE(MAX(id), 0) FROM messagesfts_cjk_rebuild_high_water)fts_cjk_rebuild_progressr  gINSERT INTO state_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.valuez&SELECT 1 FROM state_meta WHERE key = ?zISELECT 1 FROM state_meta WHERE key = 'fts_cjk_rebuild_high_water' LIMIT 1z@messages_fts_cjk ensure failed; CJK search stays on trigram/LIKETr  )rv  r   r   r'  r   r   r   r  r  r  r!   rD  executescriptFTS_CJK_TABLE_SQLrX   FTS_CJK_TRIGGER_SQLr   r   )r  r   cjk_presentlivetrign_msgshwkvstalebackfill_pendings              r9   _ensure_fts_cjk_schemaz SessionDB._ensure_fts_cjk_schema	  s'   . 6>>,
 
 (** 
 # 	 I "(..T(+0P0P>O0P0P0P(P(PT T T)# # hjj    I
 NN9 )**   NNE*,  
 !% I I'G'G'GHHHH&+D#F7	,  !2333  :&(    H (**Q  A::C hjj$B 6s2ww?9!  1 TF   
 NN8"$  hjj    +0'  !4555%~~C    hjj  +;&:D###' 	, 	, 	, NN)-     ',D####	,s   D I 	A	I 2J
	J
c                 v    t           D ]0}	 |                     d|            # t          j        $ r Y -w xY wd S )Nrn  )r   r   r   r   )r   triggers     r9   _drop_fts_triggerszSessionDB._drop_fts_triggers|	  sa    $ 	 	GBBBCCCC+   	 	s   $66r  ddlc                 L   |                      ||          }|dS 	 |                    |           dS # t          j        $ ra}|                     |          s |                     |          r|                     |           n|                     |           Y d }~dS d }~ww xY w)NFT)rL  rr  r   r   r  r]  re  rg  )r  r   r  r  statusr  s         r9   _ensure_fts_schemazSessionDB._ensure_fts_schema	  s     &&vz::>5	   %%%4' 
	 
	 
	22377  11#66 1..s3333++C00055555
	s   3 B#ABB#fn
patience_sc                 t   || j         }t          j                    |z   }d}dt          j        dt
          fd}	 	 | j        5  | j                            d           	  || j                  }| j        	                                 n:# t          $ r- 	 | j                                         n# t          $ r Y nw xY w w xY w	 ddd           n# 1 swxY w Y   | xj        dz  c_        | j        | j        z  dk    r|                                  | j        | j        z  dk    r|                                  |S # t$          $ rK |)t'          t          j                    | j        z   |          }|                     || j                  rY T t          j        $ r}t/          |                                          }d	|v sd
|v r6|                     ||          rY d}~t          j        d|dd          | ||          r|                     ||          rY d}~ d}~wt          j        $ rH} ||          r|                     ||          rY d}~#|                     |          s Y d}~?d}~wt          j        $ r-} ||          r|                     ||          rY d}~x d}~ww xY w)u  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 jitter, and retry — breaking the convoy pattern that
        SQLite's built-in deterministic backoff creates.

        *patience_s* is the total time budget for lock retries (default
        ``_WRITE_PATIENCE_S``).  Transcript-critical writes pass
        ``_TRANSCRIPT_WRITE_PATIENCE_S`` so a sibling process holding the
        lock for a legitimate long operation (VACUUM, TRUNCATE checkpoint,
        pre-bounded-merge FTS optimize from an older still-running
        install) exhausts routine writers' patience without destroying a
        user turn.  Jitter starts small (20-150ms) for fast reclaim on
        millisecond contention and backs off to 250ms-1s once the lock has
        been held longer than ``_WRITE_RETRY_SLOW_AFTER_S``.

        Returns whatever *fn* returns.
        Nr  r3   c                 H    dt          |                                           v S )Nzno more rows availabler4  r7  s    r9   _is_no_more_rowsz2SessionDB._execute_write.<locals>._is_no_more_rows	  s    +s3xx~~/?/???r;   Tr  r?   r   r,  r-  zQdatabase is locked (another Hermes process held the state.db write lock for over z.0fu   s — likely a long maintenance operation such as VACUUM, a large WAL checkpoint, or an older pre-update process; the database itself is healthy))r/  r	  r  r   r   rv  r9  r#  r   r  rM  rollbackrK   r?  _CHECKPOINT_EVERY_N_WRITES_try_wal_checkpoint_FTS_MERGE_EVERY_N_WRITES_try_incremental_merge_ftsr  r0  _COMPRESSION_BUSY_WAIT_S_sleep_before_write_retryr   rX   r   r5  _try_runtime_fts_rebuild)	r  r  r  r  compression_deadliner  resultr  err_msgs	            r9   _execute_writezSessionDB._execute_write	  s   : /J>##j0 15	@'- 	@D 	@ 	@ 	@ 	@R	QZ 
 
J&&'8999!#DJ
))++++(   ! J//1111( ! ! ! D! ,	
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 !!Q&!!$t'FF!KK,,...$t'EEJJ335554    (/+.((4+HH(, ,( 11($*G   H+   c((..**w&&&G*;*;55h
KK !  "2C8BNC C C   $#C(( T-K-KHV`-a-a HHHH(   ##C(( T-K-KHV`-a-a HHHH 44S99 =    $#C(( T-K-KHV`-a-a HHHHs   D: C)BC
B?B-,B?-
B:7B?9B::B??CD: CD: CA"D: :AJ7J7?H#!;H#"H##J75!I8I88J7
!J21J22J7r  c           	      F   t          j                    }||k    rdS |||z
  z
  }|| j        k    r t          j        | j        | j                  }nt          j        | j        | j                  }t          j	        t          |t          ||z
  d                               dS )a  Sleep one jitter interval if the patience budget still allows it.

        Returns True when the caller should retry, False when *deadline* has
        passed and the error should propagate. Jitter stays small for the
        first ``_WRITE_RETRY_SLOW_AFTER_S`` (fast reclaim on millisecond
        contention) and backs off after that, and never overshoots the
        deadline by a full slow-jitter.
        Fr.  T)r	  r  _WRITE_RETRY_SLOW_AFTER_Sr1  r2  r3  r4  _WRITE_RETRY_MIN_S_WRITE_RETRY_MAX_Sr
  r0  r  )r  r  r  rP  elapsedjitters         r9   r  z#SessionDB._sleep_before_write_retry!
  s     n(??5J./d444^,, FF
 ^'' F 	
3vs8c>599::;;;tr;   c                 v    t          |           rdS t          |                                           }d|v od|v S )a  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.
        TrY  corrupt)r8  rX   r   )r  r   s     r9   _is_fts_write_corruption_errorz(SessionDB._is_fts_write_corruption_error=
  sB     !%% 	4#hhnn}1c!11r;   c                    | j         rdS | j        sdS |                     |          sdS d| _         t                              d|           	 |                                 }n3# t          $ r&}t                              d|           Y d}~dS d}~ww xY w|st                              d           dS t                              d|           dS )ub  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.
        FTu   state.db write failed with an FTS-corruption error (%s) — attempting one-shot in-place FTS rebuild; canonical message rows are preserved.zkIn-place FTS rebuild failed (%s); the database needs the full offline repair path (repair_state_db_schema).NzpIn-place FTS rebuild made no progress; the database needs the full offline repair path (repair_state_db_schema).zFstate.db FTS indexes rebuilt in place (%d); retrying the failed write.)r@  rB  r  r  r  r  rK   r"  )r  r  rebuiltrebuild_excs       r9   r  z"SessionDB._try_runtime_fts_rebuildM
  s     . 	5  	522377 	5.2+"#&	
 	
 	

	&&((GG 	 	 	LLE  
 55555	  	LLI   5T	
 	
 	
 ts   A" "
B,BBc                 r   	 | j         5  | j                            d                                          }|r4|d         dk    r(t                              d|d         |d                    ddd           dS # 1 swxY w Y   dS # t          $ r&}t                              d|           Y d}~dS d}~ww xY w)u	  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).
        zPRAGMA wal_checkpoint(PASSIVE)r?   r   z(WAL checkpoint: %d/%d pages checkpointedr  Nz#WAL checkpoint (PASSIVE) failed: %s)r9  r#  r   r   r  r  rK   r  )r  r  r  s      r9   r  zSessionDB._try_wal_checkpoint~
  s"   "	G  ++4 (**   fQi!mmLLBq	6!9                     	G 	G 	GNN@#FFFFFFFFF	Gs;   B A#A9,B 9A==B  A=B 
B6B11B6c                    |                                   t          j        | j                   | j        5  d| _        t          | j                  }| j                                         ddd           n# 1 swxY w Y   |D ]'}	 |	                                 # t          $ r Y $w xY wd| j        _        | j        5  | j        ru| j        sN	 | j                            d           n2# t          $ r%}t"                              d|           Y d}~nd}~ww xY w| j        	                                 d| _        ddd           dS # 1 swxY w Y   dS )a;  Close the database connection.

        Drains queued token deltas first (the background writer needs the
        connection). Writable connections then attempt a TRUNCATE WAL
        checkpoint so exiting writer processes help shrink the WAL file.
        Read-only connections never request a checkpoint.
        TNPRAGMA wal_checkpoint(TRUNCATE)z-WAL checkpoint (TRUNCATE) at close failed: %s)_stop_token_writeratexit
unregister_drain_token_queue_at_exitr=  r>  r   r<  clearr   rK   r;  r   r9  r#  r  r   r  r  )r  
read_connsr   r  s       r9   r   zSessionDB.close
  s    	!!! 	$9::: " 	% 	%&*D#d.//J""$$$	% 	% 	% 	% 	% 	% 	% 	% 	% 	% 	% 	% 	% 	% 	%  	 	D

    $Z 	" 	"z 
"~ 
**+LMMMM$   K       
 
  """!
	" 	" 	" 	" 	" 	" 	" 	" 	" 	" 	" 	" 	" 	" 	" 	" 	" 	"s_   5A66A:=A:B
B('B(>EC)(E)
D3DED#EEEg      @g?fts_v22_trash_c           	          t          |                    d| j                            dd          dz   f                                                    S )zTrue when demoted v22 shadow tables are still awaiting teardown.
        Caller must hold ``self._lock`` (or pass a migration-time cursor).zSSELECT 1 FROM sqlite_master WHERE type = 'table' AND name LIKE ? ESCAPE '\' LIMIT 1r  \_%)rv  r   _FTS_TRASH_PREFIXreplacer   rT  s     r9   _has_fts_trashzSessionDB._has_fts_trash  sT     DLL2#++C77#=?
 
 (**	  	r;   r  sourcemodelrc   user_idsession_keychat_id	chat_type	thread_idparent_session_idr^   profile_namer]   c                     	
 	 
fd}                      | j                   dS )u  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).

        When ``parent_session_id`` is set (compression fork, delegate/subagent
        spawn, branch continuation) and this row's own ``cwd``/``git_repo_root``/
        ``git_branch``/``profile_name`` are still NULL after the insert, they are
        backfilled from the parent row. Callers of ``create_session`` for a child
        session historically didn't propagate these fields themselves (e.g. the
        compression-fork path), so a lineage could silently lose its working
        directory and drop out of the project sidebar every time it forked
        (#64709), or lose its owning profile and be aggregated as "default" every
        time it rotated or branched (the cross-profile session-jump bug). This
        only fills NULLs — an explicit value on the child is never overwritten.
        For compression forks specifically
        (parent ended with ``end_reason='compression'``), the gateway origin
        columns (``user_id``/``session_key``/``chat_id``/``chat_type``/
        ``thread_id``/``display_name``/``origin_json``) are inherited too, so a
        crash before the gateway re-records the peer can't strand the child
        without a recoverable routing mapping (#59527).
        c                 ^   
                     |           }|                     drt          j                  nd |	t	          j                    f           |
                    |            r0|                     df           |                     df           d S d S )Na  INSERT INTO sessions (
                   id, source, user_id, session_key, chat_id, chat_type, thread_id,
                   model, model_config, system_prompt, system_prompt_hash,
                   parent_session_id, cwd, profile_name, git_repo_root, started_at
                )
                   VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?)
                   ON CONFLICT(id) DO UPDATE SET
                       model = COALESCE(sessions.model, excluded.model),
                       model_config = COALESCE(sessions.model_config, excluded.model_config),
                       system_prompt_hash = COALESCE(
                           sessions.system_prompt_hash,
                           excluded.system_prompt_hash
                       ),
                       system_prompt = CASE
                           WHEN sessions.system_prompt_hash IS NULL
                                AND excluded.system_prompt_hash IS NOT NULL
                           THEN NULL
                           ELSE sessions.system_prompt
                       END,
                       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),
                       git_repo_root = COALESCE(sessions.git_repo_root, excluded.git_repo_root)a  UPDATE sessions
                       SET cwd = COALESCE(sessions.cwd,
                                 (SELECT p.cwd FROM sessions p
                                   WHERE p.id = sessions.parent_session_id)),
                           git_repo_root = COALESCE(sessions.git_repo_root,
                                           (SELECT p.git_repo_root FROM sessions p
                                             WHERE p.id = sessions.parent_session_id)),
                           git_branch = COALESCE(sessions.git_branch,
                                        (SELECT p.git_branch FROM sessions p
                                          WHERE p.id = sessions.parent_session_id)),
                           profile_name = COALESCE(sessions.profile_name,
                                          (SELECT p.profile_name FROM sessions p
                                            WHERE p.id = sessions.parent_session_id))
                     WHERE id = ? AND parent_session_id IS NOT NULLa  UPDATE sessions
                       SET user_id = COALESCE(sessions.user_id,
                                     (SELECT p.user_id FROM sessions p
                                       WHERE p.id = sessions.parent_session_id)),
                           session_key = COALESCE(sessions.session_key,
                                         (SELECT p.session_key FROM sessions p
                                           WHERE p.id = sessions.parent_session_id)),
                           chat_id = COALESCE(sessions.chat_id,
                                     (SELECT p.chat_id FROM sessions p
                                       WHERE p.id = sessions.parent_session_id)),
                           chat_type = COALESCE(sessions.chat_type,
                                       (SELECT p.chat_type FROM sessions p
                                         WHERE p.id = sessions.parent_session_id)),
                           thread_id = COALESCE(sessions.thread_id,
                                       (SELECT p.thread_id FROM sessions p
                                         WHERE p.id = sessions.parent_session_id)),
                           display_name = COALESCE(sessions.display_name,
                                          (SELECT p.display_name FROM sessions p
                                            WHERE p.id = sessions.parent_session_id)),
                           origin_json = COALESCE(sessions.origin_json,
                                         (SELECT p.origin_json FROM sessions p
                                           WHERE p.id = sessions.parent_session_id))
                     WHERE id = ? 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'
                       ))r  r   jsondumpsr	  r  )r   system_prompt_hashr  r  r^   r]   r  rc   r  r  r  r  r  r  r2   r  r  s     r9   _doz*SessionDB._insert_session_row.<locals>._doD  s   !%!:!:4!O!OLLc8 0<FDJ|,,,$&% !IKK9- - -\ "-88>>>  ;G  M  8 8  M;    ;; ;r;   r  N)r  _TRANSCRIPT_WRITE_PATIENCE_S)r  r  r  r  rc   r2   r  r  r  r  r  r  r^   r  r]   r  s   ``````````````` r9   _insert_session_rowzSessionDB._insert_session_row  s    hm	 m	 m	 m	 m	 m	 m	 m	 m	 m	 m	 m	 m	 m	 m	 m	 m	 m	 m	b 	CD,MNNNNNr;   c                 $     | j         ||fi | |S )z4Create a new session record. Returns the session_id.r  )r  r  r  r  s       r9   create_sessionzSessionDB.create_session  s%      V>>v>>>r;   )r  r  r  r  r  display_nameorigin_jsoninclude_compression_ancestorsr  r  r  c       	   
      l   	
 rsdS 
	f
d}|                      |           dS )a  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.

        ``include_compression_ancestors`` keeps a logical compression lineage
        on one routing peer when an explicit gateway resume moves its tip to a
        different lane. Normal per-turn metadata refreshes update only the
        supplied row.
        Nc           
         
 d}d}g }rd}d}|                     	           |                    
f           s|                     	           |                     | d| |           d S )Nr>   zWHERE id = ?a  
                    WITH RECURSIVE compression_lineage(id) AS (
                        SELECT ?
                        UNION
                        SELECT parent.id
                        FROM compression_lineage lineage
                        JOIN sessions child ON child.id = lineage.id
                        JOIN sessions parent ON parent.id = child.parent_session_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'
                    )
                z0WHERE id IN (SELECT id FROM compression_lineage)a9  
                   UPDATE sessions
                   SET session_key = ?, source = ?, user_id = ?, chat_id = ?,
                       chat_type = ?, thread_id = ?,
                       display_name = COALESCE(?, display_name),
                       origin_json = COALESCE(?, origin_json)
                   )r   extendr   )r   lineage_ctetarget_clausequery_paramsr  r  r  r  r  r  r  r  r  r  s       r9   r  z2SessionDB.record_gateway_session_peer.<locals>._do  s    K*ML, 0( !S##J/// 	   1 0##J///LL  & & "& & 	 	 	 	 	r;   r  )r  r  r  r  r  r  r  r  r  r  r  r  s    `````````` r9   record_gateway_session_peerz%SessionDB.record_gateway_session_peer  s    6  	 	F2	 2	 2	 2	 2	 2	 2	 2	 2	 2	 2	 2	 2	 2	h 	C     r;   T	finalizedc                 H    sdS fd}|                      |           dS )zMark 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).
        Nc                 @    |                      drdndf           d S )Nz5UPDATE sessions SET expiry_finalized = ? WHERE id = ?r?   r   r  )r   r  r  s    r9   r  z+SessionDB.set_expiry_finalized.<locals>._do  s7    LLG&Q
3    r;   r  )r  r  r  r  s    `` r9   set_expiry_finalizedzSessionDB.set_expiry_finalized  sM      	F	 	 	 	 	 	 	C     r;   r>   )scope
entry_jsonr  c                P    rsdS fd}|                      |           dS )u  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.
        Nc                 ^    |                      dt          j                    f           d S )Na!  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r   r	  )r   r  r  r  s    r9   r  z1SessionDB.save_gateway_routing_entry.<locals>._do3  s<    LL;
 Z=    r;   r  )r  r  r  r  r  s    ``` r9   save_gateway_routing_entryz$SessionDB.save_gateway_routing_entry#  sZ      	* 	F	 	 	 	 	 	 	 	C     r;   entriesc                j    t          j                     fd}|                     |           dS )a7  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.
        c                     |                      df           r7|                     dfd                                D                        d S d S )Nz+DELETE FROM gateway_routing WHERE scope = ?z\INSERT INTO gateway_routing (scope, session_key, entry_json, updated_at) VALUES (?, ?, ?, ?)c                 *    g | ]\  }}||	||fS rf   rf   )rz   rz  r{  rP  r  s      r9   r   zJSessionDB.replace_gateway_routing_entries.<locals>._do.<locals>.<listcomp>Q  s0    OOODAqqOQOeQ3'OOOr;   )r   executemanyitems)r   r  rP  r  s    r9   r  z6SessionDB.replace_gateway_routing_entries.<locals>._doK  su    LLFQQQ   *OOOOOGMMOOOOO     r;   Nr	  r  )r  r  r  r  rP  s    `` @r9   replace_gateway_routing_entriesz)SessionDB.replace_gateway_routing_entries?  sQ     ikk	 	 	 	 	 	 	 	C     r;   c                    | j         5  | j                            d|f                                          }ddd           n# 1 swxY w Y   d |D             S )z>Load routing entries for *scope* as {session_key: entry_json}.zCSELECT session_key, entry_json FROM gateway_routing WHERE scope = ?Nc                 ,    i | ]}|d          |d         S )r  r  rf   r  s     r9   
<dictcomp>z:SessionDB.load_gateway_routing_entries.<locals>.<dictcomp>]  s#    @@@a- !L/@@@r;   )r9  r#  r   r   )r  r  r  s      r9   load_gateway_routing_entriesz&SessionDB.load_gateway_routing_entriesV  s    Z 	 	:%%U  hjj 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	
 A@4@@@@   /AA
Asession_keysc                H    sdS fd}|                      |           dS )z=Remove routing entries for the given session keys in *scope*.Nc                 L    |                      dfdD                        d S )Nz?DELETE FROM gateway_routing WHERE scope = ? AND session_key = ?c                     g | ]}|fS rf   rf   )rz   rz  r  s     r9   r   zISessionDB.delete_gateway_routing_entries.<locals>._do.<locals>.<listcomp>i  s    222%222r;   )r  )r   r  r  s    r9   r  z5SessionDB.delete_gateway_routing_entries.<locals>._dof  s?    Q2222\222    r;   r  )r  r  r  r  s    `` r9   delete_gateway_routing_entriesz(SessionDB.delete_gateway_routing_entries_  sM      	F	 	 	 	 	 	 	C     r;   )r   active_onlyr   r  c                Z                                       dt          d           d}g }|r|dz  }|                    |           |r|dz  }|dz  } j        5   j                            ||                                          }ddd           n# 1 swxY w Y    fd|D             S )	uF  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.
        z
            SELECT sessions.*,
                   COALESCE(sp.prompt, sessions.system_prompt)
                       AS _system_prompt_resolved,
                   sessionsag   AS last_active
            FROM sessions
            LEFT JOIN system_prompts sp
              ON sp.hash = sessions.system_prompt_hash
            WHERE session_key IS NOT NULL
              AND started_at = (
                  SELECT MAX(s2.started_at) FROM sessions s2
                  WHERE s2.session_key = sessions.session_key
              )
        z AND LOWER(source) = LOWER(?)z AND ended_at IS NULLz ORDER BY last_active DESCNc                 :    g | ]}                     |          S rf   r  rz   r  r  s     r9   r   z3SessionDB.list_gateway_sessions.<locals>.<listcomp>  s'    888a&&q))888r;   )flush_token_countsr   r   r9  r#  r   r   )r  r   r  queryparamsr  s   `     r9   list_gateway_sessionszSessionDB.list_gateway_sessionsn  s/    	!!! -Z88	    	$44EMM(### 	-,,E--Z 	@ 	@:%%eV44==??D	@ 	@ 	@ 	@ 	@ 	@ 	@ 	@ 	@ 	@ 	@ 	@ 	@ 	@ 	@888848888s   .BBB)r  r  c                n   |r|dv rdS d}|t          |          g}|'|dz  }|                    t          |                     |dz  }| j        5  d | j                            ||                                          D             }ddd           n# 1 swxY w Y   |sdS rAfd|D             }|rt          |d         d	                   S t          |          d
k    rdS n4t          |          d
k    r!d |D             }	t          |	          d
k    rdS t          |d         d	                   S )a  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.
        )Nr>   Nz
            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
        z  AND COALESCE(thread_id, '') = ?z ORDER BY started_at DESCc                 ,    g | ]}t          |          S rf   r   r  s     r9   r   z4SessionDB.find_session_by_origin.<locals>.<listcomp>  s    RRRDGGRRRr;   c                 ~    g | ]9}t          |                    d           pd          t                    k    7|:S r  r>   )rX   r_   )rz   r  r  s     r9   r   z4SessionDB.find_session_by_origin.<locals>.<listcomp>  sB    TTT1AEE),<,<,B(C(Cs7||(S(SQ(S(S(Sr;   r   r   r?   c                     h | ]n}t          |                    d           pd                                          8t          |                    d           pd                                          oS r  )rX   r_   r`   r  s     r9   r|   z3SessionDB.find_session_by_origin.<locals>.<setcomp>  sx       quuY''-2..4466AEE)$$*++1133  r;   )rX   r   r9  r#  r   r   r   )
r  r   r  r  r  r  r  r  exactdistinct_userss
       `     r9   find_session_by_originz SessionDB.find_session_by_origin  s      	7j004 !#g,,/ 77EMM#i..))),,Z 	S 	SRRTZ%7%7v%F%F%O%O%Q%QRRRD	S 	S 	S 	S 	S 	S 	S 	S 	S 	S 	S 	S 	S 	S 	S 	4 	TTTTTTTE +58D>***4yy1}}t YY]]   N
 >""Q&&t474=!!!s   8BBB)r  r  r  r  r  c          	         |sdS | j         5  | j                            d||f                                          }|!|                     |          cddd           S ||	 ddd           dS | j                            d|||||f                                          }ddd           n# 1 swxY w Y   |r|                     |          ndS )a  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.
        Na  
                SELECT s.*,
                       COALESCE(sp.prompt, s.system_prompt)
                           AS _system_prompt_resolved
                FROM sessions s
                LEFT JOIN system_prompts sp ON sp.hash = s.system_prompt_hash
                WHERE s.session_key = ?
                  AND s.source = ?
                  AND (s.ended_at IS NULL OR s.end_reason IN ('agent_close', 'ws_orphan_reap'))
                  AND (COALESCE(s.message_count, 0) > 0 OR EXISTS (
                      SELECT 1 FROM messages WHERE messages.session_id = s.id LIMIT 1
                  ))
                ORDER BY s.started_at DESC
                LIMIT 1
                a  
                SELECT s.*,
                       COALESCE(sp.prompt, s.system_prompt)
                           AS _system_prompt_resolved
                FROM sessions s
                LEFT JOIN system_prompts sp ON sp.hash = s.system_prompt_hash
                WHERE s.source = ?
                  AND COALESCE(s.user_id, '') = COALESCE(?, '')
                  AND COALESCE(s.chat_id, '') = COALESCE(?, '')
                  AND COALESCE(s.chat_type, '') = COALESCE(?, '')
                  AND COALESCE(s.thread_id, '') = COALESCE(?, '')
                  AND (s.ended_at IS NULL OR s.end_reason IN ('agent_close', 'ws_orphan_reap'))
                  AND (COALESCE(s.message_count, 0) > 0 OR EXISTS (
                      SELECT 1 FROM messages WHERE messages.session_id = s.id LIMIT 1
                  ))
                ORDER BY s.started_at DESC
                LIMIT 1
                )r9  r#  r   r   r  )r  r  r  r  r  r  r  r[   s           r9   $find_latest_gateway_session_for_peerz.SessionDB.find_latest_gateway_session_for_peer  sv   *  	4Z /	 /	*$$ f%! " hjj# $ --c22)/	 /	 /	 /	 /	 /	 /	 /	2 )"35/	 /	 /	 /	 /	 /	 /	 /	6 *$$$ '9i@' ( hjj) 7/	 /	 /	 /	 /	 /	 /	 /	 /	 /	 /	 /	 /	 /	 /	` /2;t%%c***t;s   AB/B/12B//B36B3c                    |sdS | j         5  | j                            d|f                                          }||d         |d         dk    r	 ddd           dS | j                            d|f                                          }ddd           n# 1 swxY w Y   t          |          dk    r|                     |d                   ndS )	a  Return the unique live direct child of a compression-ended session.

        A stale agent may observe that another compression path already rotated
        its parent. Recovery is safe only when the durable lineage identifies
        exactly one live direct continuation. Multiple children are treated as
        ambiguous and fail closed rather than guessing which transcript owns
        subsequent messages.
        N6SELECT ended_at, end_reason FROM sessions WHERE id = ?ended_at
end_reasoncompressiona  
                SELECT s.*,
                       COALESCE(sp.prompt, s.system_prompt)
                           AS _system_prompt_resolved
                FROM sessions s
                LEFT JOIN system_prompts sp ON sp.hash = s.system_prompt_hash
                WHERE s.parent_session_id = ?
                  AND s.ended_at IS NULL
                  AND json_extract(COALESCE(s.model_config, '{}'), '$._branched_from') IS NULL
                  AND json_extract(COALESCE(s.model_config, '{}'), '$._delegate_from') IS NULL
                  AND COALESCE(s.source, '') != 'tool'
                ORDER BY s.started_at ASC
                LIMIT 2
                r?   r   )r9  r#  r   r   r   r   r  )r  r  rw  r  s       r9   find_live_compression_childz%SessionDB.find_live_compression_child  sJ    ! 	4Z 	 	Z''H"$  hjj 
 *%-,'=88	 	 	 	 	 	 	 	 :%% #$   hjj! 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	8 36d))q..t%%d1g...dJs   AB.BB B)r  rc   r2   r^   r  compression_lock_holderrequire_compression_leasechild_session_idr   r
  r  c                h    	
 
	 fd}                      |           dS )a:  Atomically close a parent and publish its durable compression child.

        The parent closure, child row, and compacted handoff become visible in
        one transaction. Readers can therefore observe either the live parent or
        a complete child, never an ended parent with a missing/empty child.
        c                    |                      df                                          }rL|8r6|d         k    s*t          |d                   t          j                    k    rt	          d           |                      df                                          }|t          d           |d         t          d           
st          d	                              |           }|                      d
rt          j                  nd |	p|d         |d         |d         p|d         |d         |d         |d         |d         |d         |d         |d         t          j                    f           	                    | 
          \  }}|                      d||f           |                      dt          j                    f          }|j
        dk    rt          d           d S )NESELECT holder, expires_at FROM compression_locks WHERE session_id = ?r<   
expires_atz+Compression lease lost before publication: zSELECT ended_at, cwd, git_branch, git_repo_root,
                          user_id, session_key, chat_id, chat_type,
                          thread_id, display_name, origin_json, profile_name
                   FROM sessions WHERE id = ?zCompression parent not found: r  z"Compression parent already ended: z+Compression child handoff must not be emptya  INSERT INTO sessions (
                   id, source, model, model_config, system_prompt,
                   system_prompt_hash,
                   parent_session_id, cwd, git_branch, git_repo_root,
                   profile_name, user_id, session_key, chat_id, chat_type,
                   thread_id, display_name, origin_json, started_at
                ) VALUES (?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)r^   
git_branchr]   r  r  r  r  r  r  r  r  GUPDATE sessions SET message_count = ?, tool_call_count = ? WHERE id = ?z^UPDATE sessions SET ended_at = ?, end_reason = 'compression' WHERE id = ? AND ended_at IS NULLr?   z/Compression parent changed during publication: )r   r   floatr	  r  RuntimeErrorr  r  r  _insert_message_rowsrowcount)r   lock_rowrw  r  total_messagestotal_tool_callsupdatedr  r
  r^   r   r  rc   r  r  r  r  r  r2   s          r9   r  z0SessionDB.publish_compression_child.<locals>._doR  s   ||W"$  hjj  )  . !H%)@@@,/00DIKK??1UBSUU   \\1 #$  hjj  ~"#WDU#W#WXXXj!-"#[HY#[#[\\\ R"#PQQQ!%!:!:4!O!OLLY %0<FDJ|,,,$&%(6%=<(?+ !:F>$:9%=)9%;';'>*=)IKK/! ! !D 04/H/H&0 0,N, LLY!13CD   ll4/0 G
 1$$"YFWYY   %$r;   Nr  )r  r  r  r  r   r  rc   r2   r^   r  r
  r  r  s   ```````````` r9   publish_compression_childz#SessionDB.publish_compression_child=  s    *N	 N	 N	 N	 N	 N	 N	 N	 N	 N	 N	 N	 N	 N	 N	 N	` 	C     r;   r  c                 @    fd}|                      |           dS )a  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.
        c                 \    |                      dt          j                    f           d S )NzRUPDATE sessions SET ended_at = ?, end_reason = ? WHERE id = ? AND ended_at IS NULLr  )r   r  r  s    r9   r  z"SessionDB.end_session.<locals>._do  s8    LL4j*5    r;   Nr  )r  r  r  r  s    `` r9   end_sessionzSessionDB.end_session  s>    	 	 	 	 	 	 	C     r;   c                 <    fd}|                      |           dS )z6Clear ended_at/end_reason so a session can be resumed.c                 6    |                      df           d S )NzCUPDATE sessions SET ended_at = NULL, end_reason = NULL WHERE id = ?r  r   r  s    r9   r  z%SessionDB.reopen_session.<locals>._do  s+    LLU    r;   Nr  r  r  r  s    ` r9   reopen_sessionzSessionDB.reopen_session  s8    	 	 	 	 	
 	C     r;   session_resetr  c                     sdS t          j                     fd}	 |                     |          }t          |          S # t          $ r Y dS w xY w)u  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).
        Fc                 D    |                      df          }|j        S )NzUPDATE sessions SET ended_at = ?, end_reason = ? WHERE id = ? AND (ended_at IS NULL OR end_reason IN ('agent_close', 'ws_orphan_reap'))r   r  )r   r   rP  r  r  s     r9   r  z/SessionDB.promote_to_session_reset.<locals>._do  s2    \\F fj)	 F ?"r;   )r	  r  rv  rK   )r  r  r  r  r  rP  s    ``  @r9   promote_to_session_resetz"SessionDB.promote_to_session_reset  s    <  	5ikk	# 	# 	# 	# 	# 	# 	#	&&s++D:: 	 	 	55	s   #A 
AAr  c                   	 |r|sdS |pd                                 }|pd                                 }dg	|g|r*	                    d                               |           |r*	                    d                               |                               |           	fd}|                     |           dS )u!  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.
        Nr>   zcwd = ?zgit_branch = ?zgit_repo_root = ?c                 b    |                      dd                               d           d S )NzUPDATE sessions SET z, z WHERE id = ?)r   r   )r   r  setss    r9   r  z)SessionDB.update_session_cwd.<locals>._do  s4    LLN		$NNNPVWWWWWr;   )r`   r   r  )
r  r  r^   r  r]   branch	repo_rootr  r  r+  s
           @@r9   update_session_cwdzSessionDB.update_session_cwd  s    "  	 	F"))++"(b//11	{ E 	"KK()))MM&!!! 	%KK+,,,MM)$$$j!!!	X 	X 	X 	X 	X 	X 	C     r;   cwd_to_rootc                     d |                                 D             sdS fd}|                     |           dS )a$  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).
        c                 $    g | ]\  }}||	||fS rf   rf   )rz   r^   ra   s      r9   r   z1SessionDB.backfill_repo_roots.<locals>.<listcomp>  s*    RRRdTRcR$RRRr;   Nc                 H    D ]\  }}|                      d||f           d S )NzXUPDATE sessions SET git_repo_root = ? WHERE cwd = ? AND COALESCE(git_repo_root, '') = ''r  )r   ra   r^   pairss      r9   r  z*SessionDB.backfill_repo_roots.<locals>._do!  sH    "  	cI3K    r;   )r  r  )r  r/  r  r3  s      @r9   backfill_repo_rootszSessionDB.backfill_repo_roots  sg     SRk.?.?.A.ARRR 	F	 	 	 	 	 	C     r;   cooldown_untilr"  c                     sdS fd}	 |                      |           dS # t          j        $ r'}t                              d|           Y d}~dS d}~ww xY w)z>Persist the active compression-failure cooldown for a session.Nc                 :    |                      df           d S )NfUPDATE sessions SET compression_failure_cooldown_until = ?, compression_failure_error = ? WHERE id = ?r  )r   r5  r"  r  s    r9   r  z:SessionDB.record_compression_failure_cooldown.<locals>._do5  s2    LL=
3    r;   z2record_compression_failure_cooldown(%s) failed: %sr  r   r   r  r  )r  r  r5  r"  r  r  s    ```  r9   #record_compression_failure_cooldownz-SessionDB.record_compression_failure_cooldown+  s      	F	 	 	 	 	 	 		$$$$$} 	 	 	NNDC        	s   ' AAAc                    |sdS t          j                     }| j        5  | j                            d|f                                          }ddd           n# 1 swxY w Y   |dS t          |t          j                  r|d         n|d         }|dS t          |          }||k    rdS t          |t          j                  r|d         n|d         }|||z
  |dS )zBReturn the active compression-failure cooldown for ``session_id``.N_SELECT compression_failure_cooldown_until, compression_failure_error FROM sessions WHERE id = ?"compression_failure_cooldown_untilr   compression_failure_errorr?   )r5  remaining_secondsr"  )	r	  r9  r#  r   r   rW   r   r$  r  )r  r  rP  r[   r5  r"  s         r9    get_compression_failure_cooldownz*SessionDB.get_compression_failure_cooldownD  sW   
  	4ikkZ 	 	*$$-  hjj	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 ;4 #w{++C455Q 	
 !4~..S  4 #w{++C+,,Q 	 -!/#!5
 
 	
s   /AA!Ac                    |sddddS | j         5  | j                            d|f                                          }ddd           n# 1 swxY w Y   |ddddS t	          |t
          j                  r|d         n|d         }t	          |t
          j                  r|d         n|d         }d	|t          |          nd|dS )
aG  Return the exact stored cooldown columns without expiry filtering.

        Compression cancellation uses this under its session lease so rollback
        can preserve an expired row, a partially-null row, or an absent session
        exactly instead of converting those states through the active-cooldown
        API.
        FNsession_existsr5  r"  r<  r=  r   r>  r?   T)r9  r#  r   r   rW   r   r$  r  )r  r  r[   r5  r"  s        r9   $get_compression_failure_cooldown_rowz.SessionDB.get_compression_failure_cooldown_rowi  sF     	T&+tdSSSZ 	 	*$$-  hjj	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 ;&+tdSSS #w{++C455Q 	 #w{++C+,,Q 	 #)7)Cn%%%
 
 	
s   /AAAsnapshotc                    t          |                    dd                    }|s<|                               }|                    dd          rt          d          dS |                    d          |                    d          fd}|                     |           |                               }dt                    ndd	}||k    rt          d
|d|          dS )a(  Restore and verify an exact cooldown-row snapshot.

        Unlike the ordinary record/clear helpers, this transactional rollback
        API deliberately propagates write and verification failures. A caller
        must not report cancellation as mutation-free when compensation failed.
        rC  FzBcannot restore absent compression cooldown row: session now existsNr5  r"  c                 t    |                      df          }|j        dk    rt          d           d S )Nr8  r?   z/compression cooldown rollback session missing: )r   r  r  )r   r   r  r"  r  s     r9   r  z?SessionDB.restore_compression_failure_cooldown_row.<locals>._do  sY    \\=5*- F
 !##"RjRR   $#r;   TrB  z<compression cooldown rollback verification failed: expected=z	, actual=)rv  r_   rD  r  r  r  )	r  r  rE  expected_existsr  r  expectedr  r"  s	    `     @@r9   (restore_compression_failure_cooldown_rowz2SessionDB.restore_compression_failure_cooldown_row  sJ    x||,<eDDEE 	>>zJJFzz*E22 "X   F<< 011W%%		 		 		 		 		 		 		 	C   :::FF"191EeHooo4
 

 X<$< <17< <   r;   c                     sdS fd}	 |                      |           dS # t          j        $ r'}t                              d|           Y d}~dS d}~ww xY w)z?Clear any persisted compression-failure cooldown for a session.Nc                 6    |                      df           d S )NzlUPDATE sessions SET compression_failure_cooldown_until = NULL, compression_failure_error = NULL WHERE id = ?r  r!  s    r9   r  z9SessionDB.clear_compression_failure_cooldown.<locals>._do  s.    LL@    r;   z1clear_compression_failure_cooldown(%s) failed: %sr9  )r  r  r  r  s    `  r9   "clear_compression_failure_cooldownz,SessionDB.clear_compression_failure_cooldown  s     	F	 	 	 	 		$$$$$} 	 	 	NNCC        	s   # AAAc                    |sdS | j         5  | j        }|	 ddd           dS |                    d|f                                          }ddd           n# 1 swxY w Y   |dS t	          |t
          j                  r|d         n|d         }	 t          dt          |pd                    S # t          t          f$ r Y dS w xY w)z3Return the persisted deterministic-fallback streak.r   Nz=SELECT compression_fallback_streak FROM sessions WHERE id = ?compression_fallback_streakr9  r#  r   r   rW   r   r$  r  rC   rE   rF   r  r  r   r[   rU   s        r9   get_compression_fallback_streakz)SessionDB.get_compression_fallback_streak  sM    	1Z 	 	:D|	 	 	 	 	 	 	 	 ,,O  hjj 		 	 	 	 	 	 	 	 	 	 	 	 	 	 	 ;1 #w{++C-..Q 	
	q#ejq//***:& 	 	 	11	(   A)AA AB2 2CCstreakc                     sdS t          dt          |                    fd}|                     |           dS )z:Persist the deterministic-fallback streak for one session.Nr   c                 8    |                      df           d S )Nz@UPDATE sessions SET compression_fallback_streak = ? WHERE id = ?r  r   
normalizedr  s    r9   r  z6SessionDB.set_compression_fallback_streak.<locals>._do  s.    LLRZ(    r;   r  rC   r  )r  r  rT  r  rX  s    `  @r9   set_compression_fallback_streakz)SessionDB.set_compression_fallback_streak  s`     	FCKK((
	 	 	 	 	 	 	C     r;   c                    |sdS | j         5  | j        }|	 ddd           dS |                    d|f                                          }ddd           n# 1 swxY w Y   |dS t	          |t
          j                  r|d         n|d         }	 t          dt          |pd                    S # t          t          f$ r Y dS w xY w)a  Return the persisted ineffective-compaction strike count.

        Mirrors ``get_compression_fallback_streak``: this is the durable half
        of the anti-thrash guard (``_ineffective_compression_count`` on the
        built-in compressor), persisted so that a fresh compressor bound to a
        resumed session inherits an armed/tripped guard instead of starting
        from zero across process restarts (#54923).
        r   Nz?SELECT compression_ineffective_count FROM sessions WHERE id = ?compression_ineffective_countrP  rQ  s        r9   !get_compression_ineffective_countz+SessionDB.get_compression_ineffective_count  sO     	1Z 	 	:D|	 	 	 	 	 	 	 	 ,,Q  hjj 		 	 	 	 	 	 	 	 	 	 	 	 	 	 	 ;1 #w{++C/00Q 	
	q#ejq//***:& 	 	 	11	rS  countc                     sdS t          dt          |                    fd}|                     |           dS )z@Persist the ineffective-compaction strike count for one session.Nr   c                 8    |                      df           d S )NzBUPDATE sessions SET compression_ineffective_count = ? WHERE id = ?r  rW  s    r9   r  z8SessionDB.set_compression_ineffective_count.<locals>._do  s.    LLTZ(    r;   rY  )r  r  r^  r  rX  s    `  @r9   !set_compression_ineffective_countz+SessionDB.set_compression_ineffective_count  s`     	FCJJ''
	 	 	 	 	 	 	C     r;        r@r<   ttl_secondsc                 
   rsdS t          j                     }||z   fd}	 t          |                     |                    S # t          j        $ r'}t
                              d|           Y d}~dS d}~ww xY w)u  Extend the compression lock lease if ``holder`` still owns it.

        Ownership is decided by the ``holder`` column alone, deliberately NOT
        by ``expires_at``: a live owner whose refresher thread was starved
        (GC pause, loaded CI runner, a slow write escaping ``_execute_write``'s
        retry budget) past its own TTL must be able to revive its still-unclaimed
        row on the next tick. Requiring ``expires_at >= now`` here made such a
        stall permanent — every later refresh matched 0 rows, so the owner kept
        compressing and rotating with no lease at all, which is exactly the
        unprotected window a competing path can fork the session lineage in.

        This does not resurrect a lock somebody else already took: SQLite
        serialises writes, so a reclaim (DELETE-expired + INSERT-or-IGNORE in
        :meth:`try_acquire_compression_lock`) and this UPDATE never interleave.
        Reclaim-first replaces ``holder``, so this UPDATE matches nothing and
        returns False; refresh-first pushes ``expires_at`` into the future, so
        the reclaimer's DELETE-expired matches nothing and its acquire fails.
        Fc                 L    |                      df          }|j        dk    S )NzOUPDATE compression_locks SET expires_at = ? WHERE session_id = ? AND holder = ?r   r'  )r   curr  r<   r  s     r9   r  z/SessionDB.refresh_compression_lock.<locals>._doX  s4    ,,6Z0 C
 <!##r;   z'refresh_compression_lock(%s) failed: %sN)r	  rv  r  r   r   r  r  )r  r  r<   rc  rP  r  r  r  s    ``    @r9   refresh_compression_lockz"SessionDB.refresh_compression_lock;  s    0  	 	5ikk;&
	$ 	$ 	$ 	$ 	$ 	$ 	$	++C00111} 	 	 	NN9C   55555	s   !A BA==Bc                 P  	 sdS t          j                     		|z   	fd}	 |                     |          \  }}|rt                              d|           t	          |          S # t
          j        $ r'}t                              d|           Y d}~dS d}~ww xY w)u  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.
        Structured holders whose local ``pid=`` no longer exists are reclaimed
        immediately, so a gateway killed during compression does not stall the
        replacement process for the full lease TTL.

        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.
        Fc                 V   d }|                      d	f                                          }|t          |t          j                  r|d         n|d         }t          |t          j                  r|d         n|d         }|k     st          |          r|                      d	|f           |}|                      d	f           |                      d	f                                          }|d uo-t          |t          j                  r|d         n|d         k    }||fS )	Nr  r<   r   r  r?   ADELETE FROM compression_locks WHERE session_id = ? AND holder = ?ziINSERT OR IGNORE INTO compression_locks (session_id, holder, acquired_at, expires_at) VALUES (?, ?, ?, ?)z9SELECT holder FROM compression_locks WHERE session_id = ?)r   r   rW   r   r$  rT   )
r   reclaimed_holderr[   current_holdercurrent_expires_atr  r  r<   rP  r  s
         r9   r  z3SessionDB.try_acquire_compression_lock.<locals>._do  so   #,,'  hjj	 
 %/W[%A%AMCMMs1v  *4C)E)EQC%%3q6 # ',,?OO - LL>#^4  
 (6$ LL& VS*5	   ,,K  hjj  $ !+C!=!=IH3q6,H ---r;   z;Reclaimed stale compression lock for session=%s (holder=%s)z+try_acquire_compression_lock(%s) failed: %sN)r	  r  r  r  rv  r   r   )
r  r  r<   rc  r  r  rk  r  r  rP  s
    ``     @@r9   try_acquire_compression_lockz&SessionDB.try_acquire_compression_locki  s    .  	5ikk;&
'	. '	. '	. '	. '	. '	. '	. '	.R	)-)<)<S)A)A&H& "$	   >>!} 	 	 	NN=C   55555	s   AA/ /B%>B  B%c                     sdS fd}	 |                      |           dS # t          j        $ r'}t                              d|           Y d}~dS d}~ww xY w)aK  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.
        Nc                 8    |                      df           d S )Nrj  r  )r   r<   r  s    r9   r  z/SessionDB.release_compression_lock.<locals>._do  s0    LL6V$    r;   z'release_compression_lock(%s) failed: %sr9  )r  r  r<   r  r  s    ``  r9   release_compression_lockz"SessionDB.release_compression_lock  s      	F	 	 	 	 	 		$$$$$} 	 	 	NN9C        	s   % AAAc                     |sdS t          j                     }| j                            d||f                                          }|dS t	          |t
          j                  r|d         n|d         S )u   Return the current (non-expired) holder for ``session_id``, or None.

        Diagnostic helper — not used by the locking protocol itself.
        NzMSELECT holder FROM compression_locks WHERE session_id = ? AND expires_at >= ?r<   r   )r	  r#  r   r   rW   r   r$  )r  r  rP  r[   s       r9   get_compression_lock_holderz%SessionDB.get_compression_lock_holder  s}    
  	4ikkj  7
 
 (**	 	
 ;4 *3 < <Hs8}}#a&Hr;   )description
provenancer  rt  ru  c                   	
 sdS ddl m}m} t          ||nt	          j                              
 ||           ||          j        		
fd}|                     || j                   dS )a-  Stamp durable mid-turn session activity (observation-only).

        Called (rate-limited) from ``AIAgent._touch_activity`` so gateway/CLI
        surfaces and stall consumers observe API/tool/compaction activity
        even when no new message row has been written yet (#72016 / #72039).

        Never moves ``last_activity_at`` backwards. When the timestamp
        advances, bounded ``last_activity_description`` /
        ``last_activity_provenance`` are written with it. No-ops when
        ``session_id`` is empty or the row does not exist.
        Nr   )bound_activity_descriptionnormalize_activity_provenancec                 >    |                      df           d S )NzUPDATE sessions SET last_activity_at = ?, last_activity_description = ?, last_activity_provenance = ? WHERE id = ? AND (last_activity_at IS NULL OR last_activity_at < ?)r  )r   descprovr  whens    r9   r  z-SessionDB.touch_session_activity.<locals>._do
  s9    LLV
 tT:t4    r;   r  )agent.session_activityrw  rx  r  r	  rU   r  _ACTIVITY_WRITE_PATIENCE_S)r  r  r  rt  ru  rw  rx  r  rz  r{  r|  s    `      @@@r9   touch_session_activityz SessionDB.touch_session_activity  s    &  	F	
 	
 	
 	
 	
 	
 	
 	

 2>RRty{{;;))+66,,Z88>	 	 	 	 	 	 	 	 	CD,KLLLLLr;   c                    sdS ddl m 	 | j                            df                                          }n# t
          j        $ r d}Y nw xY w|jt          |t
          j                  s|d         n|d         }t          |t
          j                  s|d         n|d         }|s|r|j	        j
        k    rdS fd}|                     || j        	           dS )
a  Clear mid-turn activity labels after a turn ends.

        Keeps ``last_activity_at`` intact so idle / watchdog clocks stay
        continuous. Description and provenance are observation labels for
        *what was happening at* that timestamp during an active turn; once
        the turn is idle they must not keep advertising "compressing" /
        "executing tool" (#72039).

        Response-critical-path contract (#76354 review S1): runs in the
        turn's ``finally``; a no-op clear (labels already empty) skips the
        write transaction entirely, and a real clear uses the same short
        sub-second busy budget as :meth:`touch_session_activity` instead of
        the full routine write patience.
        Nr   r   zUSELECT last_activity_description, last_activity_provenance FROM sessions WHERE id = ?last_activity_descriptionr?   last_activity_provenancec                 N    |                      ddj        j        f           d S )Nz\UPDATE sessions SET last_activity_description = ?, last_activity_provenance = ? WHERE id = ?r>   )r   UNKNOWNrU   )r   r   r  s    r9   r  z4SessionDB.clear_session_activity_labels.<locals>._do@  s;    LL '/5zB    r;   r  )r}  r   r#  r   r   r   r   rW   r$  r  rU   r  r~  )r  r  r[   rz  r{  r  r   s    `    @r9   clear_session_activity_labelsz'SessionDB.clear_session_activity_labels  s9     	F======	*$$-  hjj	 C
 } 	 	 	CCC	?!+C!=!=c3q663GbCcD!+C!=!=b3q663GaCbD  $6$>$DDD	 	 	 	 	 	 	CD,KLLLLLs   .= AAc                     |sdS |                      |          }|sdS ddlm}  ||                    d          |                    d          |                    d                    S )z?Return the durable activity snapshot for *session_id*, or None.Nr   )build_activity_snapshotlast_activity_atr  r  )r  r  r  )get_sessionr}  r  r_   )r  r  r[   r  s       r9   get_session_activityzSessionDB.get_session_activityK  s     	4z** 	4BBBBBB&& WW%788&)gg.I&J&J%(WW-G%H%H
 
 
 	
r;   model_config_jsonc                 l    |                                   fd}|                     |           dS )a  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.
        c                 :    |                      df           d S )NzMUPDATE sessions SET model_config = ?, model = COALESCE(?, model) WHERE id = ?r  )r   r  r  r  s    r9   r  z*SessionDB.update_session_meta.<locals>._doi  s0    LL_"E:6    r;   Nr  r  )r  r  r  r  r  s    ``` r9   update_session_metazSessionDB.update_session_metaZ  sX     	!!!	 	 	 	 	 	 	
 	C     r;   c                 D      fd}                      |           dS )z0Store the full assembled system prompt snapshot.c                                          |           }|                     d|f                               |            d S )NzMUPDATE sessions SET system_prompt_hash = ?, system_prompt = NULL WHERE id = ?)r  r   r  )r   r  r  r  r2   s     r9   r  z+SessionDB.update_system_prompt.<locals>._dot  sY    !%!:!:4!O!OLLP#Z0  
 44T:::::r;   Nr  )r  r  r2   r  s   ``` r9   update_system_promptzSessionDB.update_system_promptp  sD    	; 	; 	; 	; 	; 	; 	; 	C     r;   c                 l                                         fd}                     |           dS )a@  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.
        Also nulls ``system_prompt`` so stale ``Model:`` / ``Provider:``
        footer metadata is rebuilt on the next turn. A successful /model
        switch explicitly replaces any confirmed Browser runtime lock while
        preserving unrelated lineage markers in ``model_config``.
        c                 b    |                      df                               |            d S )Na  UPDATE sessions SET
                   model = ?,
                   model_config = CASE
                       WHEN model_config IS NULL THEN NULL
                       WHEN json_valid(model_config)
                           THEN json_remove(model_config, '$.browser_model_lock')
                       ELSE model_config
                   END,
                   system_prompt = NULL,
                   system_prompt_hash = NULL
                   WHERE id = ?r   r  )r   r  r  r  s    r9   r  z+SessionDB.update_session_model.<locals>._do  sD    LL
# 
#   44T:::::r;   Nr  )r  r  r  r  s   ``` r9   update_session_modelzSessionDB.update_session_model~  sX    " 	!!!	; 	; 	; 	; 	; 	; 	;  	C     r;   )r  providermodel_optionsroute_source	confirmedr  r  r  r  c                     |pdpd|pi |pdt          |          t          j                    d fd}                     |           dS )aK  Persist a Browser / API client runtime lock without clobbering lineage markers.

        Merges ``browser_model_lock`` into the existing ``model_config`` JSON so
        ``_branched_from`` / ``_delegate_from`` survive. Nulls ``system_prompt``
        so cached ``Model:`` / ``Provider:`` footers cannot lie after a switch.
        r>   )r  r  r  r  r  
updated_atc                 \   |                      df                                          }|d S t          |t          j                  r|d         n|d         }i }t          |t
                    rS|                                r?	 t          j        |          }t          |t                    r|}n6# t          $ r i }Y n(w xY wt          |t                    rt          |          }|d<   |                      dt          j        |          f                               |            d S )N.SELECT model_config FROM sessions WHERE id = ?rc   r   browser_model_lockzUPDATE sessions SET
                   model_config = ?,
                   model = COALESCE(?, model),
                   system_prompt = NULL,
                   system_prompt_hash = NULL
                   WHERE id = ?)r   r   rW   r   r$  rX   r`   r  loadsr   rK   r  r  )	r   r[   r   r   parsedlockr  r  r  s	        r9   r  z2SessionDB.update_session_runtime_lock.<locals>._do  sE   ,,@  hjj  {)3C)E)EQ#n%%3q6C%'F#s## #		 # !Z__F!&$// (!'       FFF C&& #c+/F'(LL# F##UJ7   44T:::::   +B1 1C ?C N)rv  r	  r  )	r  r  r  r  r  r  r  r  r  s	   ```     @r9   update_session_runtime_lockz%SessionDB.update_session_runtime_lock  s    " !B[b*0b(.Bi)++
 
	; 	; 	; 	; 	; 	; 	; 	;: 	C     r;   enabledc                 H    sdS fd}|                      |           dS )uY  Persist the per-session YOLO bypass flag into ``model_config``.

        Merges ``yolo_mode`` into the existing ``model_config`` JSON (same
        merge discipline as ``update_session_runtime_lock`` so lineage
        markers like ``_branched_from`` / ``_delegate_from`` survive). The
        CLI resume paths read this flag back so a ``/yolo ON`` toggle — or a
        ``--yolo`` launch — survives ``hermes --resume`` into a fresh
        process. No-op when the session row doesn't exist yet; the
        creation-time ``model_config`` carries the flag for ``--yolo``
        launches.
        Nc                 J   |                      df                                          }|d S t          |t          j                  r|d         n|d         }i }t          |t
                    rS|                                r?	 t          j        |          }t          |t                    r|}n6# t          $ r i }Y n(w xY wt          |t                    rt          |          }t                    |d<   |                      dt          j        |          f           d S )Nr  rc   r   	yolo_modez1UPDATE sessions SET model_config = ? WHERE id = ?)r   r   rW   r   r$  rX   r`   r  r  r   rK   rv  r  )r   r[   r   r   r  r  r  s        r9   r  z'SessionDB.set_session_yolo.<locals>._do  s2   ,,@  hjj  {)3C)E)EQ#n%%3q6C%'F#s## #		 # !Z__F!&$// (!'       FFF C&& #c"&w--F;LLCF##Z0    r  r  )r  r  r  r  s    `` r9   set_session_yolozSessionDB.set_session_yolo  sM      	F	 	 	 	 	 	. 	C     r;   session_metac                    | pi                      d          }t          |t                    r'	 t          j        |          }n# t
          $ r Y dS w xY wt          |t                    sdS t          |                     d                    S )u  Read the persisted YOLO flag off a session row dict.

        Accepts the dict returned by ``get_session`` (``model_config`` is a
        JSON string) or an already-parsed dict. Returns False on any parse
        failure — resume must never enable the bypass by accident.
        rc   Fr  )r_   rW   rX   r  r  rK   r   rv  )r  r   s     r9   session_yolo_enabledzSessionDB.session_yolo_enabled  s     !r&&~66c3 	joo   uu#t$$ 	5CGGK(()))s   A 
AA)billing_modebase_urlr  c                t                                         fd}                     |           dS )u  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).
        c                 f    |                      df                               |            d S )Na  UPDATE sessions SET
                   billing_provider = ?,
                   billing_base_url = ?,
                   billing_mode = COALESCE(?, billing_mode),
                   system_prompt = NULL,
                   system_prompt_hash = NULL
                   WHERE id = ?r  )r   r  r  r  r  r  s    r9   r  z3SessionDB.update_session_billing_route.<locals>._do*  sH    LL# 8\:>	 	 	 44T:::::r;   Nr  )r  r  r  r  r  r  s   ````` r9   update_session_billing_routez&SessionDB.update_session_billing_route  sd    & 	!!!	; 	; 	; 	; 	; 	; 	; 	; 	; 	C     r;   )input_tokensoutput_tokenscache_read_tokenscache_write_tokensreasoning_tokensapi_call_count)estimated_cost_usdactual_cost_usd)r  cost_statuscost_sourcepricing_versionbilling_providerbilling_base_urlr  c                    | j         5  | j        }| j        o|du p|                                 }|s| j                            ||f           ||                                sPt          j        | j        dd          }|| _        |	                                 t          j        | j                   | j                                          ddd           n# 1 swxY w Y   |r | j        |fi | dS dS )u  Enqueue a token/cost delta for the background writer.

        Accepts the same keyword arguments as :meth:`update_token_counts`
        and applies them asynchronously with identical semantics.  Cheap
        (append + notify) — safe to call on the turn thread after every
        API call.  After close() has stopped the writer, falls back to the
        synchronous path and may raise like :meth:`update_token_counts`.
        Nzsession-db-token-writerT)targetrL   daemon)rH  rI  rJ  is_aliverF  r   r7  Thread_token_writer_loopstartr  registerr  
notify_allupdate_token_counts)r  r  r  threadwriter_stoppeds        r9   queue_token_countszSessionDB.queue_token_countsS  sg    # 	4 	4.F!4 $7foo&7&7"7  " 4!((*f)=>>>>):):> '-#66#  F
 17D-LLNNNOD$CDDD&113331	4 	4 	4 	4 	4 	4 	4 	4 	4 	4 	4 	4 	4 	4 	42  	; %D$Z::6:::::	; 	;s   CCC Cr   c                 >   | j         s	| j        sdS d}| j        5  t          j                    |z   }| j         s| j        r| j        }||                                s<| j        s5d| _        t          | j                   }| j                                          nR|t          j                    z
  }|dk    r	 ddd           dS | j        	                    |           | j         | j        ddd           n# 1 swxY w Y   |r	 | 
                    |           | j        5  d| _        | j                                         ddd           n# 1 swxY w Y   nD# | j        5  d| _        | j                                         ddd           w # 1 swxY w Y   w xY wdS )uO  Block until every queued token delta has been applied.

        Returns True when the queue is fully drained, False on timeout
        (callers then read totals that are stale by the still-queued
        deltas — no worse than reading before the flush existed).
        Never raises: apply failures are logged by the writer.
        TNr   F)rF  rK  rH  r	  r  rI  r  r   r  wait_apply_token_batchr  )r  r   batchr  r  	remainings         r9   r  zSessionDB.flush_token_counts  s      	)@ 	4# 	7 	7~'''1H# 7t'> 7 2^6??+<+<^ 3 $ /3D+ !233E%++---$t~'7'77	>> 9	7 	7 	7 	7 	7 	7 	7 	7: &++I6667 # 7t'> 7	7 	7 	7 	7 	7 	7 	7 	7 	7 	7 	7 	7 	7 	7 	7<  	88''...+ 8 8.3D+*557778 8 8 8 8 8 8 8 8 8 8 8 8 8 8T+ 8 8.3D+*557778 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 tsZ   BC6(C66C:=C:E  !EEEF!!FFFFFFc                 v   	 | j         5  | j        s.| j        s'| j                                          | j        s| j        '| j        s	 d d d            d S d| _        t          | j                  }| j                                         d d d            n# 1 swxY w Y   	 |                     |           | j         5  d| _        | j                                          d d d            n# 1 swxY w Y   nD# | j         5  d| _        | j                                          d d d            w # 1 swxY w Y   w xY w9)NTF)	rH  rF  rJ  r  rK  r   r  r  r  )r  r  s     r9   r  zSessionDB._token_writer_loop  s(   	8' * *+ 2D4K 2*//111 + 2D4K 2( 	* * * * * * * * +/'T.//!'')))* * * * * * * * * * * * * * *8''...+ 8 8.3D+*557778 8 8 8 8 8 8 8 8 8 8 8 8 8 8T+ 8 8.3D+*557778 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8!	8sY   >B4BBB C5 <!C))C-0C-5D6=!D*D6*D..D61D.2D6r  c                 0   	 |                      |          }n4# t          $ r'}t                              d|           |}Y d}~nd}~ww xY w|D ]G\  }}	  | j        |fi | # t          $ r&}t                              d||           Y d}~@d}~ww xY wdS )zBApply queued deltas in order, coalescing where safe. Never raises.z?async token accounting: coalesce failed, applying raw batch: %sNz5async token accounting: apply failed (session=%s): %s)_coalesce_token_deltasrK   r  r  r  )r  r  	coalescedr  r  r  s         r9   r  zSessionDB._apply_token_batch  s   
	33E::II 	 	 	 NN    IIIIII	 #, 		 		J((>>v>>>>    K       		 		s,    
A	AA	A##
B-BBc                 .   g }|D ]\  }d}                     d          s$|ft          fd| j        D                       z   }|r||d         d         |k    r|d         d         }| j        D ]2}|                     |d                               |d          z   ||<   3| j        D ]6}                     |          }||                     |          pd|z   ||<   7|                    ||t                    f           d |D             S )	a  Merge consecutive incremental deltas with an identical route.

        Only adjacent deltas merge, so ordering across sessions and across
        a mid-session /model switch is preserved exactly.  absolute=True
        deltas (cumulative overwrites) never merge.
        Nabsolutec              3   B   K   | ]}                     |          V  d S r   )r_   )rz   fr  s     r9   r   z3SessionDB._coalesce_token_deltas.<locals>.<genexpr>  s>       , ,&'FJJqMM, , , , , ,r;   r!  r   r          c                     g | ]
\  }}}||fS rf   rf   )rz   r  r{   kws       r9   r   z4SessionDB._coalesce_token_deltas.<locals>.<listcomp>  s"    333jabb	333r;   )r_   tuple_TOKEN_DELTA_ROUTE_FIELDS_TOKEN_DELTA_SUM_FIELDS_TOKEN_DELTA_COST_FIELDSr   r   )	r  r  groupsr  rp   mergedr  rU   r  s	           @r9   r  z SessionDB._coalesce_token_deltas  sn    EG"' 	? 	?JC::j)) !me , , , ,+/+I, , , ' '   ?#/fRjms.B.BA5 D DA &

1a 0 06::a3C3C CF1II6 C CA"JJqMME( &,ZZ]]%9cU$Bq	C sJV=>>>>33F3333r;         $@join_timeoutc                    | j         5  d| _        | j                                          | j        }ddd           n# 1 swxY w Y   |n|                                rZ|                    |           |                                r0t                              d|t          | j	                             dS | j         5  t          j                    |z   }| j        ry|t          j                    z
  }|dk    r<t                              d|t          | j	                             	 ddd           dS | j                             |           | j        yt          | j	                  }|r d| _        | j	                                         ddd           n# 1 swxY w Y   |r	 |                     |           | j         5  d| _        | j                                          ddd           dS # 1 swxY w Y   dS # | j         5  d| _        | j                                          ddd           w # 1 swxY w Y   w xY wdS )z@Stop the writer thread and drain remaining deltas. Never raises.TN)r   zZasync token accounting: writer did not stop within %.0fs; %d queued delta(s) not persistedr   zfasync token accounting: concurrent drain did not finish within %.0fs; %d queued delta(s) not persistedF)rH  rJ  r  rI  r  r   r  r  r   rF  r	  r  rK  r  r   r  r  )r  r  r  r  r  r  s         r9   r  zSessionDB._stop_token_writer  s~   # 	/ 	/&*D#"--///.F	/ 	/ 	/ 	/ 	/ 	/ 	/ 	/ 	/ 	/ 	/ 	/ 	/ 	/ 	/ &//"3"3KKK---   	 7 #d&7"8"8  
  # 	* 	*~'',6H) 	7$t~'7'77	>>NNP$c$*;&<&<  
 	* 	* 	* 	* 	* 	* 	* 	* &++I666 ) 	7  *++E **.'!'')))+	* 	* 	* 	* 	* 	* 	* 	* 	* 	* 	* 	* 	* 	* 	*,  	88''...+ 8 8.3D+*557778 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8T+ 8 8.3D+*557778 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8		8 	8sk   (<A A >A)F4AFFF%G< !G//G36G3<H=!H1%H=1H55H=8H59H=c                 R    	 |                                   d S # t          $ r Y d S w xY wr   )r  rK   r)  s    r9   r  z$SessionDB._drain_token_queue_at_exit3  sA    	##%%%%% 	 	 	DD	s    
&&r   r  r  r  r  r  r  r  r  r  r  r  r  r  r  c                 h   	
                       d           |rdndt          ppp	pppp	          		
|rndrndrndrndf| opp	pppp	
 fd}                     |           dS )u  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).
        r  )r  a  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 = ?a^  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 = ?Nc                    |                      df                                          }||d         nd }||d         nd }t          ||d         ndpd          }|dk    o+o)t                    ot          
          o|k    p|
k    }|r|                      d
	f           |                                 r'                    | 
	           d S d S )NzISELECT model, billing_provider, api_call_count FROM sessions WHERE id = ?r  r  r  r   zUPDATE sessions
                       SET model = ?, billing_provider = ?,
                       billing_base_url = ?, billing_mode = ?
                       WHERE id = ?)r  r  r  r  r  r  r  r  r  r  r  r  r  r  )r   r   rC   rv  _record_model_usage)r   r[   existing_modelexisting_providerexisting_api_callsfirst_accounted_router  r  r  r  r  r  r  r  r  r  has_accounted_usager  r  r  r  r  record_model_usager  r  r_  s         r9   r  z*SessionDB.update_token_counts.<locals>._do  s   ,,[  hjj  .1_S\\$N;>?$6 7 7PT!$sc*:&;&;TU%[Z[!\!\ #a' W'WKKW )**W $u,U0AEU0U " % ' ,.>jY   LLf%%%! ((%5%5!-!-"/&7'9%5'9$3 + +#1! )      r;   )r  rv  r  )r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r_  s   ```````````` ````  @@@@r9   r  zSessionDB.update_token_counts9  s   B 	  Ye DDD )	##CC*#C( # 5M 5-> 5!5%559G5!5%4
 
  3= 3=/9LLT(2EEd#
@ #+l 
 "M "-> "!"%5"9G"! 	/	 /	 /	 /	 /	 /	 /	 /	 /	 /	 /	 /	 /	 /	 /	 /	 /	 /	 /	 /	 /	 /	 /	 /	` 	C     r;   )taskr  c                   |                     d|f                                          }||d         nd}||d         nd}||d         nd}||d         nd}|r|pd}|pd}|pd}|pd}n|p|pd}|p|pd}|p|pd}|p|pd}t          j                    }|                     d	||||||pd|pd
|pd
|pd
|	pd
|
pd
|pd
t          |pd          t          |pd          ||||f           dS )u  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).
        zYSELECT model, billing_provider, billing_base_url, billing_mode FROM sessions WHERE id = ?Nr  r  r  r  r  r>   a  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_seenr   r  )r   r   r	  r  )r  r   r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r[   
sess_modelsess_providersess_base_urlsess_billing_mode	eff_modeleff_providereff_base_urleff_billing_moderP  s                               r9   r  zSessionDB._record_model_usage  s   D ll)M
 
 (**	 	
 &)_S\\$
36?.//36?.//36?C//  		G*I+1rL+1rL+1r88yI+B}BL+B}BL+F/@FBikk5*  
#!!"!&Q"'a %A(/C00o,--%+)	
 )	
 )	
 )	
 )	
r;   r  c                 (     | j         ||fd|i| |S )zHEnsure a session row exists (INSERT OR IGNORE). Accepts optional kwargs.r  r  )r  r  r  r  r  s        r9   ensure_sessionzSessionDB.ensure_sessionH  s,     	! VKK5KFKKKr;   )	r  r  r  r  r  r  r  r  r  c       	             	
 rsdS                       d           	
 fd}                     |           dS )us  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.
        Nr  c                 h    
                     | d pdpdpdpd	pdd d d d           d S )Nr   r?   )r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  )r  )r   r  r  r  r  r  r  r  r  r  r  r  r  s    r9   r  z-SessionDB.record_auxiliary_usage.<locals>._dox  sv    $$!1!1!).Q+0q"3"8q#5#:!1!6Q#5 $   # %     r;   )r  r  )r  r  r  r  r  r  r  r  r  r  r  r  r  s   ```````````` r9   record_auxiliary_usagez SessionDB.record_auxiliary_usageS  s    <  	 	F 	  Y777	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	( 	C     r;   sessions_dirzOptional[Path]c                      t          j                     dz
   fd}                     |          pg }|r|r|D ]}                     ||           t          |          S )zCRemove empty TUI ghost sessions (no messages, no title, >24hr old).Q c                    |                      df                                          }d |D             }|rTd                    dt          |          z            }|                      d| d|                               |            |S )NaZ  
                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
                  )
            c                 f    g | ].}t          |t          t          f          r|d          n|d         /S )r   r   )rW   r  r   r  s     r9   r   zESessionDB.prune_empty_ghost_sessions.<locals>._do.<locals>.<listcomp>  s7    SSS:a%77D1Q44QtWSSSr;   r}   r~   r   r   )r   r   r   r   r  )r   r  r   placeholderscutoffr  s       r9   r  z1SessionDB.prune_empty_ghost_sessions.<locals>._do  s    << 	! 	 	 %HJJ  TSdSSSC ?"xxc#hh77HHHH#   88>>>Jr;   )r	  r  _remove_session_filesr   )r  r  r  removed_idsr{   r  s   `    @r9   prune_empty_ghost_sessionsz$SessionDB.prune_empty_ghost_sessions  s    u$	 	 	 	 	 	( ))#..4" 	>K 	>" > >**<====;r;   c                 h    t          j                     dz
  fd}|                     |          pdS )aj  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.
        i:	 c                 h    t          j                     }|                     d|f          }|j        S )Na  
                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
                  )
                )r	  r   r  )r   rP  r  r  s      r9   r  z=SessionDB.finalize_orphaned_compression_sessions.<locals>._do  s9    )++C\\( f+ F. ?"r;   r   r  )r  r  r  s     @r9   &finalize_orphaned_compression_sessionsz0SessionDB.finalize_orphaned_compression_sessions  sI     v%	# 	# 	# 	# 	#6 ""3'',1,r;   c                 
   |                                   |                                 5 }|                    d|f          }|                                }ddd           n# 1 swxY w Y   |r|                     |          ndS )zGet a session by ID.zSELECT s.*, COALESCE(sp.prompt, s.system_prompt) AS _system_prompt_resolved FROM sessions s LEFT JOIN system_prompts sp ON sp.hash = s.system_prompt_hash WHERE s.id = ?N)r  rW  r   r   r  )r  r  r   r   r[   s        r9   r  zSessionDB.get_session  s    
 	!!!^^ 		$\\!
  F //##C		$ 		$ 		$ 		$ 		$ 		$ 		$ 		$ 		$ 		$ 		$ 		$ 		$ 		$ 		$ /2;t%%c***t;s   ,A!!A%(A%session_id_or_prefixc                    |                      |          }|r|d         S |                    dd                              dd                              dd          }| j        5  | j                            d| df          }d	 |                                D             }d
d
d
           n# 1 swxY w Y   t          |          dk    r|d         S d
S )a*  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.
        r   \\\r  rl   r  r  zSSELECT id FROM sessions WHERE id LIKE ? ESCAPE '\' ORDER BY started_at DESC LIMIT 2c                     g | ]
}|d          S r   rf   rz   r[   s     r9   r   z0SessionDB.resolve_session_id.<locals>.<listcomp>  s    >>>Ss4y>>>r;   Nr?   r   )r  r  r9  r#  r   r   r   )r  r  r  escapedr   matchess         r9   resolve_session_idzSessionDB.resolve_session_id  s'      !566 	; !WT6""WS%  WS%  	 	 Z 	? 	?Z''f  F ?>FOO,=,=>>>G	? 	? 	? 	? 	? 	? 	? 	? 	? 	? 	? 	? 	? 	? 	? w<<11:ts   %>B//B36B3r  titlec                 p   | sdS t          |           } t          j        dd|           }t          j        dd|          }t          j        dd|                                          }|sdS t	          |          t
          j        k    r-t          dt	          |           dt
          j         d	          |S )
a  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.
        Nz [\x00-\x08\x0b\x0c\x0e-\x1f\x7f]r>   zB[\u200b-\u200f\u2028-\u202e\u2060-\u2069\ufeff\ufffc\ufff9-\ufffb]z\s+rg  zTitle too long (z chars, max r   )r	   resubr`   r   r  MAX_TITLE_LENGTHrF   )r  cleaneds     r9   sanitize_titlezSessionDB.sanitize_title  s      	4 %U++
 &<b%HH &Q
 
 &g..4466 	4w<<)444Z3w<<ZZY=WZZZ   r;   ancestor_iddescendant_idc                    |r|r||k    rdS t          j        d          }|                    d| d|||f                                          }|duS )u  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.
        Fchild)aa9  
            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 z`
            )
            SELECT 1 FROM ancestors WHERE id = ? AND id != ? LIMIT 1
            N)r   formatr   r   )r  r   r  r  edger[   s         r9   _is_compression_ancestorz"SessionDB._is_compression_ancestor4  s      	- 	;-3O3O5 &,w777ll    K7
 
 (** 	 $r;   only_if_emptyc                z                                      fd}                     |          }|dk    S )Nc                    r5|                      df                                          }||d         dS 	r{|                      d	f          }|                                }|rM|d         }                    | |          r|                      d|f           nt          d	 d	|           rd
nd}|                      d| 	f          }|j        S )N'SELECT title FROM sessions WHERE id = ?r  r   z3SELECT id FROM sessions WHERE title = ? AND id != ?r   )r  r  z-UPDATE sessions SET title = NULL WHERE id = ?zTitle 'z' is already in use by session z AND title IS NULLr>   z*UPDATE sessions SET title = ? WHERE id = ?)r   r   r#  rF   r  )
r   r  r   conflictconflict_id	predicater$  r  r  r  s
         r9   r  z)SessionDB._set_session_title.<locals>._dob  sF    ,,=M  (**  ?gg&6&B1 IJ'  "??,, "*4.K 44+Z 5   
 K(N   
 )YeYYKYY   1>E,,2I\\HYHH
# F ?"r;   r   )r  r  )r  r  r  r$  r  r  s   ````  r9   _set_session_titlezSessionDB._set_session_titleY  sc     ##E**-	# -	# -	# -	# -	# -	# -	# -	#^ &&s++!|r;   c                 2    |                      ||d          S )aL  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).
        Fr$  r+  r  r  r  s      r9   set_session_titlezSessionDB.set_session_title  s     &&z5&NNNr;   c                 2    |                      ||d          S )a
  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`.
        Tr-  r.  r/  s      r9   set_auto_title_if_emptyz!SessionDB.set_auto_title_if_empty  s     &&z5&MMMr;   c                     | j         5  | j                            d|f          }|                                }ddd           n# 1 swxY w Y   |r|d         ndS )z%Get the title for a session, or None.r'  Nr  r9  r#  r   r   )r  r  r   r[   s       r9   get_session_titlezSessionDB.get_session_title  s    Z 	$ 	$Z''9J= F //##C		$ 	$ 	$ 	$ 	$ 	$ 	$ 	$ 	$ 	$ 	$ 	$ 	$ 	$ 	$
  #,s7||,   1AA	A	archivedc                 H    fd}|                      |          }|dk    S )u  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.
        c                     |                      drdndf          }|j        }||dk     r-|                      d                                          d         }|S )Na[  
                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)
                r?   r   SELECT changes()r   r  r   )r   r   r  r7  r  s      r9   r  z+SessionDB.set_session_archived.<locals>._do  sp    \\: Zh)=A>= F@ H8a<<<<(:;;DDFFqIOr;   r   r  )r  r  r7  r  r  s    ``  r9   set_session_archivedzSessionDB.set_session_archived  sB    $	 $	 $	 $	 $	 $	J &&s++!|r;   pinnedc                 H    fd}|                      |          }|dk    S )ul  Pin or unpin a session (and its whole compression lineage).

        ``pinned`` is a durable "keep" flag: pinned sessions are exempt from
        the ``sessions.auto_archive`` stale sweep (see
        :meth:`archive_stale_sessions`). Desktop is the current writer — its
        sidebar pins mirror here so a backend/other-surface sweep honours
        them. Like :meth:`set_session_archived` the whole compression chain is
        flipped as a unit, so pinning the surfaced tip protects the root (and
        vice-versa) no matter which id the caller holds. Returns True when at
        least one row changed.
        c                     |                      drdndf          }|j        }||dk     r-|                      d                                          d         }|S )NaY  
                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 pinned = ?
                WHERE id IN (SELECT id FROM lineage)
                r?   r   r:  r;  )r   r   r  r=  r  s      r9   r  z)SessionDB.set_session_pinned.<locals>._do  sp    \\: Zf);!<= F@ H8a<<<<(:;;DDFFqIOr;   r   r  )r  r  r=  r  r  s    ``  r9   set_session_pinnedzSessionDB.set_session_pinned  sB    $	 $	 $	 $	 $	 $	J &&s++!|r;   c                     |                                  5 }|                    d|f          }|                                }ddd           n# 1 swxY w Y   |r|                     |          ndS )z?Look up a session by exact title. Returns session dict or None.zSELECT s.*, COALESCE(sp.prompt, s.system_prompt) AS _system_prompt_resolved FROM sessions s LEFT JOIN system_prompts sp ON sp.hash = s.system_prompt_hash WHERE s.title = ?N)rW  r   r   r  )r  r  r   r   r[   s        r9   get_session_by_titlezSessionDB.get_session_by_title  s    ^^ 		$\\$
  F //##C		$ 		$ 		$ 		$ 		$ 		$ 		$ 		$ 		$ 		$ 		$ 		$ 		$ 		$ 		$ /2;t%%c***t;s   ,AAAc                    |                      |          }|                    dd                              dd                              dd          }|                                 5 }|                    d| df          }|                                }d	d	d	           n# 1 swxY w Y   |r|d
         d         S |r|d         S d	S )ad  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).
        r  r  r  rl   r  r  zaSELECT id, title, started_at FROM sessions WHERE title LIKE ? ESCAPE '\' ORDER BY started_at DESC #%Nr   r   )rB  r  rW  r   r   )r  r  r  r  r   r   numbereds          r9   resolve_session_by_titlez"SessionDB.resolve_session_by_title$  s    ))%00 --f--55c5AAII#uUU^^ 	)\\J" F
 ((H	) 	) 	) 	) 	) 	) 	) 	) 	) 	) 	) 	) 	) 	) 	)  	A;t$$ 	;ts   (/B##B'*B'
base_titlec           	      N   t          j        d|          }|r|                    d          }n|}|                    dd                              dd                              dd          }| j        5  | j                            d	|| d
f          }d |                                D             }ddd           n# 1 swxY w Y   |s|S d}|D ]I}t          j        d|          }	|	r0t          |t          |	                    d                              }J| d|dz    S )u   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.
        z^(.*?) #(\d+)$r?   r  r  r  rl   r  r  zESELECT title FROM sessions WHERE title = ? OR title LIKE ? ESCAPE '\'rD  c                     g | ]
}|d          S )r  rf   r  s     r9   r   z7SessionDB.get_next_title_in_lineage.<locals>.<listcomp>V  s    BBBGBBBr;   Nz^.* #(\d+)$z #)
r  rR   rD   r  r9  r#  r   r   r  rC   )
r  rG  rR   baser  r   r  max_numtms
             r9   get_next_title_in_lineagez#SessionDB.get_next_title_in_lineageA  s    *J77 	;;q>>DDD ,,tV,,44S%@@HHeTTZ 	C 	CZ''X'' F CB0A0ABBBH	C 	C 	C 	C 	C 	C 	C 	C 	C 	C 	C 	C 	C 	C 	C  	K  	8 	8A++A 8gs1771::77'''A+'''s   5?C  CCc                 r   |}|r|hnt                      }t          d          D ]}| j        5  | j                            dt          d           d|f          }|                                }ddd           n# 1 swxY w Y   ||c S |d         }|r||v r|c S |                    |           |}|S )a  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.
        r  a  
                    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,
                      r  z DESC,
                      child.started_at DESC,
                      child.id DESC
                    LIMIT 1
                    Nr   )r   r  r9  r#  r   r   r   r  )r  r  r  seenr  r   r[   child_ids           r9   get_compression_tipzSessionDB.get_compression_tipd  s3   * #.yy s  	  	A ( (++ 088  ( J+ . oo''1( ( ( ( ( ( ( ( ( ( ( ( ( ( (2 {4yH x4//HHXGGs   AA<<B 	B 	r  _session_compact_cols_sql   sourcesexclude_sourcesrh   limitoffsetinclude_childrenmin_message_countproject_compression_tipsorder_by_last_activeinclude_archivedarchived_onlyid_querysearch_querycompact_rowsinclude_pinnedc                 @   |                                   g }g }|s?|                    t                     |                    t          d           d           |r|gnt	          |pg           }|rMd                    d |D                       }|                    d| d           |                    |           |r*|                    d           |                    |           |rMd                    d |D                       }|                    d	| d           |                    |           |r<t          |          \  }}|                    |           |                    |           |d
k    r*|                    d           |                    |           |r|                    d           n|s|                    d           |rdd                    |           nd}t	          |          }|rdnd}|rdnd}|pd                                	                                }|pd                                	                                }|
rc|}g }g } dt          dt          fd}!|r3|                     d           |                     |!|                     |rt          j        dd|          }"d}#d}$|                     |!|          gdz             |"r:|$d|#                    d           dz  }$|                     |!|"                     |                     |$dz              | r#d                    |           }%|r| d|% nd|% }|r|                                 nd}&d | d!t          d"           d#|& | d$t            d%t#          d&           d'| d(| d)}'||z   |z   ||gz   }nU|r|                                 nd}&d*|& | d$t            d%t#          d&           d+| d(| d,}'|                    ||g           |                                 5 }(|(                    |'|          })|)                                }*d-d-d-           n# 1 swxY w Y   g }+|*D ]h},|                     |,          }-t-          |-                    d.d                    |-d/<   |-                    d0d-           |+                    |-           i|rd1 |+D             }.|r| d2nd3}/|r|                                 nd}&d*|& | d$t            d4| d(|/ d5
}0|                                 5 }(|(                    |0|          }1|1                                }2d-d-d-           n# 1 swxY w Y   |2D ]x},|                     |,          }-|-d6         |.v r"t-          |-                    d.d                    |-d/<   |.                    |-d6                    |+                    |-           y|	r(|s%i }3|+D ]N}-|-                    d7          d8k    r|                     |-d6                   }4|4|-d6         k    r|4|3|-d6         <   O|3r6|                     t9          |3                                          |9          ni }5g }6|+D ]}-|3                    |-d6                   }4|4r|5                    |4          nd-}7|7s|6                    |-           Nt=          |-          }8d:D ]}9|9|7v r|7|9         |8|9<   |-d6         |8d;<   |6                    |8           |6}+|+S )<u  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 (freshest of last_activity_at heartbeat and latest
        message timestamp, else started_at).

        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.

        Pass ``include_pinned=True`` to back-fill any conversation carrying the
        durable ``pinned`` flag that the LIMIT/OFFSET window left out. A pin is
        a "this must always be reachable" statement, so a pinned conversation
        aging past the requested page is a bug, not a paging outcome — the
        desktop sidebar would render an empty Pinned section. Back-filled rows
        obey the same filters (source, archived, min_message_count) as the
        page: an archived or filtered-out conversation stays out.

        Pass ``session_key`` to restrict results to one stable gateway
        conversation scope (DM, group, channel, or thread, including the
        configured per-user isolation policy).
        s.model_config IS NULLr}   c              3      K   | ]}d V  dS rl  rf   rm  s     r9   r   z/SessionDB.list_sessions_rich.<locals>.<genexpr>  "      #A#AAC#A#A#A#A#A#Ar;   s.source IN (r   zs.session_key = ?c              3      K   | ]}d V  dS rl  rf   rm  s     r9   r   z/SessionDB.list_sessions_rich.<locals>.<genexpr>  rg  r;   s.source NOT IN (r   s.message_count >= ?s.archived = 1s.archived = 0zWHERE  AND r>   zA, COALESCE(sp.prompt, s.system_prompt) AS _system_prompt_resolvedz=LEFT JOIN system_prompts sp ON sp.hash = s.system_prompt_hashneedler3   c                     |                      dd                               dd                               dd          }d| dS )Nr  r  r  rl   r  r  )r  )ro  r  s     r9   _like_patternz3SessionDB.list_sessions_rich.<locals>._like_patternL  sI    NN40088eDDLLSRWXX  &7~~~%r;   znEXISTS (SELECT 1 FROM chain cq        WHERE cq.root_id = s.id          AND LOWER(cq.cur_id) LIKE ? ESCAPE '\')z[\W_]+z`REPLACE(REPLACE(REPLACE(REPLACE(LOWER(COALESCE({0}, '')), '-', ''), '_', ''), '.', ''), ' ', '')zEXISTS (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 '\'r  z OR zcs.titlez LIKE ? ESCAPE '\'rr   zs.*zr
                WITH RECURSIVE chain(root_id, cur_id) AS (
                    SELECT s.id, s.id FROM sessions s a  
                    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(cur_idz) AS effective_last_active
                    FROM chain
                    GROUP BY root_id
                )
                SELECT z@,
                    COALESCE(
                        (SELECT a  
                         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,
                    sz 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
                z
                z
                ORDER BY _effective_last_active DESC, s.started_at DESC, s.id DESC
                LIMIT ? OFFSET ?
            z
                SELECT z@ AS last_active
                FROM sessions s
                zY
                ORDER BY s.started_at DESC
                LIMIT ? OFFSET ?
            N_preview_rawpreview_effective_last_activec                     h | ]
}|d          S r   rf   )rz   rs  s     r9   r|   z/SessionDB.list_sessions_rich.<locals>.<setcomp>  s    222A$222r;   z AND s.pinned = 1zWHERE s.pinned = 1a  
                         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
                z8
                ORDER BY s.started_at DESC
            r   r  r  )ra  )r   r  r  message_counttool_call_countr  last_activeru  r  r2   r^   r  r]   _lineage_root_id)r  r   r   rg   r   r   r  ro   r`   r   rX   r  r  r!  _compact_session_colsr   r   r   rW  r   r   r  r   r  r  r_   rR  _get_session_rich_rows_batchr   valuesr   ):r  r  rU  rV  rh   rW  rX  rY  rZ  r[  r\  r]  r^  r_  r`  ra  rb  r  where_clausesr  include_sourcesr  clauseclause_params	where_sqlbase_where_paramsprompt_selectprompt_join	id_needlesearch_needleouter_where	id_paramsfilter_clausesrq  compact_needlecompact_sqlsearch_clausecombined_selr  r   r   r  r  r[   rs  seen_idspinned_wherepinned_querypinned_cursorpinned_rowstip_ids_by_roottip_idtip_rows	projectedtip_rowr  rp   s:                                                             r9   list_sessions_richzSessionDB.list_sessions_rich  s	   T 	!!! 	U   !4555  $78H$I$I!S!S!STTT&,E6(($w}"2E2E 	+88#A#A#A#A#AAAL  !@!@!@!@AAAMM/*** 	'  !4555MM+&&& 	+88#A#A#A#A#AAAL  !D\!D!D!DEEEMM/*** 	)$6z$B$B!FM  (((MM-(((q    !7888MM+,,, 	3  !12222! 	3  !1222>KS:W\\-88:::QS	 !LL UBBT 	
  QBBP 	 ^**,,2244	%+2244::<< {	+ $K#%I(*N&c &c & & & &  ; %%I  
   y!9!9::: < "$	2}!E!E> 
>    --">">!?!!CDDD! D!R{11*==RRRM $$]]>%B%BCCC%%md&:;;; "<<775>Wy11x111DWXDWDW  4@J4--///UD$7@$ $  <HEE!$ $( )$( ,)$ $, "5-$ $8 .c229$ $@ A$ $B C$ $ $EN f_y0E6?BFF3?J4--///UD+  "5  .c22      E  MM5&/***^^ 	%\\%00F??$$D	% 	% 	% 	% 	% 	% 	% 	% 	% 	% 	% 	% 	% 	% 	%  	 	C&&s++A)!%%*C*CDDAiLEE*D111OOA  !	#22222H3<V9////BV  4@J4--///UD+  "5      L$ !! 7T $\;L M M+44667 7 7 7 7 7 7 7 7 7 7 7 7 7 7 # # #**3//T7h&&-aeeNB.G.GHH)QtW%%%"""" $ )	!,< )	! /1O 6 655&&-7711!D'::QtW$$/5OAdG, #11..0011 2      I ) )(,,QtW5528B(,,v...d $$Q''' a 3 3C
 g~~&-cls-.tW)*  (((( Hs$   -+Q$$Q(+Q(6+U--U14U1z json:r   c                 6   t          |t                    rt          |          S |"t          |t          t          t
          f          r|S 	 | j        t          j        |          z   S # t          t          f$ r t          t          |                    cY S w xY w)ah  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.
        )rW   rX   r	   r   rC   r  _CONTENT_JSON_PREFIXr  r  rE   rF   clsr   s     r9   _encode_contentzSessionDB._encode_content&  s     gs## 
	1 (000?j5#u2EFF?N	6 +dj.A.AAA:& 	6 	6 	6'G55555	6s   A( (-BBc                 2   t          |t                    r|                    | j                  rg	 t	          j        |t          | j                  d                   S # t          j        t          f$ r t          
                    d           |cY S w xY w|S )z;Reverse :meth:`_encode_content`; returns scalars unchanged.NzCFailed to decode JSON-encoded message content; returning raw string)rW   rX   r   r  r  r  r   JSONDecodeErrorrE   r  r  r  s     r9   _decode_contentzSessionDB._decode_contentI  s     gs## 	(:(:3;S(T(T 	z'#c.F*G*G*H*H"IJJJ()4   +    s   -A 2BBdisplay_metadatac                    | sdS t          | t                    r	 t          j        |           }n7# t          j        t
          f$ r t                              d           Y dS w xY wt          |t                    st                              d           dS t          j	        |          S t          | t                    rt          j	        |           S t                              dt          |           j                   dS )a~  Serialize ``display_metadata`` for its TEXT column without double-encoding.

        Import/replace paths can hand us an already-serialized JSON string (the
        same hazard ``tool_calls`` guards against above). ``json.dumps`` on that
        string would store a quoted JSON string, and the single ``json.loads``
        on read then yields a ``str`` instead of a dict.
        Nz+Ignoring non-JSON display metadata on writez-Ignoring non-object display metadata on writez6Ignoring unexpected display metadata type on write: %s)rW   rX   r  r  r  rE   r  r  r   r  rN  r   )r  r  s     r9   _encode_display_metadataz"SessionDB._encode_display_metadataW  s      	4&,, 		&$455()4   LMMMtt fd++ NOOOt:f%%%&-- 	0:.///D!""+	
 	
 	
 ts   0 0A$#A$c                 b   |                     d|t          j                    f                                          }||d         |k    rt          d|d          |                     d|f                                          }|#|d         |d         d	k    rt	          |          dS dS dS )
uF  Transcript-append admission checks, run INSIDE the write txn.

        Shared by :meth:`append_message` and :meth:`append_messages_batch` so
        the two writers can never diverge on these correctness invariants
        (this guard has already needed targeted fixes — see the #74478
        patience note below).
        zLSELECT holder FROM compression_locks WHERE session_id = ? AND expires_at > ?Nr<   r  z& is being compressed by another writerr  r  r  r  )r   r	  r   r  r  )r  r   r  r
  active_locksessions         r9   _check_transcript_write_guardsz(SessionDB._check_transcript_write_guardst  s     ll6%
 
 (**	 	 #H%)@@@3O:OOO   ,,DM
 
 (** 	
 
#/%66/
;;;	  //66r;   r   c                    | dS 	 t          | t                    rt          j        |           n| }t          |t                    rt          j        |          }n7# t          j        t
          f$ r t                              d           Y dS w xY wt          |t                    st                              d           dS |S )a  Decode a ``display_metadata`` column into the dict every reader expects.

        Every message read path must go through this. Returning the raw TEXT
        instead reaches the desktop as a string, where ``'task_count' in meta``
        throws and fails the whole resume. Rows written before the encode guard
        landed are double-encoded, so unwrap a second layer when we find one.
        Nz0Ignoring invalid display metadata on message rowz3Ignoring non-object display metadata on message row)	rW   rX   r  r  r  rE   r  r  r   )r   metas     r9   _decode_display_metadataz"SessionDB._decode_display_metadata  s     ;4	&0c&:&:C4:c???D$$$ (z$''$i0 	 	 	NNMNNN44	 $%% 	NNPQQQ4s   AA 0BBr   r`  
tool_callstool_call_idtoken_countfinish_reason	reasoningreasoning_contentreasoning_detailscodex_reasoning_itemscodex_message_itemsplatform_message_idobservedeffect_disposition	timestampapi_contentdisplay_kindc                    	
                       |          |rt          j        |          nd|rt          j        |          nd|rt          j        |          ndt          |t                    r4	 t          j        |          }n# t          j        t          f$ r g }Y nw xY w|rt          j        |          nd                     |          t          j	                    |u	 t          |d          r"t          |                                          nt          |          n2# t          t          f$ r t                              d|           Y nw xY wd|&t          |t                     rt#          |          nd	
 fd}                     | j                  S )u  
        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).
        Nr  /Ignoring invalid explicit message timestamp: %rr   r?   c                                         |            |                     dt                    	
t                    t                    rdnddt          t                    rt                    nd t          t                    rt                    nd f          }|j        }dk    r|                     df           n|                     df           |S )N  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, display_kind, display_metadata)
                   VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)r?   r   zUPDATE sessions SET message_count = message_count + 1,
                       tool_call_count = tool_call_count + ? WHERE id = ?zBUPDATE sessions SET message_count = message_count + 1 WHERE id = ?)r  r   rZ   rW   rX   	lastrowid)r   r   msg_idr  codex_items_jsoncodex_message_items_jsonr
  r  display_metadata_jsonr  r  message_timestampnum_tool_callsr  r  r  r  reasoning_details_jsonr   r  r  stored_contentr  r  tool_calls_jsonr`  s      r9   r  z%SessionDB.append_message.<locals>._do  sJ   //j"9   \\] " #%i00&%!%i00%&788*$,'!(AAq6@c6R6R\%k222X\7A,PS7T7T^%l333Z^)+ F< %F !!M#Z0    XM   Mr;   r  )r  r  r  rW   rX   r  r  rE   r  r	  hasattrr  r  rF   r  r  r   r   r  r  )r  r  r   r   r`  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r
  r  r  r  r  r  r  r  r  r  s   ``` ` `````   ``` `` ` @@@@@@@@r9   append_messagezSessionDB.append_message  s   \ !% = =>N O O !+DJ()))&* 	 %/DJ,---*. 	 #-DJ*+++(, 	! j#&& 	  !Z
33

()4      


 4>H$*Z000D --g66 IKK [9k22 9(-i.A.A.C.C(D(D%%(-i(8(8%z* [ [ [NPYZZZZZ[ !0::t0L0LSS___RSN0	 0	 0	 0	 0	 0	 0	 0	 0	 0	 0	 0	 0	 0	 0	 0	 0	 0	 0	 0	 0	 0	 0	 0	 0	 0	 0	n ""D= # 
 
 	
s%   B   B;:B;AE ,E21E2
chunk_rowsc           	          sdS |^t                    |k    rKd}t          dt                    |          D ](}|                     |||z                      z  })|S  fd}                     | j                  S )u  Append multiple messages atomically in ONE write transaction.

        ``messages`` is a list of dicts in the same shape
        :meth:`_insert_message_rows` already consumes for replace/compact/
        import (role, content, tool_name, tool_calls, tool_call_id,
        finish_reason, reasoning*, codex_*, timestamp, api_content,
        display_kind, display_metadata, ...). Reusing that helper keeps ONE
        row-serialization path for every multi-row writer.

        A turn-boundary flush writes the whole turn (user + assistant + tool
        rows, typically 3-8 messages) as one BEGIN IMMEDIATE / commit pair
        instead of one transaction (and, off WAL, one fsync) per row.

        Atomicity contract: all rows land or none do (the caller re-flushes
        unstamped messages on the next attempt). The same admission guards
        as :meth:`append_message` run once for the batch — same session,
        same instant.

        ``chunk_rows`` bounds the transaction size for LARGE copies (branch
        seeds can be thousands of rows; measured: 10k rows ≈ 2.4s inside one
        BEGIN IMMEDIATE because the FTS triggers run per row, which would
        monopolize the write lock and starve concurrent writers). When set,
        the batch commits in chunks of at most that many rows — same
        recovery semantics as the old per-row loops (a mid-copy failure
        leaves a partial seed), just with bounded lock holds. A turn flush
        never needs it. Returns the inserted row count.
        r   N)r
  c                                          |                                |           \  }}|dk    r|                     d||f           n|                     d|f           |S )Nr   zUPDATE sessions SET message_count = message_count + ?,
                       tool_call_count = tool_call_count + ? WHERE id = ?zBUPDATE sessions SET message_count = message_count + ? WHERE id = ?)r  r  r   )r   insertedtool_calls_totalr
  r   r  r  s      r9   r  z,SessionDB.append_messages_batch.<locals>._don  s    //j"9   *.)B)Bj(* *&H&  !##M/<    Xz*   Or;   r  )r   r  append_messages_batchr  r  )r  r  r   r
  r  inserted_totalr  r  s   ````    r9   r  zSessionDB.append_messages_batch?  s    D  	1!c(mmj&@&@Nq#h--<<  $"<"<U5:#556,C #= # # 
 "!	 	 	 	 	 	 	 	, ""D= # 
 
 	
r;   )r  c                v     rrsdS  fd}t                               |                    S )a_  Stamp presentation metadata on this turn's freshly persisted row.

        The model still receives ``role`` and ``content`` unchanged. Gateway and
        CLI synthetic inputs call this immediately after their serial turn has
        flushed, preserving producer provenance without classifying by content
        during transcript rendering.
        Fc                 
   |                      d                              f                                          }|dS |                      dt                                                  |d         f           dS )NzqSELECT id FROM messages WHERE session_id = ? AND role = ? AND content = ? AND active = 1 ORDER BY id DESC LIMIT 1FzGUPDATE messages SET display_kind = ?, display_metadata = ? WHERE id = ?r   T)r   r  r   rZ   r  )r   r[   r   r  r  r   r  r  s     r9   r  z?SessionDB.set_latest_matching_message_display_kind.<locals>._do  s    ,,JT4#7#7#@#@A  hjj	 
 {uLLY%l33112BCCF   4r;   )rv  r  )r  r  r   r   r  r  r  s   `````` r9   (set_latest_matching_message_display_kindz2SessionDB.set_latest_matching_message_display_kind  su      	 	 	5	 	 	 	 	 	 	 	 	 	$ D'',,---r;   	reactionsr   authormessage_row_idemojir  c                T     rdS  fd}                      |          S )ap  Set (or with ``emoji=None`` clear) *author*'s reaction on one message.

        iOS Tapback semantics: one reaction per author per message. Re-sending
        the same emoji clears it, a different emoji replaces it. Returns the
        message's full reaction list after the write, or ``None`` when the row
        doesn't exist or isn't part of *session_id*.
        Nc                    |                      d	f                                          }|d S 
                    |d                   pi }|                    
j                  }fdt          |t                    r|ng D             }t          fdt          |t                    r|ng D             d           }d uo|d uo|                    d          k    }r9|s7|                    t                    t          j
                    d           |r||
j        <   n|                    
j        d            |                      d|r
                    |          nd 	f           |S )NESELECT display_metadata FROM messages WHERE id = ? AND session_id = ?r   c                 p    g | ]2}t          |t                    r|                    d           k    0|3S r  rW   r   r_   rz   r  r  s     r9   r   z?SessionDB.set_message_reaction.<locals>._do.<locals>.<listcomp>  sL       a&& ,-55??f+D+D +D+D+Dr;   c              3   x   K   | ]4}t          |t                    r|                    d           k    0|V  5dS )r  Nr  r  s     r9   r   z>SessionDB.set_message_reaction.<locals>._do.<locals>.<genexpr>  sX        !!T** 01uuX&/H/H /H/H/H/H r;   r  )r  r  at5UPDATE messages SET display_metadata = ? WHERE id = ?)r   r   r  r_   REACTIONS_METADATA_KEYrW   r   nextr   rZ   r	  r  r  )r   r[   r  r  r  previoustoggling_offr  r  r  r  r  s          r9   r  z+SessionDB.set_message_reaction.<locals>._do  s   ,,W,  hjj  {t00Q88>BDxx ;<<H   &04&@&@H((b  I
    *4Xt*D*DLhh"  
  H T!]hd&:]x||G?T?TX]?]   \   /66&PTPYP[P[\\    <4=T0114d;;;LLG8<F..t444$W   r;   r  )r  r  r  r  r  r  s   ````` r9   set_message_reactionzSessionDB.set_message_reaction  s_      	^34)	 )	 )	 )	 )	 )	 )	 )	 )	V ""3'''r;   c                 f   |r|g S | j         5  | j                            d||f                                          }ddd           n# 1 swxY w Y   |g S |                     |d                   pi }|                    | j                  }t          |t                    rd |D             ng S )zGReturn the reaction list persisted on one message row (never ``None``).Nr  r   c                 <    g | ]}t          |t                    |S rf   )rW   r   r  s     r9   r   z3SessionDB.get_message_reactions.<locals>.<listcomp>  s'    <<<a
1d(;(;<<<<r;   )	r9  r#  r   r   r  r_   r  rW   r   )r  r  r  r[   r  r  s         r9   get_message_reactionszSessionDB.get_message_reactions  s
     	^3IZ 	 	*$$W,  hjj 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 ;I,,SV44:HHT899	@J9VZ@[@[c<<9<<<<accs   0A

AAc                L     sg S  fd}                      |          pg S )a6  Return *author*'s not-yet-surfaced reactions and mark them seen.

        Powers the cache-safe model-context path: reactions are announced on the
        NEXT user turn (never by rewriting the message that was reacted to), and
        the ``seen`` stamp guarantees each one is announced exactly once.
        c                    |                      df                                          }g }|D ]C}
                    |d                   }|s!|                    
j                  }t          |t                    sQd}|D ]}t          |t                    r.|                    d          	k    s|                    d          rFd|d<   d}
                    |d                   }|	                    |d         |d	         |                    d
          pdt          |t                    r|ndd           |r1|                      d
                    |          |d         f           E|S )NzSELECT id, role, content, display_metadata FROM messages WHERE session_id = ? AND active = 1 AND display_metadata IS NOT NULL ORDER BY idr  Fr  rP  Tr   r   r   r  r>   )row_idr   r  rA  r  )r   r   r  r_   r  rW   r   r   r  r   rX   r  )r   r  pendingr[   r  r  changedreactionr   r  r  r  s            r9   r  z,SessionDB.take_unseen_reactions.<locals>._do  s   << 	 
 hjj  G    44S9K5LMM  HHT%@AA	!)T22  )  H&x66!#<<11V;;#<<// < !'+HV$"G"223y>BBGNN&)$i$'K%-\\'%:%:%@b/9'3/G/G$OGGR	      LLO66t<<c$iH  
 Nr;   r  )r  r  r  r  s   ``` r9   take_unseen_reactionszSessionDB.take_unseen_reactions  sS      	I+	 +	 +	 +	 +	 +	 +	Z ""3''-2-r;   )r   rX  require_textr  c          	         |r
|dvs|dk     rdS |rdnd}| j         5  | j                            d| d||t          |          f                                          }ddd           n# 1 swxY w Y   |r|d         ndS )uP  Row id of the most recent active message with *role*, or ``None``.

        Two callers, same need — "the message I mean, without an id": the agent
        defaulting to the turn that triggered it, and the desktop reacting to a
        live message that hasn't round-tripped through a resume yet.
        ``offset`` steps to earlier turns (1 = the one before the latest) so a
        reaction can land retroactively — "two messages ago" is how the caller
        thinks about it.

        ``require_text`` (default) skips rows with no plain-text content —
        tool-call-only assistant turns and attachment stubs don't render as
        bubbles, so "the latest message" as a HUMAN means it must never
        resolve to one (a reaction landing on an invisible row looks dropped,
        and its annotation quotes an empty string).
        >   r   r   r   Nz0AND content IS NOT NULL AND TRIM(content) != '' r>   zISELECT id FROM messages WHERE session_id = ? AND role = ? AND active = 1 z!ORDER BY id DESC LIMIT 1 OFFSET ?r9  r#  r   rC   r   )r  r  r   rX  r  text_filterr[   s          r9   latest_message_row_idzSessionDB.latest_message_row_id>  s    $  	T)>>>&1**4 COV>>TV 	 Z 	 	*$$Q"-Q Q QT3v;;/  hjj	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 &s1vv$&s   AA**A.1A.c                 0    |                      |d          S )a  Row id of the most recent active user message, or ``None``.

        The agent's default reaction target: "the message that triggered me",
        so the model never has to thread row ids through a tool call (mirrors
        the photon adapter's ``_record_last_inbound``).
        r   )r   )r  )r  r  s     r9   latest_user_message_row_idz$SessionDB.latest_user_message_row_id`  s     ))*6)BBBr;   r  c                     |sdS | j         5  | j                            dt          |          |f                                          }ddd           n# 1 swxY w Y   |r|d         ndS )zRole of the active message at *row_id* in *session_id*, or ``None``.

        Lets a reaction event carry the target's role so a renderer can match
        a live message that doesn't know its durable row id yet.
        NzHSELECT role FROM messages WHERE id = ? AND session_id = ? AND active = 1r   r  )r  r  r  r[   s       r9   get_message_rolezSessionDB.get_message_rolei  s      	4Z 	 	*$$ZVj)  hjj 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 &s1vv$&s   =AAAc                 ~   t          j                     }d}d}|D ]}|                    dd          }|                    d          }	|}
|                    d          	 |                    d          }t          |d          r"t          |                                          }
nt          |          }
nE# t
          t          f$ r1 t                              d|                    d                     Y nw xY w|dk    r|                    d	          nd}|dk    r|                    d
          nd}|dk    r|                    d          nd}|rt          j
        |          nd}|rt          j
        |          nd}|rt          j
        |          nd}t          |	t                    r4	 t          j        |	          }	n# t          j        t
          f$ r g }	Y nw xY w|	rt          j
        |	          nd}|                    d          p|                    d          }|                    d          }|                    d|||                     |                    d                    |                    d          |t#          |                    d                    |                    d          |
|                    d          |                    d          |dk    r"t#          |                    d                    nd|dk    r"t#          |                    d                    nd|||||                    d          rdnddt          |t                    rt#          |          ndt          |                    d          t                    r"t#          |                    d                    nd|                     |                    d                    f           |dz  }|	)|t          |	t&                    rt)          |	          ndz  }t+          |dz   |
dz             }!||fS )u  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.
        r   r   r  r  r  Nr  r   r  r  r  r  
message_idr  r  r   r  r`  r  r  r  r  r  r  r?   r  r  gư>)r	  r_   r  r  r  rE   rF   r  r  r  r  rW   rX   r  r  r   r  rZ   r  r   r   r  )r  r   r  r   now_tsr  r  r   r   r  r  ts_valuer  r  r  r  r  r  r  platform_msg_idr  s                        r9   r  zSessionDB._insert_message_rowsz  sv     R	B R	BC7769--D..J &ww{##/j"ww{33Hx55 <,1(2D2D2F2F,G,G)),1(OO)!:. j j jLL!RTWT[T[\gThThiiiiij@D@S@S(; < < <Y]48K4G4G/000T " 37+2E2E-...4   2CL
,--- # 6KT
0111PT  4GP
.///D % *c** $$!%J!7!7JJ,i8 $ $ $!#JJJ$8BLdj444O -..G#'',2G2G  ''-00KLL] ((););<<GGN++#%cggk&:&:;;GG011%GGM**GGO,,?C{?R?R%cggk&:&:;;;X\GK{GZGZ%cgg.A&B&BCCC`d*$,#,,3AA!6@c6R6R\%k222X\BLSWWUcMdMdfiBjBjt%cggn&=&=>>>pt11#'':L2M2MNN+  < MH% '1*d'C'CJC
OOO  $(9D(@AAFF)))s%    AB77?C98C91GG! G!c                 T     |rdnd fd}                      |           dS )uy  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`.
         AND active = 1r>   c                 l   |                      df                                          }|#|d         |d         dk    rt                    |                      d f           |                      df                               |           \  }}|                      d||f           d S )Nr  r  r  r  )DELETE FROM messages WHERE session_id = ?GUPDATE sessions SET message_count = 0, tool_call_count = 0 WHERE id = ?r  )r   r   r  r  )r   r  r  r  active_clauser   r  r  s       r9   r  z'SessionDB.replace_messages.<locals>._do  s    llH  hjj 
 #J'3L)]::3J???LLKMKK   LLY   04/H/Hj(0 0,N, LLY!1:>    r;   Nr  )r  r  r   r  r  r  s   ```  @r9   replace_messageszSessionDB.replace_messages  s\    6 .9@))b	 	 	 	 	 	 	 	6 	C     r;   c                     | j         5  | j                            d|f          }|                                ducddd           S # 1 swxY w Y   dS )u?  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.
        zBSELECT 1 FROM messages WHERE session_id = ? AND active = 0 LIMIT 1Nr4  r  r  r   s      r9   has_archived_messageszSessionDB.has_archived_messages  s     Z 	1 	1Z''T F ??$$D0	1 	1 	1 	1 	1 	1 	1 	1 	1 	1 	1 	1 	1 	1 	1 	1 	1 	1s   2AAAcompacted_messagesc                 @      fd}                      |          S )uj  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.
        c                     |                      df                               |           \  }}|                      d||f           |S )NzQUPDATE messages SET active = 0, compacted = 1 WHERE session_id = ? AND active = 1r  )r   r  )r   r  r  r  r  r  s      r9   r  z*SessionDB.archive_and_compact.<locals>._do>  sw     LL6  
 *.)B)Bj"4* *&H&
 LLY+Z8   Or;   r  )r  r  r  r  s   ``` r9   archive_and_compactzSessionDB.archive_and_compact#  s>    6	 	 	 	 	 	 	. ""3'''r;   c                 j    |                      |          fd}|                     |          S )u  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).
        c                 ^    |                      dt                    f          }|j        S )NzUPDATE 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 ?)r   rZ   r  )r   r   r  encodedr  s     r9   r  z2SessionDB.set_latest_user_api_content.<locals>._doj  s9    \\%
 #;//WE F ?"r;   )r  r  )r  r  r   r  r  r  s    ` ` @r9   set_latest_user_api_contentz%SessionDB.set_latest_user_api_contentW  sS    " &&w//		# 		# 		# 		# 		# 		# 		# ""3'''r;   include_inactivec                    |rdnd}d| d}|g}||r |dz  }|                     |dn||g           |                                 5 }|                    ||          }	|	                                }
ddd           n# 1 swxY w Y   g }|
D ]}t	          |          }d|v r|                     |d                   |d<   |                    d	          rZ	 t          j        |d	                   |d	<   n;# t          j	        t          f$ r" t                              d
           g |d	<   Y nw xY w|                    d          |                     |d                   |d<   |                    |           |S )u=  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).
        r>   r  z+SELECT * FROM messages WHERE session_id = ? ORDER BY idNz LIMIT ? OFFSET ?r!  r   r  zDFailed to deserialize tool_calls in get_messages, falling back to []r  )r  rW  r   r   r   r  r_   r  r  r  rE   r  r  r  r   )r  r  r  rW  rX  r  r_  r  r   r   r  r  r[   r   s                 r9   get_messageszSessionDB.get_messagesw  s   . /E4E++ + + 	 #|&&CMM22E6BCCC^^ 	%\\#v..F??$$D	% 	% 	% 	% 	% 	% 	% 	% 	% 	% 	% 	% 	% 	% 	%  	 	Cs))CC!%!5!5c)n!E!EIww|$$ ++(,
3|3D(E(EC%%,i8 + + +NN#ijjj(*C%%%+ ww)**6*.*G*GL^H_*`*`&'MM#s$   +A??BBC555D-,D-   around_message_idwindowc                 8   |dk     rd}|                                  5 }|                    d||f                                          }|sg dddcddd           S |                    d|||dz   f                                          }|                    d|||f                                          }ddd           n# 1 swxY w Y   t	          t          |                    t	          |          z   }g }	|D ]}
t          |
          }d|v r|                     |d                   |d<   |                    d	          rZ	 t          j
        |d	                   |d	<   n;# t          j        t          f$ r" t                              d
           g |d	<   Y nw xY w|                    d          |                     |d                   |d<   |	                    |           t#          dt%          |          dz
            }t%          |          }|	||dS )u  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.
        r   z>SELECT 1 FROM messages WHERE id = ? AND session_id = ? LIMIT 1)r  messages_beforemessages_afterNzPSELECT * FROM messages WHERE session_id = ? AND id <= ? ORDER BY id DESC LIMIT ?r?   zNSELECT * FROM messages WHERE session_id = ? AND id > ? ORDER BY id ASC LIMIT ?r   r  zKFailed to deserialize tool_calls in get_messages_around, falling back to []r  )rW  r   r   r   r   reversedr   r  r_   r  r  r  rE   r  r  r  r   r  r   )r  r  r  r  r   anchor_existsbefore_rows
after_rowsr  r  r[   r   r  r  s                 r9   get_messages_aroundzSessionDB.get_messages_around  s   2 A::F^^ 	 LLP"J/  hjj  ! Q"$aPP	 	 	 	 	 	 	 	 ,,+ .
;	 
 hjj  * .7	 
 hjj #	 	 	 	 	 	 	 	 	 	 	 	 	 	 	2 H[))**T*-=-== 	 	Cs))CC!%!5!5c)n!E!EIww|$$ ++(,
3|3D(E(EC%%,i8 + + +NNe   )+C%%%	+
 ww)**6*.*G*GL^H_*`*`&'MM# a[!1!1A!566Z.,
 
 	
s*   2CACCCE##5FFc                    |s|S 	 |                      |          }n# t          $ r |}Y nw xY w|r||k    r|}| j        5  |}|h}d}t          d          D ]}	 | j                            d|f                                          }n # t          $ r |cY c cddd           S w xY w||}	 | j                            d|f                                          }n # t          $ r |cY c cddd           S w xY w| n@t          |d          r|d         n|d         }	|	r|	|v r n|                    |	           |	}||n|cddd           S # 1 swxY w Y   dS )u  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.
        N    z3SELECT 1 FROM messages WHERE session_id = ? LIMIT 1a  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keysr   r   )	rR  rK   r9  r  r#  r   r   r  r  )
r  r  tipr  rP  bestr  r[   	child_rowrQ  s
             r9   resolve_resume_session_idz#SessionDB.resolve_resume_session_id  s   .  		**:66CC 	 	 	CCC	 	3*$$JZ +	< +	< G9DD2YY $# $#&*,,M 
  hjj C ! & & &%%%%%+	< +	< +	< +	< +	< +	< +	< +	<&?"D& $
 2 2D !
! ! hjj I ! & & &%%%%%E+	< +	< +	< +	< +	< +	< +	< +	<B&$E.5i.H.HZ9T??iXYl 8t#3#3E""""+44W+	< +	< +	< +	< +	< +	< +	< +	< +	< +	< +	< +	< +	< +	< +	< +	< +	< +	<sf    ++E.BEB%E$B%%E-.CEC9(E8C99AEEEinclude_ancestorsrepair_alternationinclude_row_idsc           
         |g}|r|                      |          }|rdnd}|                                 5 }d                    d |D                       }	|                    d| j         d|	 d| dt          |                                                    }
d	d	d	           n# 1 swxY w Y   |                     |
||||
          S )u  
        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.
        r>   r  r}   c              3      K   | ]}d V  dS rl  rf   rm  s     r9   r   z9SessionDB.get_messages_as_conversation.<locals>.<genexpr>t  "      #=#=AC#=#=#=#=#=#=r;   zSELECT $ FROM messages WHERE session_id IN (r   r  Nr  r*  r+  r,  _session_lineage_root_to_tiprW  r   r   _CONVERSATION_ROW_COLUMNSr  r   _rows_to_conversation)r  r  r*  r  r+  r,  session_idsr  r   r  r  s              r9   get_messages_as_conversationz&SessionDB.get_messages_as_conversationT  sI   4 "l 	H;;JGGK.E4E^^ 	88#=#=#=#=#===L<<
/$8 
/ 
/6B
/ 
/ !
/ 
/ 
/ k""  hjj 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	" ))!/1+ * 
 
 	
s   A$B%%B),B)a  id, 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, display_kind, display_metadata)r,  c                   g }|D ]7}|                      |d                   }|d         dv r6t          |t                    r!t          |                                          }|d         |d}	|r|d         |d         |	d<   |d         r|d         |	d<   |d	         r|d	         |	d	<   |d
         r"|                     |d
                   }
|
|
|	d
<   |d         r|d         |	d<   |d         r|d         |	d<   |d         r|d         |	d<   |d         r|d         |	d<   |d         rZ	 t          j        |d                   |	d<   n;# t          j        t          f$ r" t                              d           g |	d<   Y nw xY w|d         r|d         |	d<   |d         rd|	d<   |d         dk    r_|d         r|d         |	d<   |d         r|d         |	d<   |d         |d         |	d<   |d         rZ	 t          j        |d                   |	d<   n;# t          j        t          f$ r" t                              d           d|	d<   Y nw xY w|d         rZ	 t          j        |d                   |	d<   n;# t          j        t          f$ r" t                              d           d|	d<   Y nw xY w|d         rZ	 t          j        |d                   |	d<   n;# t          j        t          f$ r" t                              d           d|	d<   Y nw xY w|r|                     ||	          r"|                    |	           9t          |          }|r2|r0dd lm}  |d|          }|rt                              d!||           |S )"ap  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.
        r   r   >   r   r   )r   r   r   N_row_idr  r  r  r  r  r`  r  r  zKFailed to deserialize tool_calls in conversation replay, falling back to []r  r  r  Tr   r  r  r  r  z=Failed to deserialize reasoning_details, falling back to Noner  zAFailed to deserialize codex_reasoning_items, falling back to Noner  z?Failed to deserialize codex_message_items, falling back to Noner   )repair_message_sequenceu   Repaired %d message-alternation violation(s) while restoring session %s — durable transcript kept them, see repair_message_sequence)r  rW   rX   r   r`   r  r  r  r  rE   r  r  #_is_duplicate_replayed_user_messager   r   agent.agent_runtime_helpersr:  r   )r  r  r  r*  r+  r,  r   r[   r   r   decodedr:  r  s                r9   r5  zSessionDB._rows_to_conversation  s      P	! P	!C**3y>::G6{333
7C8P8P3*73399;;v;7;;C  +3t9#8!$TI =! 8%(%7M">" :&).&9N#%& 677<N8OPP&.5C*+; 4#&{#3K >" :&).&9N#; 4#&{#3K '( F,/0D,E()<  ++(,
3|3D(E(EC%%,i8 + + +NN#pqqq(*C%%%+ () ?$'(=$>L!: '"&J 6{k))' @+.+?C({# 8'*;'7C$*+7/23F/GC+,*+ 8837:cBU>V3W3W/00 0)< 8 8 8'fggg37/0008 ./ <<7;z#F]B^7_7_344 0)< < < <'jkkk7;3444< ,- ::59ZDY@Z5[5[122 0)< : : :'hiii591222: ! T%M%MhX[%\%\ OOC     4H== 	( 	 LKKKKK..tX>>H 2    sH   (E5E>=E>0H5III005J('J(4K5L
	L
c                    |                                }|                                 5 }d                    d |D                       }|                    d| j         d| dt          |                                                    }ddd           n# 1 swxY w Y   fd|D             }|                     |dd	d	
          }|                     |d	dd	
          }||fS )u  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).
        r}   c              3      K   | ]}d V  dS rl  rf   rm  s     r9   r   z5SessionDB.get_resume_conversations.<locals>.<genexpr>)  r/  r;   SELECT session_id, r0  ) AND active = 1 ORDER BY idNc                 ,    g | ]}|d          k    |S r  rf   rz   r  r  s     r9   r   z6SessionDB.get_resume_conversations.<locals>.<listcomp>6  s'    EEE!q*'D'DA'D'D'Dr;   FTr1  r2  )	r  r  r6  r   r  r  r  model_historydisplay_historys	    `       r9   get_resume_conversationsz"SessionDB.get_resume_conversations  sk   & 77
CC^^ 		88#=#=#=#=#===L<<d&D  6B  
 k""  hjj 		 		 		 		 		 		 		 		 		 		 		 		 		 		 		 FEEEtEEE22!##  3 
 
 44!"$  5 
 
 o--s   A!BBBc                    |                                }t          |          dk    rg S |                                 5 }d                    d |D                       }|                    d| j         d| dt          |                                                    }ddd           n# 1 swxY w Y   fd|D             }|sg S |                     |d	d
          S )uG  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)
        r?   r}   c              3      K   | ]}d V  dS rl  rf   rm  s     r9   r   z8SessionDB.get_ancestor_display_prefix.<locals>.<genexpr>_  r/  r;   r@  r0  rA  Nc                 ,    g | ]}|d          k    |S rC  rf   rD  s     r9   r   z9SessionDB.get_ancestor_display_prefix.<locals>.<listcomp>f  s'    JJJqAlOz,I,I,I,I,Ir;   TF)r  r*  r+  )	r3  r   rW  r   r   r4  r  r   r5  )r  r  r6  r   r  r  ancestor_rowss    `     r9   get_ancestor_display_prefixz%SessionDB.get_ancestor_display_prefixG  sX   ( 77
CC{q  I^^ 	88#=#=#=#=#===L<<d&D  6B   k""	 
 hjj 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 KJJJDJJJ 	I))!"$	 * 
 
 	
s    A!B--B14B1c                 T    |                      |          }|r|d         r|d         n|S )u'  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.
        r   )r3  )r  r  chains      r9   get_conversation_rootzSessionDB.get_conversation_rootp  s3     11*==!>eAh>aJ?r;   c                    |s|gS g }|}t                      }|                                 5 }t          d          D ]}|r||v r nx|                    |           |                    |           |                    d|f                                          }| n!t          |d          r|d         n|d         }d d d            n# 1 swxY w Y   t          t          |                    p|gS )Nr  z3SELECT parent_session_id FROM sessions WHERE id = ?r%  r  r   )
r   rW  r  r  r   r   r   r  r   r  )r  r  rN  r  rP  r   r  r[   s           r9   r3  z&SessionDB._session_lineage_root_to_tip~  sa    	 <uu^^ 	W3ZZ W W 'T//E!!!W%%%llIJ  (**  ;E6=c66J6JV#122PSTUPV	W 	W 	W 	W 	W 	W 	W 	W 	W 	W 	W 	W 	W 	W 	W HUOO$$44s   BC

CCr   c                    |                     d          dk    rdS |                     d          }t          |t                    r|sdS t          |           D ]}}|                     d          dk    r|                     d          |k    r dS |                     d          dk    r-|                     d          s|                     d          r dS ~dS )Nr   r   Fr   Tr   r  )r_   rW   rX   r  )r   r   r   prevs       r9   r;  z-SessionDB._is_duplicate_replayed_user_message  s    776??f$$5'')$$'3'' 	w 	5X&& 	 	Dxx6))dhhy.A.AW.L.Lttxx;..DHHY4G4G.488T`KaKa.uuur;   target_message_idc                    | j         5  | j                            df                                          }ddd           n# 1 swxY w Y   |t	          d d           t          |          }|                    d          dk    r)t	          d|                    d          d d	          |                     |                    d
                    |d
<   g }fd}|                     |          }| j         5  | j                            df                                          }ddd           n# 1 swxY w Y   |r|d         |d         nd}t          |          ||dS )u  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.
        z6SELECT * FROM messages WHERE id = ? AND session_id = ?Nzmessage z not found in session r   r   z1rewind target must be a 'user' message (got role=z, id=r   r   c                    |                      df          }d |                                D             }|r9d                    d |D                       }|                      d| d|           |                      df           |S )NzGSELECT id FROM messages WHERE session_id = ? AND id >= ? AND active = 1c                     g | ]
}|d          S rj  rf   r  s     r9   r   z<SessionDB.rewind_to_message.<locals>._do.<locals>.<listcomp>      333A1Q4333r;   r}   c              3      K   | ]}d V  dS rl  rf   rm  s     r9   r   z;SessionDB.rewind_to_message.<locals>._do.<locals>.<genexpr>  "      '9'9'9'9'9'9'9'9r;   z,UPDATE messages SET active = 0 WHERE id IN (r   zMUPDATE sessions SET rewind_count = COALESCE(rewind_count, 0) + 1 WHERE id = ?)r   r   r   )r   r   r   r  r  rS  s       r9   r  z(SessionDB.rewind_to_message.<locals>._do  s    \\B./ F
 43!2!2333C "xx'9'9S'9'9'999R<RRR   LL  
 Jr;   z@SELECT MAX(id) FROM messages WHERE session_id = ? AND active = 1r   )rewound_counttarget_messagenew_head_id)
r9  r#  r   r   rF   r   r_   r  r  r   )	r  r  rS  r[   
target_rowrewoundr  head_rowr\  s	    ``      r9   rewind_to_messagezSessionDB.rewind_to_message  sS   < Z 	 	*$$H"J/  hjj 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	
 ;P,PPJPP   #YY
>>&!!V++G>>&))G G2CG G G   !% 4 4Z^^I5N5N O O
9	 	 	 	 	 	( %%c** Z 	 	z))R  hjj 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	
 &.S(1+2Ihqkkt !\\(&
 
 	
s#   0AA
A
/EE
Esince_message_idc                 <    fd}|                      |          S )zMark 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.
        c                    |                      df          }d |                                D             }|r9d                    d |D                       }|                      d| d|           t          |          S )NzGSELECT id FROM messages WHERE session_id = ? AND id >= ? AND active = 0c                     g | ]
}|d          S rj  rf   r  s     r9   r   z:SessionDB.restore_rewound.<locals>._do.<locals>.<listcomp>
  rW  r;   r}   c              3      K   | ]}d V  dS rl  rf   rm  s     r9   r   z9SessionDB.restore_rewound.<locals>._do.<locals>.<genexpr>  rY  r;   z,UPDATE messages SET active = 1 WHERE id IN (r   )r   r   r   r   )r   r   r   r  r  ra  s       r9   r  z&SessionDB.restore_rewound.<locals>._do  s    \\B-. F
 43!2!2333C "xx'9'9S'9'9'999R<RRR   s88Or;   r  )r  r  ra  r  s    `` r9   restore_rewoundzSessionDB.restore_rewound  s8    	 	 	 	 	 	 ""3'''r;   rb   c                 .    dt          d           d}g }g }|r*|                    d           |                    |           |r<t          |          \  }}	|                    |           |                    |	           |rdd                    |           nd}
|                    ||g            j        5   j                            | |
 d|          } fd	|                                D             cd
d
d
           S # 1 swxY w Y   d
S )a  List sessions, optionally filtered by source.

        Returns rows enriched with a computed ``last_active`` column
        (freshest of ``last_activity_at`` and latest message timestamp,
        else ``started_at``), ordered by most-recently-used first.

        Pass ``workspace_key`` to scope rows to one workspace - matching
        :func:`workspace_key` semantics (git repo root, else cwd). Used by
        ``hermes -c``/``--resume`` so the "last" session is the last one in
        the *current* workspace, not the global MRU.
        zMSELECT s.*, COALESCE(sp.prompt, s.system_prompt) AS _system_prompt_resolved, rs  z^ AS last_active FROM sessions s LEFT JOIN system_prompts sp ON sp.hash = s.system_prompt_hash s.source = ?r  rn  r>   zI ORDER BY last_active DESC, s.started_at DESC, s.id DESC LIMIT ? OFFSET ?c                 :    g | ]}                     |          S rf   r  )rz   r[   r  s     r9   r   z-SessionDB.search_sessions.<locals>.<listcomp>D  s'    MMMCD**3//MMMr;   N)	r   r   ru   r  r   r9  r#  r   r   )r  r  rW  rX  rb   select_with_last_activer  r  	ws_clause	ws_paramsr  r   s   `           r9   search_sessionszSessionDB.search_sessions  s   &M',,M M M 	   	"  000MM&!!! 	%#8#G#G Iy  +++MM)$$$?LT;gll=99;;;RT	ufo&&&Z 	N 	NZ''* [[ [ [ 	 F NMMM6??;L;LMMM	N 	N 	N 	N 	N 	N 	N 	N 	N 	N 	N 	N 	N 	N 	N 	N 	N 	Ns   =A D

DDexclude_childrenc	                    g }	g }
|r?|	                     t                     |	                     t          d           d           |r|gnt          |pg           }|rMd                    d |D                       }|	                     d| d           |
                    |           |rMd                    d |D                       }|	                     d| d           |
                    |           |r<t          |          \  }}|	                     |           |
                    |           |d	k    r*|	                     d
           |
                     |           |r|	                     d           n|s|	                     d           |	rdd                    |	           nd}| j        5  | j        	                    d| |
          }|
                                d	         cddd           S # 1 swxY w Y   dS )u   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).
        rd  re  r}   c              3      K   | ]}d V  dS rl  rf   rm  s     r9   r   z*SessionDB.session_count.<locals>.<genexpr>n  rg  r;   rh  r   c              3      K   | ]}d V  dS rl  rf   rm  s     r9   r   z*SessionDB.session_count.<locals>.<genexpr>r  rg  r;   rj  r   rk  rl  rm  r  rn  r>   zSELECT COUNT(*) FROM sessions sN)r   r   rg   r   r   r  ro   r9  r#  r   r   )r  r  rU  rh   rZ  r]  r^  rn  rV  r  r  r  r  r  r  r  r   s                    r9   session_countzSessionDB.session_countJ  s   2  	U   !4555  $78H$I$I!S!S!STTT&,E6(($w}"2E2E 	+88#A#A#A#A#AAAL  !@!@!@!@AAAMM/*** 	+88#A#A#A#A#AAAL  !D\!D!D!DEEEMM/*** 	)$6z$B$B!FM  (((MM-(((q    !7888MM+,,, 	3  !12222! 	3  !1222?LT;gll=99;;;RT	Z 	( 	(Z''(U)(U(UW]^^F??$$Q'	( 	( 	( 	( 	( 	( 	( 	( 	( 	( 	( 	( 	( 	( 	( 	( 	( 	(s   :8G??HHr?   r  c                     | j         5  | j                            d|f          }|                                }ddd           n# 1 swxY w Y   t	          |          |k    S )u  Check if at least N sessions exist (archived included).

        Short-circuits via LIMIT — much cheaper than ``session_count()``,
        which pays a full index scan for its default ``archived = 0``
        filter (measured 543us vs 4us on a 20k-session DB). Archived
        sessions count: every caller so far asks "has this install ever
        had sessions", and an archived session is still a created one.
        Use this instead of ``session_count() >= n`` when the exact count
        is irrelevant.
        zSELECT 1 FROM sessions LIMIT ?N)r9  r#  r   r   r   )r  r  r   r  s       r9   session_count_gezSessionDB.session_count_ge  s     Z 	% 	%Z''(H1$OOF??$$D	% 	% 	% 	% 	% 	% 	% 	% 	% 	% 	% 	% 	% 	% 	% 4yyA~r6  )r]  r^  rn  c                   g }g }|r?|                     t                     |                     t          d           d           |r|                     d           n|s|                     d           |rdd                    |           nd}| j        5  | j        t          d	          | j                            d
| d|                                          }ddd           n# 1 swxY w Y   d |D             S )a  Return a ``{source: count}`` dict via a single ``GROUP BY`` query.

        Replaces the O(N) ``list_sessions_rich`` histogram loop with an
        aggregate query. When ``exclude_children`` is False the query uses
        ``idx_sessions_source``; when True, the child-exclusion predicates
        require a full table scan (same as ``session_count`` and
        ``list_sessions_rich``).

        ``exclude_children=True`` mirrors ``list_sessions_rich`` visibility
        (roots + branch sessions, excluding sub-agent runs, delegates, and
        compression continuations) so the source counts match what the
        Sessions page actually lists.
        rd  re  rl  rm  r  rn  r>   NzSessionDB connection is closedzYSELECT COALESCE(NULLIF(s.source, ''), 'cli') AS source, COUNT(*) AS count FROM sessions szC GROUP BY COALESCE(NULLIF(s.source, ''), 'cli') ORDER BY count DESCc                 d    i | ]-}t          |d                    t          |d         pd          .S )r  r^  r   )rX   rC   r  s     r9   r  z5SessionDB.session_count_by_source.<locals>.<dictcomp>  s7    KKKsCM""CG(9$:$:KKKr;   )	r   r   rg   r   r9  r#  r  r   r   )r  r]  r^  rn  r  r  r  r  s           r9   session_count_by_sourcez!SessionDB.session_count_by_source  s   (  	U  !4555  $78H$I$I!S!S!STTT 	3  !12222! 	3  !1222?LT;gll=99;;;RT	Z 		 		z!"#CDDD:%%&"+& & &   hjj 		 		 		 		 		 		 		 		 		 		 		 		 		 		 		 LKdKKKKs   AC,,C03C0c                     | j         5  |r| j                            d|f          }n| j                            d          }|                                d         cddd           S # 1 swxY w Y   dS )z2Count messages, optionally for a specific session.z2SELECT COUNT(*) FROM messages WHERE session_id = ?zSELECT COUNT(*) FROM messagesr   Nr4  r
  s      r9   rx  zSessionDB.message_count  s    Z 	( 	( M++H:-  ++,KLL??$$Q'	( 	( 	( 	( 	( 	( 	( 	( 	( 	( 	( 	( 	( 	( 	( 	( 	( 	(s   AA((A,/A,c                     | j         5  | j                            d||f          }|                                ducddd           S # 1 swxY w Y   dS )ab  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.
        zOSELECT 1 FROM messages WHERE session_id = ? AND platform_message_id = ? LIMIT 1Nr4  )r  r  r  r   s       r9   has_platform_message_idz!SessionDB.has_platform_message_id  s     Z 	1 	1Z''K01 F
 ??$$D0	1 	1 	1 	1 	1 	1 	1 	1 	1 	1 	1 	1 	1 	1 	1 	1 	1 	1s   3AAAr  c                     |                     d          }|sdS 	 t          |t                    rt          j        |          n|}n# t
          t          j        f$ r Y dS w xY wt          |t                    o|                     d          d uS )Nrc   F_branched_from)r_   rW   rX   r  r  rE   r  r   )r  r  r   r-  s       r9   _is_branch_child_rowzSessionDB._is_branch_child_row  s    kk.)) 	5	%/S%9%9B$*S///sCC4/0 	 	 	55	#t$$N1A)B)B$)NNs   +A A! A!r  c                     |                     d          }|r|                     |          rdS |                     |          }t          |o|                     d          dk              S )Nr  Fr  r  )r_   r}  r  rv  )r  r  	parent_idrw  s       r9   _is_compression_child_rowz#SessionDB._is_compression_child_row  sm    II122	 	D55e<< 	5!!),,FHvzz,77=HIIIr;   c                    |                      |          }|r|                     |          r|r|gng S |}|d         h}|                     |          rZ|                      |d                   }|r
|d         |v rn2|}|                    |d                    |                     |          Z|d         g}|d         h}|}|                    d          dk    r| j        5  | j                            d|d         f                                          }	ddd           n# 1 swxY w Y   d}
|	D ]*}t          |          }|                     |          s|}
 n+|
r
|
d         |v rn^|
                    |
d                    |                    |
d                    |
}|d         |k    r|                    d          dk    ||v r|n|gS )z@Return compression ancestors through tip in chronological order.r   r  r  r  z
                    SELECT * FROM sessions
                    WHERE parent_session_id = ?
                    ORDER BY started_at ASC
                    N)r  r}  r  r  r_   r9  r#  r   r   r   r   )r  r  r  ra   	ancestorsrw  lineagerP  r  r  
next_childr[   	candidates                r9   get_compression_lineagez!SessionDB.get_compression_lineage  so   "":.. 	3$33G<< 	3#*2J<<2$ZL	,,T22 	&%%d+>&?@@F VD\Y66DMM$t*%%% ,,T22 	& :,T
|kk,''=88  z))
 T]$  (**                J   II	00;; !*JE  D!1T!9!9NN:d+,,,HHZ%&&& Gt}
** 1 kk,''=882 %//wwj\As   "5D##D'*D'c                 <    fd}|                      |           dS )z9Delete all messages for a session and reset its counters.c                 d    |                      df           |                      df           d S )Nr  r  r  r!  s    r9   r  z%SessionDB.clear_messages.<locals>._do%  sJ    LL;j]   LLY    r;   Nr  r"  s    ` r9   clear_messageszSessionDB.clear_messages#  s8    	 	 	 	 	 	C     r;   c                 "   | dS dD ]2}| | | z  }	 |                     d           ## t          $ r Y /w xY w	 |                     d| d          D ])}	 |                     d           # t          $ r Y &w xY wdS # t          $ r Y dS w xY w)aH  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.
        N)z.jsonz.jsonlT)
missing_okrequest_dump_z_*.json)unlinkrP   glob)r  r  rW  r   s       r9   r  zSessionDB._remove_session_files/  s    F) 	 	F*6f666AD))))   	!&&'Jz'J'J'JKK  HHH----   D 
  	 	 	DD	sA   *
77B  A.-B  .
A;8B  :A;;B   
BBc                    | j         5  | j                            d|f                                          }|sg cddd           S t	          | j        |g          }ddd           n# 1 swxY w Y   |gt          |          S )aE  Return every session row that :meth:`delete_session` would remove.

        The requested session is first, followed by its recursively discovered
        delegate/subagent children. Branch and compression children are not
        included because deletion preserves them by orphaning their parent
        reference.
        +SELECT 1 FROM sessions WHERE id = ? LIMIT 1N)r9  r#  r   r   r   sorted)r  r  rT  delegate_idss       r9   get_session_delete_targetsz$SessionDB.get_session_delete_targetsJ  s    Z 	Q 	QZ''=
} hjj   	Q 	Q 	Q 	Q 	Q 	Q 	Q 	Q 7tzJ<PPL	Q 	Q 	Q 	Q 	Q 	Q 	Q 	Q 	Q 	Q 	Q 	Q 	Q 	Q 	Q 2VL1122s   2A)A))A-0A-expected_delete_idsc                      g |t          |          nd fd}                     |          }|r1D ]}                     ||                                |           t          |          S )u  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. When *expected_delete_ids* is provided, deletion proceeds only
        if the parent plus delegate cascade still matches that exact set. This
        lets export-before-delete callers fail closed if a new delegate appears
        after they materialize their archive. The delegate tree is re-walked
        inside the write transaction on purpose (TOCTOU guard); the cost is
        accepted for correctness. Returns True if the session was found and
        deleted.
        Nc                    |                      df          }|                                dS ht          | g          }|k    rdS                     t	          | g                     |                      df           |                      df           |                      df                               |            dS )Nr  FzHUPDATE sessions SET parent_session_id = NULL WHERE parent_session_id = ?r  !DELETE FROM sessions WHERE id = ?T)r   r   r   r  r   r  )r   r   
actual_idsexpected_idsremoved_delegate_idsr  r  s      r9   r  z%SessionDB.delete_session.<locals>._dov  s    \\=
} F   (u'0
|DD
 -- 5 ''(A$(U(UVVVLL.  
 LLDzmTTTLL<zmLLL44T:::4r;   )r   r  r  rv  )	r  r  r  r  r  deleteddelegate_idr  r  s	   ``     @@r9   delete_sessionzSessionDB.delete_session[  s    , +-(;(GC#$$$T 		 	 	 	 	 	 	 	2 %%c** 	A3 F F**<EEEE&&|Z@@@G}}r;   c                       fd}                      |          }|r                     |           t          |          S )u  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.
        c                     |                      df          }|j        dk    r                    |            |j        dk    S )Na  
                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
                  )
                r   )r   r  r  )r   r   r  r  s     r9   r  z.SessionDB.delete_session_if_empty.<locals>._do  sR    \\  F ""88>>>?Q&&r;   )r  r  rv  )r  r  r  r  r  s   ``   r9   delete_session_if_emptyz!SessionDB.delete_session_if_empty  sb    &	' 	' 	' 	' 	' 	'( %%c** 	A&&|Z@@@G}}r;   r6  c                      |sdS t          d |D                       sdS g g  fd}                     |          }D ]}                     ||           D ]}                     ||           |S )u  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).
        r   c                 @    h | ]}t          |t                    ||S rf   )rW   rX   ry   s     r9   r|   z,SessionDB.delete_sessions.<locals>.<setcomp>  s-    VVV3C9M9MVRUV3VVVr;   c                 d   d                     dt                    z            }|                     d| d          }d |                                D             }|sdS d                     dt          |          z            }                    t          | |                     |                     d| d|           |                     d| d|           |                     d	| d|                               |                                |           t          |          S )
Nr}   r~   z%SELECT id FROM sessions WHERE id IN (r   c                     g | ]
}|d          S r   rf   r  s     r9   r   z:SessionDB.delete_sessions.<locals>._do.<locals>.<listcomp>  s    ???cD	???r;   r   r   r   r   )r   r   r   r   r  r   r  )	r   r  r   r  existing_placeholdersr  r  r  
unique_idss	        r9   r  z&SessionDB.delete_sessions.<locals>._do  sn   88C#j//$9::L \\GGGG F @?V__->->???H q$'HHS3x==-@$A$A! ''(A$(Q(QRRR LLH/DH H H  
 LLU=RUUU   LLM5JMMM   44T:::x(((x== r;   )r   r  r  )	r  r6  r  r  r^  r{   r  r  r  s	   `     @@@r9   delete_sessionszSessionDB.delete_sessions  s    :  	1 VV+VVVWW
 	1!#*,"	! "	! "	! "	! "	! "	! "	! "	!H ##C((' 	: 	:C&&|S9999 	: 	:C&&|S9999r;   c                     | j         5  | j                            d          }|                                d         cddd           S # 1 swxY w Y   dS )u  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.
        z_SELECT COUNT(*) FROM sessions WHERE message_count = 0 AND ended_at IS NOT NULL AND archived = 0r   Nr4  )r  r   s     r9   count_empty_sessionszSessionDB.count_empty_sessions  s      Z 	( 	(Z''# F ??$$Q'	( 	( 	( 	( 	( 	( 	( 	( 	( 	( 	( 	( 	( 	( 	( 	( 	( 	(s   4A		AAc                 z     g  fd}                      |          }D ]}                     ||           |S )uJ  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.
        c                    |                      d          }d |                                D             }|sdS d                    dt          |          z            }|                      d| dt	          |                     |D ]E}|                      d|f           |                      d	|f                               |           F                    |            t          |          S )
NzYSELECT id FROM sessions WHERE message_count = 0 AND ended_at IS NOT NULL AND archived = 0c                     h | ]
}|d          S r   rf   r  s     r9   r|   z?SessionDB.delete_empty_sessions.<locals>._do.<locals>.<setcomp>U      BBB3t9BBBr;   r   r}   r~   r   r   r  r  r   r   r   r   r   r   r  )r   r   r6  r  r{   r  r  s        r9   r  z,SessionDB.delete_empty_sessions.<locals>._doN  s    \\# F CB0A0ABBBK q88C#k*:*:$:;;LLL?/;? ? ?[!!   # 	( 	(
 ?#   @3&III""3''''44T:::{###r;   )r  r  )r  r  r  r^  r{   r  s   `    @r9   delete_empty_sessionszSessionDB.delete_empty_sessions/  sl    : "$	$ 	$ 	$ 	$ 	$ 	$@ ##C(( 	: 	:C&&|S9999r;   )last_active_beforelast_active_afterstarted_beforestarted_afterr  
title_liker  rh   min_messagesmax_messagesr7  
model_liker  r  r  r  branch_like
min_tokens
max_tokensmin_costmax_costmin_tool_callsmax_tool_callsr  r  r  r  r  r  r  r  r  r  r  r  r  r  r  c                    dg}g }| *|                     d           |                     |            |*|                     d           |                     |           |*|                     d           |                     |           |*|                     d           |                     |           |r*|                     d           |                     |           |r@|                     d           |                     d	|                                 d	           |r*|                     d
           |                     |           |r<t          |          \  }}|                     |           |                    |           |*|                     d           |                     |           |	*|                     d           |                     |	           |r@|                     d           |                     d	|                                 d	           |r<|                     d           |                     |                                           |r*|                     d           |                     |           |r*|                     d           |                     |           |r*|                     d           |                     |           |r@|                     d           |                     d	|                                 d	           |*|                     d           |                     |           |*|                     d           |                     |           |*|                     d           |                     |           |*|                     d           |                     |           |*|                     d           |                     |           |*|                     d           |                     |           |
du r|                     d           n|
du r|                     d           d                    |          |fS )u  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``.
        zs.ended_at IS NOT NULLNzCOALESCE(
                       (SELECT MAX(m.timestamp) FROM messages m
                        WHERE m.session_id = s.id),
                       s.started_at
                   ) < ?zCOALESCE(
                       (SELECT MAX(m.timestamp) FROM messages m
                        WHERE m.session_id = s.id),
                       s.started_at
                   ) >= ?zs.started_at < ?zs.started_at >= ?rh  z#LOWER(COALESCE(s.title, '')) LIKE ?r  zs.end_reason = ?rk  zs.message_count <= ?z#LOWER(COALESCE(s.model, '')) LIKE ?z+LOWER(COALESCE(s.billing_provider, '')) = ?zs.user_id = ?zs.chat_id = ?zs.chat_type = ?z(LOWER(COALESCE(s.git_branch, '')) LIKE ?zA(COALESCE(s.input_tokens, 0) + COALESCE(s.output_tokens, 0)) >= ?zA(COALESCE(s.input_tokens, 0) + COALESCE(s.output_tokens, 0)) <= ?z9COALESCE(s.actual_cost_usd, s.estimated_cost_usd, 0) >= ?z9COALESCE(s.actual_cost_usd, s.estimated_cost_usd, 0) <= ?z#COALESCE(s.tool_call_count, 0) >= ?z#COALESCE(s.tool_call_count, 0) <= ?Trl  Frm  rn  )r   r   ro   r  r   )r  r  r  r  r  r  r  rh   r  r  r7  r  r  r  r  r  r  r  r  r  r  r  r  clausesr  r  r  s                              r9   _prune_filter_wherezSessionDB._prune_filter_wheres  s   Z ,,)NN   MM,---(NN   MM+,,,%NN-...MM.)))$NN.///MM-((( 	"NN>***MM&!!! 	5NN@AAAMM3j..00333444 	&NN-...MM*%%% 	)$6z$B$B!FMNN6"""MM-(((#NN1222MM,'''#NN1222MM,''' 	5NN@AAAMM3j..00333444 	,NNHIIIMM(..**+++ 	#NN?+++MM'""" 	#NN?+++MM'""" 	%NN,---MM)$$$ 	6NNEFFFMM4k//11444555!NNS   MM*%%%!NNS   MM*%%%NNK   MM(###NNK   MM(###%NN@AAAMM.)))%NN@AAAMM.)))tNN+,,,,NN+,,,||G$$f,,r;   older_than_daysc                 r   |                     d          3|                     d          |t          j                    |dz  z
  |d<    | j        d	d|i|\  }}| j        5  | j                            d| d|          }d |                                D             cddd           S # 1 swxY w Y   dS )
az  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, last_active, ended_at,
        message_count, archived``. ``older_than_days`` is an inactivity
        threshold: it uses the latest message timestamp, falling back to
        ``started_at`` for sessions without messages.
        r  Nr  r   r  a  SELECT s.id, s.source, s.title, s.model, s.started_at,
                           COALESCE(
                               (SELECT MAX(m.timestamp) FROM messages m
                                WHERE m.session_id = s.id),
                               s.started_at
                           ) AS last_active,
                           s.ended_at, s.message_count, s.archived
                    FROM sessions s WHERE z?
                    ORDER BY last_active ASC, s.started_at ASCc                 ,    g | ]}t          |          S rf   r  r  s     r9   r   z3SessionDB.list_prune_candidates.<locals>.<listcomp>&   s    ;;;#DII;;;r;   rf   )r_   r	  r  r9  r#  r   r   )r  r  r  filterswherer  r   s          r9   list_prune_candidateszSessionDB.list_prune_candidates  s3   $ KK,--5,--5+,0IKK%'-G() 10JJJ'JJvZ 	< 	<Z''B ,1B B B  F <;):):;;;	< 	< 	< 	< 	< 	< 	< 	< 	< 	< 	< 	< 	< 	< 	< 	< 	< 	<s   "=B,,B03B0c                     |                     dd            | j        d||d|}|D ]}|                     |d         d           t          |          S )a  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.
        r7  F)r  r  r   Trf   )
setdefaultr  r<  r   )r  r  r  r  r  r[   s         r9   archive_sessionszSessionDB.archive_sessions(   s    $ 	:u---)t) 
+F
 
>E
 
  	7 	7C%%c$i66664yyr;   exclude_pinned	idle_daysr  c          	         ||dk     rdS t          j                     t          |          dz  z
  }|rdnd}| j        5  | j                            d| dt          d           d	|f                                          }ddd           n# 1 swxY w Y   d
 |D             }|D ]}|                     |d           t          |          S )uj  Archive every session untouched for at least ``idle_days`` days.

        "Touched" is the freshest of ``last_activity_at`` and the latest
        message timestamp (else ``started_at``) — i.e. real recency, not
        creation time — so a session
        created long ago but active yesterday is spared, while an old
        abandoned one (even a still-open one) is swept. Unlike
        :meth:`archive_sessions`, this method can also archive unended
        sessions.

        Guards:
          * ``pinned = 0`` when ``exclude_pinned`` (the Desktop "keep" flag).
          * ``archived = 0`` so repeat runs are idempotent no-ops.
          * only lineage *tips* / standalone rows are candidates
            (``end_reason <> 'compression'``); a stale tip archives its whole
            chain via :meth:`set_session_archived`, so we never resurrect an
            active conversation by matching an old compressed-away root whose
            live continuation is recent.

        Returns the number of sessions archived. Never raises for an empty or
        non-positive ``idle_days`` — it simply archives nothing.
        Nr   g     @zAND s.pinned = 0r>   z
                SELECT s.id FROM sessions s
                WHERE s.archived = 0
                  AND COALESCE(s.end_reason, '') <> 'compression'
                  z
                  AND rs  z? < ?
                ORDER BY s.started_at ASC
                c                 b    g | ],}t          |t          j                  r|d          n|d         -S )r   r   )rW   r   r$  r  s     r9   r   z4SessionDB.archive_stale_sessions.<locals>.<listcomp>k   s5    OOOQ:a55?$1Q4OOOr;   T)	r	  r  r9  r#  r   r   r   r<  r   )r  r  r  r  
pin_clauser  r   r{   s           r9   archive_stale_sessionsz SessionDB.archive_stale_sessionsB   sF   2 	A1uY//'99+9A''r
Z 	 	:%% 	 
 044   	
 
 hjj 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 PO$OOO 	1 	1C%%c400003xxs   ABBBZ   c                 6   	
 |                     d          3|                     d          |t          j                    |dz  z
  |d<     j        dd|i|\  	
g  	
fd}                     |          }D ]}                     ||           |S )u  Delete sessions matching the filters. Returns count deleted.

        By default, delete ended sessions inactive for
        ``older_than_days`` days, optionally restricted to ``source``.
        Activity is the latest message timestamp, falling back to
        ``started_at`` for sessions without messages. Additional keyword
        filters AND together — the full set is defined by
        :meth:`_prune_filter_where`:

        * ``last_active_before`` / ``last_active_after`` — epoch bounds on
          the latest message timestamp (falling back to ``started_at``).
        * ``started_before`` / ``started_after`` — epoch bounds on
          ``started_at``. An explicit ``started_before`` overrides the
          default ``older_than_days`` inactivity cutoff; pass
          ``older_than_days=None`` for no implicit upper age bound.
        * ``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.
        r  Nr  r   r  c                    |                      d           }d |                                D             }|sdS d                    dt          |          z            }|                      d| dt	          |                     |D ]E}|                      d|f           |                      d	|f                               |           F                    |            t          |          S )
Nz"SELECT s.id FROM sessions s WHERE c                     h | ]
}|d          S r   rf   r  s     r9   r|   z8SessionDB.prune_sessions.<locals>._do.<locals>.<setcomp>   r  r;   r   r}   r~   r   r   r  r  r  )	r   r   r6  r  r{   r  r  r  where_paramss	        r9   r  z%SessionDB.prune_sessions.<locals>._do   s    \\<U<<l F CB0A0ABBBK q 88C#k*:*:$:;;LLL?/;? ? ?[!!   # ( (H3&QQQ@3&III""3''''44T:::{###r;   rf   )r_   r	  r  r  r  )r  r  r  r  r  r  r^  r{   r  r  r  s   `       @@@r9   prune_sessionszSessionDB.prune_sessionsp   s    X KK,--5,--5+,0IKK%'-G() 7d6PPfPPP|!#	$ 	$ 	$ 	$ 	$ 	$ 	$ 	$0 ##C(( 	: 	:C&&|S9999r;   rp   c                     | j         5  | j                            d|f                                          }ddd           n# 1 swxY w Y   |dS t	          |t
          j                  r|d         n|d         S )z1Read a value from the state_meta key/value store.*SELECT value FROM state_meta WHERE key = ?NrU   r   )r9  r#  r   r   rW   r   r$  )r  rp   r[   s      r9   get_metazSessionDB.get_meta   s     Z 	 	*$$<sf hjj 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 ;4)#w{;;Gs7||QGr  r   rU   c                x    ||                     df           dS fd}|                     |           dS )ut  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.
        Nrq  c                 8    |                      df           d S )Nrq  r  )r   rp   rU   s    r9   r  zSessionDB.set_meta.<locals>._do   s0    LLHe    r;   )r   r  )r  rp   rU   r   r  s    ``  r9   set_metazSessionDB.set_meta   sp     NNHe  
 F	 	 	 	 	 	 	C     r;   workspaces_rootc                      t          |                              d          sdS d                                dk    rdS  fd}                     |          S )u  Retag legacy kanban worker rows from ``cli`` to ``kanban``.

        Workers used to spawn without ``HERMES_SESSION_SOURCE``, so their runs
        landed as untitled ``cli`` rows and the sidebar rendered one per attempt
        labeled with the worker's own prompt. New workers tag themselves; this
        reclaims the rows already on disk so they drop out of the session lists
        too. Identified by cwd under the board's workspaces root — a path only
        the dispatcher ever runs a session in.

        Gated per workspaces root (``state_meta``) so each board reclaims its
        own rows exactly once. Returns the number of rows retagged.
        rj   r   zkanban_worker_source_retagged:r  c                     |                      d                    dd                              dd                              dd          dz   f          }|j        pd	}                    d
|           |S )NzaUPDATE sessions SET source = 'kanban' WHERE source = 'cli' AND (cwd = ? OR cwd LIKE ? ESCAPE '\')r  r  r  rl   r  r  rk   r   r  r  )r   r  r  r  )r   r   retaggedgatern   r  s      r9   r  z3SessionDB.retag_kanban_worker_sessions.<locals>._do!  s    \\Of55==c5IIQQRUW\]]`dde F +!HMM$FM333Or;   )rX   rm   r  r  )r  r  r  r  rn   s   `  @@r9   retag_kanban_worker_sessionsz&SessionDB.retag_kanban_worker_sessions   s     _%%,,U33 	1888==#%%1
	 
	 
	 
	 
	 
	 
	 ""3'''r;   c                 6    d }|                      |           dS )uF  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.
        c                    |                      d           |                     dd                                          }|r<t          |d                                                   rt          |d                   nd}|dk     rW|                     d                                          }t          d |D                       }|r|                      d           |                     d	d
           d S )Na  
                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);
                r  ) telegram_dm_topic_schema_versionr   r  z5PRAGMA foreign_key_list('telegram_dm_topic_bindings')c              3   J   K   | ]}|d          dk    o|d         pddk    V  dS )r  r     r>   CASCADENrf   r  s     r9   r   zHSessionDB.apply_telegram_topic_migration.<locals>._do.<locals>.<genexpr>Q!  sQ       $ $ Fj(Hc!fly-H$ $ $ $ $ $r;   a  
                        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);
                        rq  )r  2)rr  r   r   rX   isdigitrC   r   r   )r   r  current_versionfk_rowsneeds_rebuilds        r9   r  z5SessionDB.apply_telegram_topic_migration.<locals>._do!!  s+   ! ! !L ll<5  hjj  29]S__=T=T=V=V]c'!*ooo\]O"",,K (**  !$ $ $&$ $ $ ! ! ! &&  6 LLH9    r;   Nr  )r  r  s     r9   apply_telegram_topic_migrationz(SessionDB.apply_telegram_topic_migration!  s1    T	 T	 T	j 	C     r;   )has_topics_enabledallows_users_to_create_topicsr  r  c                    |                                   t          j                    dt          t                   dt          t                   fdfd}|                     |           dS )zEnable 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.
        rU   r3   c                     | d S | rdndS )Nr?   r   rf   rY   s    r9   _to_intz5SessionDB.enable_telegram_topic_mode.<locals>._to_int!  s    }t$111$r;   c                     |                      dt                    t                                          f           d S )Na  
                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
                )r   rX   )r   r  r  r  r  rP  r  s    r9   r  z1SessionDB.enable_telegram_topic_mode.<locals>._do!  sf    LL LLLLG.//G9::    r;   N)r  r	  r   rv  rC   r  )r  r  r  r  r  r  r  rP  s    ```` @@r9   enable_telegram_topic_modez$SessionDB.enable_telegram_topic_modex!  s     	++---ikk	%8D> 	%hsm 	% 	% 	% 	%
	 	 	 	 	 	 	 	 	 	4 	C     r;   )clear_bindingsr  c                @    fd}|                      |           dS )a  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.
        c                     	 |                      dt          j                    t                    f           r&|                      dt                    f           d S d S # t          j        $ r Y d S w xY w)NOUPDATE telegram_dm_topic_mode SET enabled = 0, updated_at = ? WHERE chat_id = ?z8DELETE FROM telegram_dm_topic_bindings WHERE chat_id = ?)r   r	  rX   r   r   )r   r  r  s    r9   r  z2SessionDB.disable_telegram_topic_mode.<locals>._do!  s    (Y[[#g,,/  
 " LLRW     
 +   s   AA# #A65A6Nr  )r  r  r  r  s    `` r9   disable_telegram_topic_modez%SessionDB.disable_telegram_topic_mode!  s>     	 	 	 	 	 	 	C     r;   c                   | j         5  	 | j                            dt          |          t          |          f                                          }n!# t
          j        $ r Y ddd           dS w xY w	 ddd           n# 1 swxY w Y   |dS t          |t
          j                  r|d         n|d         }t          |          S )zDReturn whether Telegram DM topic mode is enabled for this chat/user.z
                    SELECT enabled FROM telegram_dm_topic_mode
                    WHERE chat_id = ? AND user_id = ?
                    NFr  r   )
r9  r#  r   rX   r   r   r   rW   r$  rv  )r  r  r  r[   r  s        r9   is_telegram_topic_mode_enabledz(SessionDB.is_telegram_topic_mode_enabled!  s0   Z 
	 
		j(( \\3w<<0  (**  +   
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 ;5$.sGK$@$@L#i..c!fG}}5   BA	ABA2#B1A22BB	Bc                @   | j         5  	 | j                            dt          |          t          |          f                                          }n!# t
          j        $ r Y ddd           dS w xY w	 ddd           n# 1 swxY w Y   |rt          |          ndS )z?Return the session binding for a Telegram DM topic, if present.z
                    SELECT * FROM telegram_dm_topic_bindings
                    WHERE chat_id = ? AND thread_id = ?
                    Nr9  r#  r   rX   r   r   r   r   )r  r  r  r[   s       r9   get_telegram_topic_bindingz$SessionDB.get_telegram_topic_binding!  s    Z 
	 
		j(( \\3y>>2  (**  +   
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	  )tCyyyT)r  c                   | j         5  	 | j                            dt          |          f                                          }n## t
          j        $ r g cY cddd           S w xY w	 ddd           n# 1 swxY w Y   d |D             S )zAll 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).
        zSSELECT * FROM telegram_dm_topic_bindings WHERE chat_id = ? ORDER BY updated_at DESCNc                 ,    g | ]}t          |          S rf   r  r  s     r9   r   zCSessionDB.list_telegram_topic_bindings_for_chat.<locals>.<listcomp>"  s    ***cS		***r;   )r9  r#  r   rX   r   r   r   )r  r  r  s      r9   %list_telegram_topic_bindings_for_chatz/SessionDB.list_telegram_topic_bindings_for_chat!  s    Z 	 	z))A\\O  (**	 
 +   			 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 +*T****s4   A6;AA6A&A6%A&&A66A:=A:c                $   | j         5  	 | j                            dt          |          f                                          }n!# t
          j        $ r Y ddd           dS w xY w	 ddd           n# 1 swxY w Y   |rt          |          ndS )a  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.
        z{
                    SELECT * FROM telegram_dm_topic_bindings
                    WHERE session_id = ?
                    Nr  r  r  r[   s      r9   %get_telegram_topic_binding_by_sessionz/SessionDB.get_telegram_topic_binding_by_session"  s    Z 
	 
		j(( __&  (**  +   
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	  )tCyyyT)4   A4;AA4A$A4#A$$A44A8;A8c                    t                    t                    ddifd}|                     |           d         S )u  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).
        r^  r   c                 x   	 |                      df          }|j        pdd<   n# t          j        $ r	 dd<   Y d S w xY wd         sd S 	 |                      df                                          }|+|                      dt          j                    f           d S d S # t          j        $ r Y d S w xY w)Nz
                    DELETE FROM telegram_dm_topic_bindings
                    WHERE chat_id = ? AND thread_id = ?
                    r   r^  z
                    SELECT 1 FROM telegram_dm_topic_bindings
                    WHERE chat_id = ? LIMIT 1
                    r  )r   r  r   r   r   r	  )r   r   r  r  r  r  s      r9   r  z4SessionDB.delete_telegram_topic_binding.<locals>._doB"  s     i(  $*?#7a  +   #$  7#  LL J  (**  $LLLg.     %$ +   s!   $( A A AB& &B98B9)rX   r  )r  r  r  r  r  s    `` @r9   delete_telegram_topic_bindingz'SessionDB.delete_telegram_topic_binding"  sl    B g,,	NN	A,#	 #	 #	 #	 #	 #	 #	J 	C   wr;   auto)managed_moder  c                8   |                                   t          j                    t                    t                    t                    t                    t                    fd}|                     |           dS )a   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.
        c                    |                      df                                          }|t          |t          j                  r|d         n|d         }t          |t          j                  r|d         n|d         }t          |          k    st          |          	k    rt          d          |                      d	
f           d S )Nz
                SELECT chat_id, thread_id FROM telegram_dm_topic_bindings
                WHERE session_id = ?
                r  r   r  r?   z3session is already linked to another Telegram topicaI  
                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
                )r   r   rW   r   r$  rX   rF   )r   existing_sessionlinked_chatlinked_threadr  r  rP  r  r  r  r  s       r9   r  z*SessionDB.bind_telegram_topic.<locals>._do"  s   #||     hjj   +=GHXZaZe=f=f.y99l|}~lAKL\^e^iAjAj  !D 0 = =  qA  BC  qD{##w..#m2D2D	2Q2Q$%Z[[[LL  	    r;   N)r  r	  rX   r  )	r  r  r  r  r  r  r  r  rP  s	    `````` @r9   bind_telegram_topiczSessionDB.bind_telegram_topicj"  s      	++---ikkg,,	NN	g,,+&&__
%	 %	 %	 %	 %	 %	 %	 %	 %	 %	 %	L 	C     r;   c                   | j         5  	 | j                            dt          |          f                                          }n!# t
          j        $ r Y ddd           dS w xY w	 ddd           n# 1 swxY w Y   |duS )aM  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.
        z
                    SELECT 1 FROM telegram_dm_topic_bindings
                    WHERE session_id = ?
                    LIMIT 1
                    NF)r9  r#  r   rX   r   r   r   r  s      r9   #is_telegram_session_linked_to_topicz-SessionDB.is_telegram_session_linked_to_topic"  s     Z 	 	
j((
 __&  (**  +   	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 $r	  
   )rW  c          
         | j         5  	 | j                            dt           dt	          d           dt          |          t          |          f                                          }nw# t          j	        $ re | j                            dt           dt	          d           dt          |          t          |          f                                          }Y nw xY wddd           n# 1 swxY w Y   g }|D ]R}| 
                    |          }t          |                    dd                    |d	<   |                    |           S|S )
uG  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.
        z
                    SELECT s.*,
                        COALESCE(sp.prompt, s.system_prompt)
                            AS _system_prompt_resolved,
                        COALESCE(
                            (SELECT a7  
                             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,
                        rs  a   AS last_active
                    FROM sessions s
                    LEFT JOIN system_prompts sp
                      ON sp.hash = s.system_prompt_hash
                    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 ?
                    ae   AS last_active
                    FROM sessions s
                    LEFT JOIN system_prompts sp
                      ON sp.hash = s.system_prompt_hash
                    WHERE s.source = 'telegram'
                      AND s.user_id = ?
                    ORDER BY last_active DESC, s.started_at DESC
                    LIMIT ?
                    Nrt  r>   ru  )r9  r#  r   r   r   rX   rC   r   r   r   r  r   r  r   )r  r  r  rW  r  r  r[   r  s           r9   (list_unlinked_telegram_sessions_for_userz2SessionDB.list_unlinked_telegram_sessions_for_user"  s    Z 6	 6	5z))
 &9  2#66  0 \\3u::.3 4 (**5 6 +    z))
 &9  2#66  ( \\3u::.+ , (**- ;6	 6	 6	 6	 6	 6	 6	 6	 6	 6	 6	 6	 6	 6	 6	p *, 	% 	%C,,S11G!/NB0O0O!P!PGIOOG$$$$s6   C0A"A-,C0-A1C!C0 C!!C00C47C4r  c                    	 | j         5  | j        	 ddd           dS | j                            d                                          d         }| j                            d                                          d         }ddd           n# 1 swxY w Y   t	          |          t	          |          z  S # t
          $ r&}t                              d|           Y d}~dS d}~ww xY w)u3  Database size in bytes as SQLite itself accounts for it.

        ``page_count * page_size`` — the size the main DB file will have once
        the WAL is checkpointed back into it.

        Prefer this over ``os.path.getsize(db_path)`` when reporting the effect
        of a VACUUM. In WAL mode a VACUUM's rewrite lands in the ``-wal`` file,
        and the checkpoint that folds it back is refused while any other
        connection (a live gateway) holds a read-mark. Until that happens the
        main file on disk still carries its pre-VACUUM size and keeps growing,
        so a stat()-based before/after delta understates the win and can go
        negative — the "reclaimed -3820.1 MB" report on a database that had
        actually shrunk 60%.

        Returns None if the pragmas cannot be read.
        NzPRAGMA page_countr   zPRAGMA page_sizez"Could not read logical DB size: %s)r9  r#  r   r   rC   rK   r  r  )r  
page_count	page_sizer  s       r9   logical_size_byteszSessionDB.logical_size_bytes#  s[   "		 Q Q:%Q Q Q Q Q Q Q Q "Z//0CDDMMOOPQR
 J../ABBKKMMaP		Q Q Q Q Q Q Q Q Q Q Q Q Q Q Q
 z??S^^33 	 	 	LL=sCCC44444	sE   B9 	BB9 A$BB9 BB9 B!B9 9
C)C$$C)c                    d}	 |                                  }n2# t          $ r%}t                              d|           Y d}~nd}~ww xY w| j        5  	 | j                            d           n2# t          $ r%}t                              d|           Y d}~nd}~ww xY w| j                            d           ddd           n# 1 swxY w Y   |S )u  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).
        r   z%FTS optimize before VACUUM failed: %sNr  z2WAL checkpoint (TRUNCATE) before VACUUM failed: %sr  )optimize_ftsrK   r  r  r9  r#  r   r  )r  	optimizedr  s      r9   vacuumzSessionDB.vacuum1#  sN   . 		I))++II 	I 	I 	INNBCHHHHHHHH	I Z 	) 	)X
""#DEEEE X X XQSVWWWWWWWWXJx(((	) 	) 	) 	) 	) 	) 	) 	) 	) 	) 	) 	) 	) 	) 	) sP    
AAACA/.C/
B9BCBCCC      retention_daysmin_interval_hoursr!  min_vacuum_interval_daysc                    dddd}	 |                      d          }t          j                    }|r;	 t          |          }	||	z
  |dz  k     rd|d<   |S n# t          t          f$ r Y nw xY w|                     ||          }
|
|d	<   |                      d
          }d}|r4	 |t          |          z
  |dz  k    }n# t          t          f$ r d}Y nw xY w|rx|
dk    rr|rp	 |                                  d|d<   |                     d
t          |                     n2# t          $ r%}t                              d|           Y d}~nd}~ww xY w|                     dt          |                     |
dk    r't                              d|
||d         rdnd           nD# t          $ r7}t                              d|           t          |          |d<   Y d}~nd}~ww xY w|S )uD  Idempotent auto-maintenance: prune inactive sessions + optional VACUUM.

        Records the last run timestamp in state_meta so subsequent calls
        within ``min_interval_hours`` no-op. VACUUM has its own, typically
        longer, throttle controlled by ``min_vacuum_interval_days`` so routine
        pruning does not repeatedly rewrite the database. 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
        Fr   )skippedprunedvacuumedlast_auto_prune  Tr(  )r  r  r)  last_vacuumr   r*  zstate.db VACUUM failed: %sNzFstate.db auto-maintenance: pruned %d session(s) inactive for %d days%sz	 + VACUUMr>   z$state.db auto-maintenance failed: %sr"  )r  r	  r  rE   rF   r  r!  r  rX   rK   r  r  r   )r  r$  r%  r!  r  r&  r  last_rawrP  last_tsr)  last_vacuum_raw
vacuum_duer  s                 r9   maybe_auto_prune_and_vacuumz%SessionDB.maybe_auto_prune_and_vacuumW#  s}   < .3aU!S!S7	'}}%677H)++C #HooGW}'9D'@@@,0y)% A ":.   D (( .) )  F  &F8 #mmM::OJ &&"%o(>(>">C[^cCc!cJJ!:. & & &!%JJJ& F&1***FKKMMM)-F:&MM-S::::  F F FNN#?EEEEEEEEF
 MM+SXX666zz\"#)*#5=KK2	    	' 	' 	'NNA3GGG!#hhF7OOOOOO	'
 s   *F! !A F! A*'F! )A**8F! #B= <F! =CF! CF! !<D F! 
E(EF! EAF! !
G"+-GG"r  c                 $   ddd}	 |                      d          }t          j                    }|r9	 |t          |          z
  |dz  k     rd|d<   |S n# t          t          f$ r Y nw xY w|                     ||          }||d	<   |                     dt          |                     |dk    rt          	                    d
||           nD# t          $ r7}t                              d|           t          |          |d<   Y d}~nd}~ww xY w|S )u  Idempotent auto-archive: soft-hide sessions idle for ``idle_days``.

        Sibling of :meth:`maybe_auto_prune_and_vacuum` but non-destructive —
        it archives (hides) rather than deletes, and ages on last activity
        (see :meth:`archive_stale_sessions`) rather than creation. Records the
        last run in ``state_meta['last_auto_archive']`` so calls within
        ``min_interval_hours`` no-op; safe to call opportunistically (startup
        hooks, or when the Desktop backend lists sessions).

        Never raises. Returns a dict with:
          - ``"skipped"`` (bool) — within min_interval_hours of last run
          - ``"archived"`` (int) — sessions archived this run
          - ``"error"`` (str, optional) — present only on failure
        Fr   )r(  r7  last_auto_archiver,  Tr(  r  r7  z=state.db auto-archive: archived %d session(s) idle >= %s daysz state.db auto-archive failed: %sr"  N)r  r	  r  rE   rF   r  r  rX   r  r   rK   r  )	r  r  r%  r  r  r.  rP  r7  r  s	            r9   maybe_auto_archivezSessionDB.maybe_auto_archive#  sa   ( .3!B!B	'}}%899H)++C U8__,/AD/HHH,0y)% I ":.   D 22. 3  H "*F: MM-s3xx888!||S  
  	' 	' 	'NN=sCCC!#hhF7OOOOOO	' s;   *C A C A'$C &A''A$C 
D-DDc                 <    fd}|                      |          S )zMark 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.
        c                 J    |                      df          }|j        dk    S )NzUPDATE sessions SET handoff_state = 'pending',     handoff_platform = ?,     handoff_error = NULL WHERE id = ? AND (handoff_state IS NULL                   OR handoff_state IN ('completed', 'failed'))r   r'  )r   rf  r   r  s     r9   r  z&SessionDB.request_handoff.<locals>._do#  s5    ,,Q :& C <!##r;   r  )r  r  r   r  s    `` r9   request_handoffzSessionDB.request_handoff#  s8    
	$ 
	$ 
	$ 
	$ 
	$ 
	$ ""3'''r;   c                     	 | j                             d|f          }|                                }|sdS |d         |d         |d         dS # t          $ r Y dS w xY w)zRead the current handoff state for a session.

        Returns ``{"state", "platform", "error"}`` or None if the session has
        no handoff record.
        zPSELECT handoff_state, handoff_platform, handoff_error FROM sessions WHERE id = ?Nhandoff_statehandoff_platformhandoff_error)stater   r"  )r#  r   r   rK   )r  r  rf  r[   s       r9   get_handoff_statezSessionDB.get_handoff_state$  s    	*$$- C
 ,,..C t_- 23_-  
  	 	 	44	s   2A A 
AAc                      	  j                             d          } fd|                                D             S # t          $ r g cY S w xY w)zvReturn all sessions in handoff_state='pending', oldest first.

        Used by the gateway's handoff watcher.
        zSELECT s.*, COALESCE(sp.prompt, s.system_prompt) AS _system_prompt_resolved FROM sessions s LEFT JOIN system_prompts sp ON sp.hash = s.system_prompt_hash WHERE s.handoff_state = 'pending' ORDER BY s.started_at ASCc                 :    g | ]}                     |          S rf   r  r  s     r9   r   z3SessionDB.list_pending_handoffs.<locals>.<listcomp>*$  s'    FFF!D**1--FFFr;   )r#  r   r   rK   )r  rf  s   ` r9   list_pending_handoffszSessionDB.list_pending_handoffs$  sm    
	*$$, C GFFFs||~~FFFF 	 	 	III	s   9= AAc                 8    fd}|                      |          S )uC   Atomically transition pending → running. Returns True if claimed.c                 H    |                      df          }|j        dk    S )NzXUPDATE sessions SET handoff_state = 'running' WHERE id = ? AND handoff_state = 'pending'r   r'  )r   rf  r  s     r9   r  z$SessionDB.claim_handoff.<locals>._do0$  s/    ,,= C
 <!##r;   r  r"  s    ` r9   claim_handoffzSessionDB.claim_handoff.$  s2    	$ 	$ 	$ 	$ 	$ ""3'''r;   c                 <    fd}|                      |           dS )zMark a handoff as completed.c                 6    |                      df           d S )NzRUPDATE sessions SET handoff_state = 'completed', handoff_error = NULL WHERE id = ?r  r!  s    r9   r  z'SessionDB.complete_handoff.<locals>._do;$  s-    LL4    r;   Nr  r"  s    ` r9   complete_handoffzSessionDB.complete_handoff9$  s8    	 	 	 	 	 	C     r;   c                 @    fd}|                      |           dS )z/Mark a handoff as failed and record the reason.c                 H    |                      dd d         f           d S )NzLUPDATE sessions SET handoff_state = 'failed', handoff_error = ? WHERE id = ?r  r  )r   r"  r  s    r9   r  z#SessionDB.fail_handoff.<locals>._doE$  s8    LL1ttj)    r;   Nr  )r  r  r"  r  s    `` r9   fail_handoffzSessionDB.fail_handoffC$  s>    	 	 	 	 	 	 	C     r;   )r3   Nr]  r   )NNNNNNNNNNNN)T)r$  )NN)rb  )r  )r  )r   r   Nr   r   r   NNNNNNNNr   F)r  N)NNNNrT  r   Fr   TFFFNNFFN)NNNNNNNNNNNNFNNNNNN)F)FNr   )r  )FFFF)NrT  r   N)NNNr   FFFN)r?   )r  NN)r  r"  TNr#  )r  r"  T)r   r   r   r   r/  r  r~  r  r  r  r  r3  r4  r  r  _FTS_MERGE_MAX_PAGES_PER_INDEX_FTS_MERGE_COMMANDS_PER_PASS_IMPORT_MAX_SESSIONS _IMPORT_MAX_MESSAGES_PER_SESSION_IMPORT_MAX_TOTAL_MESSAGES_IMPORT_MAX_SESSION_BYTES_IMPORT_MAX_TOTAL_BYTESstaticmethodr   rX   r  r  r   r$  r   r   r  r   rv  r  
ConnectionrU  r   rW  r   r  r]  Cursorra  re  rg  r~  r  r  r   r   r  r  r  r5  r  r  r  r   _FTS_REBUILD_CHUNK_ROWS_FTS_REBUILD_DUTY_FACTOR_FTS_REBUILD_MIN_PAUSEr  r  r  r  r  r  r  r  r  r   r  r  r  r  r	  r  r  r#  r(  r.  r4  r:  r@  rD  rJ  rM  rC   rR  rZ  r]  ra  rg  rn  rq  rs  r   r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r   r  r  r  r  r  r  r  r  r  r
  r  r  r  r  r#  r+  r0  r2  r5  r<  r@  rB  rF  rN  rR  	frozenset_SESSION_COMPACT_EXCLUDEDrS  __annotations__r  r  classmethodr  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r"  r)  r7  r4  r5  rG  rL  rO  r3  r;  r`  rf  rm  rr  rt  rw  rx  rz  r}  r  r  r  r  r  r  r  r  r  r  r   r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  _FTS_TABLESr  r!  r2  r5  r8  r>  rA  rD  rG  rJ  rf   r;   r9   r  r    sg.         H #' 
 "%  # ###!# !%%("#$ 
 '-$!' /.(3- HSM    \ 
 
 
 \
 w{ tCH~    \t t t t t t tp3); < 3 3 3 3j   ^$ (@ T    \ 
7+C 
 
 
 
 \
 &'. &T & & & \&2
W-E 
$ 
 
 
 

'*B 
t 
 
 
 
t, t, t, t,l 7> d    \  	
 
   < '+@ @g()1,-@ UO@ 
	@ @ @ @D+0	   8 2G,A 2d 2 2 2 \2/G,A /d / / / /bG G G G<+" +" +"V ""  )&d    " '+!%)!% !eO eOeO eO 	eO
 38neO eO eO c]eO eO eO eO eO eO eO eO  
!eO eO eO eON c       .3R! R! R!R! 	R!
 R! R! R! R! R! R! R! (,R! 
R! R! R! R!h! !s !t !t ! ! ! !( BD! ! !!,/!;>!	! ! ! !: 8:! ! !CH~!14!	! ! ! !. <> A A AS A$sCx. A A A A 8:! ! ! I!14!	! ! ! !$ #' 	'9 '9 '9 3-'9 	'9
 
d38n	'9 '9 '9 '9\ $(!%0" 0" 0" 0" 	0"
 C=0" #0" 
#0" 0" 0" 0"l "&%)!%#'#'G< G< G< G< #	G<
 c]G< #G< C=G< C=G< 
$sCx.	!G< G< G< G<R)K!$)K	$sCx.	!)K )K )K )Kd '+! '+*.e! e! e! e! 	e!
 e! tCH~&e! e! 38ne! e! e! e! "%e! $(e! 
e! e! e! e!N!c !s !t ! ! ! !$! ! ! ! ! ! .=/ //'*/	/ / / /d W[$! $!$!$'$!58$!PS$!	$! $! $! $!L!tCH~ !$ ! ! ! !2  $	   }	
 
   2#
#
 
$sCx.	!#
 #
 #
 #
J%
%
 
c3h%
 %
 %
 %
N-- sCx.- 
	- - - -^S T    (# #    0!# !s !t ! ! ! !C C    >!C ! !PT ! ! ! !N #	, ,, , 	,
 
, , , ,d #	V VV V 	V
 
V V V Vp3      4Ic Ihsm I I I I( #-M
 &*37-M -M -M-M UO-M
 c]-M /0-M 
-M -M -M -M^.M .M .M .M .M .M`
s 
xS#X7O 
 
 
 
&  $	! !! ! }	!
 
! ! ! !,!!.6sm!	! ! ! !#!s #!3 #!4 #! #! #! #!R  $"&26&*6! 6! 6!6! }	6!
 3-6!  S#X/6! sm6! 6! 
6! 6! 6! 6!p&!3 &! &!$ &! &! &! &!P *8DcN+C * * * * \*. '+!! !! !!!! 	!!
 !! sm!! 
!! !! !! !!h  I!
*;S *;t *; *; *; *;X1 1% 1$ 1 1 1 1f8 8 8 8*U3S#X3F-G(H T    04%T#s(^ 3454	eCc3h'(	)4 4 4 4<48 48u 48 48 48 48 48l    !""# !.2+/%)%))-*.*.&*%g! g!g! g! 	g!
 g! g!  g! g! %UOg! "%g! c]g! c]g! "#g! #3-g! #3-g!  sm!g!" #g!$ %g!& 
'g! g! g! g!x 'd
 d
 d
 d

 }d
 #3-d
 #3-d
 smd
 d
 d
 d
  d
 d
 %UOd
 "%d
  c]!d
" c]#d
$ %d
& 'd
( 
)d
 d
 d
 d
R  		 		 	 		 
	 	 	 	   $*.*.!""# !.29! 9! 9!9! 9!
 }9! #3-9! #3-9! 9! 9! 9!  9! 9! %UO9! 
9! 9! 9! 9!v   7G  SV        >%- %- %- %- %-N<c <htCH~.F < < < <$s x}    8 -hsm - - - - \-^#$'#8;#	# # # #J99 9
 9 
9 9 9 9vOC O O O O O ON# Nc Nd N N N N-C -HSM - - - -0s 0d 0t 0 0 0 0d2S 2$ 24 2 2 2 2h<# <(4S>2J < < < <c hsm    :!(C !(C !( !( !( !(F:c :hsm : : : :D !*		./! ! 04x}333 !%)!&!")-%*!&# "$%o oo co c	o
 o o o o o #'o #o o o o o  !o" #o$ %o& 
d38n	'o o o or ' 6c  6c  6  6  6 [ 6D c c    [ 3 8C=    \8< #<>Fsm<	< < < <B c htCH~.F    \4  !!%!%%)#'#',0%)&*5915-Q
 Q
Q
 Q
 	Q

 Q
 Q
 Q
 Q
 Q
 Q
 Q
 Q
  #Q
 !Q
 !Q
  !Q
" %SM#Q
$ %Q
& c]'Q
( sm)Q
* #4S>2+Q
, "*#-Q
. 
/Q
 Q
 Q
 Q
n 26$(G
 G
G
 tCH~&G
 "*#	G

 SMG
 
G
 G
 G
 G
V 6: .  .  . .(+ .69 .IL ."4S>2 . 
 .  .  .  .J ) =( =( =(=( =( }	=( =( 
$tCH~&	'=( =( =( =(~dd/2d	d38n	d d d d, 179. 9. 9.9.*-9.	d38n	9. 9. 9. 9.x /5A\` '  '  ' '(+ '>A 'UY '	# '  '  '  'DCS CXc] C C C C'3 ' ' ' ' ' '"_*S _*DcSVhDX _*]bcfhkck]l _* _* _* _*J "	8! 8!8! tCH~&8! 	8!
 
8! 8! 8! 8!t1 1 1 1 1 12(2(37S#X3G2(	2( 2( 2( 2(h(((+(:=(	( ( ( (F "'#2 22 2 }	2
 2 
d38n	2 2 2 2p 	N
 N
N
 N
 	N

 
c3hN
 N
 N
 N
`W<C W<C W< W< W< W<x #(!&#( %6
 6
6
  6
 	6

 !6
 6
 
d38n	6
 6
 6
 6
x	6  !&| | | 	|
  | !| | 
d38n	| | | ||1.1.	tDcN#T$sCx.%99	:1. 1. 1. 1.f'
c '
d4S>>R '
 '
 '
 '
R@ @ @ @ @ @5s 5tCy 5 5 5 5, d4S>6J QUVY[^V^Q_ dh    \"U
U
25U
	c3hU
 U
 U
 U
n(# ( ( ( ( ( (< !+N +N+N +N 	+N
 +N 
d38n	+N +N +N +Nf !!"!&#!&%);( ;(;( c;( 	;(
 ;( ;( ;( ;( c;( 
;( ;( ;( ;(z # d    & "'#!&+L +L +L +L 	+L
 +L 
c3h+L +L +L +LZ	( 	( 	(s 	( 	( 	( 	(11471	1 1 1 1,ODcN Ot O O O OJtCH~ J$ J J J J+B# +B$s) +B +B +B +BZ
! 
! 
! 
! 
! 
! HTN  PT    \43S 3T#Y 3 3 3 3( (,37	9 99 tn9 &d3i0	9
 
9 9 9 9| (,* ** tn* 
	* * * *^ (,R R#YR tnR 
	R R R Rh(c ( ( ( (6 (,B BtnB 
B B B BH  /3-1*.)- $$($($(&*&*#'$("&!%!%#'%)$($($($((,(,1I- I- I-$UOI- $E?I- !	I-
  I- I- SMI- SMI- SMI- smI- smI- 4.I- SMI- 3-I- #I-  #!I-" C=#I-$ c]%I-& SM'I-( SM)I-* 5/+I-, 5/-I-. !/I-0 !1I-2 
sDy	3I- I- I- \I-Z ,0'< '<!%'< '<
 
d38n	'< '< '< '<V ,0 !% 
 
   6 ;?, , ,,37,	, , , ,` ,.'+	R R!%R R tn	R 
R R R RlHC HHSM H H H H" KO! ! !!"!/7/G!	! ! ! !6!(C !(C !( !( !( !(Fb! b! b! b!R .28</! /! /! /! 	/!
 %TN/! (0~/! 
/! /! /! /!j  $	! ! ! ! 	!
 
! ! ! !B s t    $* * 	*
 
$sCx.	!* * * *(+ + 
d38n		+ + + +** * 
$sCx.	!	* * * *0K  K  	K 
 
K  K  K  K j #>! >! >! >! 	>!
 >! >! >! >! 
>! >! >! >!@     6 K K K K 	K
 K 
d38n	K K K Kf OKHSM    8$ $ $ $ $P !"$'+(*X XX  X 	X
 tnX #&X 
c3hX X X Xx "$#	3 33  3 	3
 
c3h3 3 3 3B(# ( ( ( ( ( (&C HT#s(^4L    .tDcN';    $	( 	( 	( 	( 	( 	(!3 !4 ! ! ! !!s !3 !4 ! ! ! ! ! !r;   r  c                   &    e Zd ZdZd	dZdefdZdS )
AsyncSessionDBu   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.dbr  r3   Nc                     || _         d S r   )_db)r  r_  s     r9   r  zAsyncSessionDB.__init__Q$  s    r;   rL   c                 ^    t          | j        |          t                    sS fd}|S )Nc                  >   K   t          j        g| R i | d {V S r   )asyncio	to_thread)argsr  attrs     r9   
_offloadedz.AsyncSessionDB.__getattr__.<locals>._offloadedY$  s:       *4A$AAA&AAAAAAAAAr;   )r>  ra  callable)r  rL   rh  rg  s      @r9   __getattr__zAsyncSessionDB.__getattr__T$  sM    tx&&~~ 	K	B 	B 	B 	B 	B r;   )r_  r  r3   N)r   r   r   r   r  rX   rj  rf   r;   r9   r^  r^  N$  sO         Z  Z         r;   r^  )rc   )r   r   )r   rd  r  r;  r5   r  loggingrG   r1  r  r   r   r7  r	  collectionsr   
contextlibr   pathlibr   agent.memory_managerr   r}  r   agent.message_sanitizationr	   agent.skill_commandsr
   r   r   hermes_constantsr   hermes_cli.sqlite_runtimer   r   typingr   r   r   r   r   r   r   hermes_state_commonr   r   r   r   r   r   r   r   r   r   r    r!   r"   r#   r$   r%   r&   r'   r(   r)   r*   r+   r,   r-   r.   hermes_state_portabilityr/   hermes_state_schemar0   hermes_state_searchr1   rI   rO  	getLoggerr   r  compilerA   rX   r:   rv  rT   rZ   rb   rg   ro   ru   r   r   r   r   r   r   r   r   rZ  r8  r   r   r   r!  r   r  r   r   r   r   r   r   rS  r   r   r   r  r   r   r   r   r  r  r  rK   r  r0  r6  r1  rE  rM  r8  r@  rB  rF  rY  ry  r  r  rs  rt  r  r  r  r  r  r  r  r  rC   r  r	  r  r^  rf   r;   r9   <module>r{     sU           				  				  



            % % % % % %       1 1 1 1 1 1 5 5 5 5 5 5 ; ; ; ; ; ;         
 - , , , , ,      G F F F F F F F F F F F F F F F F F                                                     6 = < < < < < 2 2 2 2 2 2 2 2 2 2 2 2MMMM   FFF 
	8	$	$","*-G"H"H Es Es E E E E-S -T - - - -`LS LS L L L LtCH~ (3-    "H HS Hc H H H Hb3 b5d3i+@ b b b b
s uS$s)^'<    $6$s) 6S	 6 6 6 6@S	 d3i     GCLL!/##j0 * *$ * * * *`  #' (3- & & &&	((  (+suu CH , , ,*IN,,  ), SX - - -+Y^-- hsm     "Xc]     EtCH~ E$ E E E E&4S>"	$sCx.   6' '# 'UX ' ' ' '.C 2 Cx} C C C C* '*<          F'*<     > %)1 15/1	1 1 1 1#    8c 8 8 8 84    '2   " 	c c c

c c 	c
 	c c c cT !	, , ,

, , 	,
 	, , , ,^  
	   :S y T    @ ; ; ;

; ; 
	; ; ; ;h  %(CEE S ) ) )%y~'' 	S} 	S 	S 	S 	S 	S CMC/$6 C4 C C C C"4 D    )T )htn ) ) ) )^ W  W  W W  W  
	W  W  W  W t{t { { { { {| =A e e eD eT eT#s(^ e e e eT  ) V8$ 8 8 8 8    '"4     2
 
 
 
 
L 
 
 
I I I I I, I I I    (C           H '*$+ $+ $+
$+ #$+26$+	$+ $+ $+ $+NoT ohtn o o o odLu! Lu! Lu! Lu! Lu!"$68O Lu! Lu! Lu!^j         s   C CC