
    .cj\             	       H   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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mZmZmZmZ 	 ddlZn# e$ r dZY nw xY w ej        e          Z ej         d          Z!d	e"d
e#fdZ$ded
efdZ%dee"ef         d
ee"         fdZ&d_de"d
e"fdZ'de"d
ee"ee"         f         fdZ(dZ)dZ*de)+                    d           dZ,d`de"d
e"fdZ-dee"         d
ee"         fdZ.dee"         d
ee"         fdZ/ ed           Z0 e            d!z  Z1d"Z2d#Z3d$Z4d%Z5da6ee"         e7d&<    e	j8                    Z9 e:            Z;e:e"         e7d'<    e	j8                    Z< e:            Z=e:e"         e7d(<    e	j8                    Z>d)Z?d*ee"         d
dfd+Z@d
ee"         fd,ZAd-ZBd*ee"ef         d
e#fd.ZCd/eee"ef                  d
eee"ef                  fd0ZDdad2e"d
e"fd3ZEd4ejF        d
ee"         fd5ZGd4ejF        d
dfd6ZHd4ejF        d
dfd7ZI	 dbd8eeJ         d
e#fd9ZKd
e"fd:ZLd!d;d4ejF        d<e"d
e"fd=ZMd4ejF        d<e"d
e"fd>ZNd<e"d?e#d
dfd@ZOd<e"dAePd
dfdBZQdCZR e:            ZSe:e"         e7dD<    e	j8                    ZTdAeUd
e#fdEZVdFed
e#fdGZWdFed
ee         fdHZXdFed
ee"         fdIZYdJdKdFedLe#d
ee"ef         fdMZZdNZ[dOZ\dPZ]dQZ^dRZ_dSZ`dTZadUZbd
efdVZcd
e#fdWZdd4ejF        d
e#fdXZedYZfdZZg G d[ d\          Zh G d] d^          ZidS )ca}  
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)Path)sanitize_context)_sanitize_surrogates)get_hermes_home)AnyCallableDictListOptionalTupleTypeVarz(?:^|:)pid=(\d+)(?::|$)holderreturnc                    t           j        dk    rdS 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           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).
    ntF N   r   T)osname_COMPRESSION_LOCK_HOLDER_PID_REsearchintgroup	TypeError
ValueErrorgetpidpsutil
pid_exists	ExceptionkillProcessLookupErrorPermissionErrorOSErrorOverflowError)r   matchpids      ./home/ice/.hermes/hermes-agent/hermes_state.py(_compression_lock_holder_process_is_deadr(   ,   s@     
w$u+226<R@@E}u%++a..!!z"   uu
axxu
bikk u	 (---- 	 	 	55	
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   r)   s    r'   _scrub_surrogatesr.   ^   s'     +5UC*@*@K&&&eK    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)r0   rootr3   s      r'   workspace_keyr7   i   s]     GGO$$*1133D 775>>R
&
&
(
(C;$r/   model_configcolc                     d|  dS )Nzjson_extract(COALESCE(z, '{}'), '$._delegate_from') )r9   s    r'   _delegate_from_jsonr<   z   s    GCGGGGr/   
cwd_prefixc                 J    |                      d          p| }d|| d| dgfS )Nz/\z+(s.cwd = ? OR s.cwd LIKE ? OR s.cwd LIKE ?)z/%\%)rstrip)r=   prefixs     r'   _cwd_prefix_clauserB   ~   s<    u%%3F86f===U[R`R`R`:aaar/   zjson_extract(COALESCE({a}.model_config, '{{}}'), '$._branched_from') IS NOT NULL OR EXISTS (SELECT 1 FROM sessions p            WHERE p.id = {a}.parent_session_id            AND p.end_reason = 'branched'            AND {a}.started_at >= p.ended_at)zsEXISTS (SELECT 1 FROM sessions p        WHERE p.id = {a}.parent_session_id        AND p.end_reason = 'compression')z (s.parent_session_id IS NULL OR sa)aliasc                     t                               |           }t                              |           }d|  d| d| dS )zISubagent runs (cascade-delete targets), not branches or compression tips.rD   (z(.parent_session_id IS NOT NULL AND NOT (z) AND NOT ()))_BRANCH_CHILD_SQLformat_COMPRESSION_CHILD_SQL)rG   branchcompressions      r'   _ephemeral_child_sqlrP      sc    %%%..F(//%/88K	%E 	% 	%	% 	% 	% 	% 	%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 r;   r;   .0sids     r'   	<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 idr;   )rU   r0   founds     r'   
<listcomp>z/_collect_delegate_child_ids.<locals>.<listcomp>   s+    UUU#c$iu>T>TCI>T>T>Tr/   c                     g | ]}|v|	S r;   r;   )rU   rV   seedss     r'   r^   z/_collect_delegate_child_ids.<locals>.<listcomp>   s#    555CCu$4$4C$4$4$4r/   )r<   setlistjoinlenexecutefetchallupdate)connrQ   dffrontierphcursorr]   r`   s         @@r'   _collect_delegate_child_idsrm      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 )NrX   rY   *DELETE FROM messages WHERE session_id IN (rF   IUPDATE sessions SET parent_session_id = NULL WHERE parent_session_id IN ("DELETE FROM sessions WHERE id IN ()rm   rc   rd   re   )rh   rQ   idsrk   s       r'   _delete_delegate_childrenrs      s    
%dJ
7
7C
 	FXXcCHHn%%G"GGGMMM1+-1 1 1	
 	
 	

 	?"???EEEJr/   Tstate.db   r   i   )zlocking protocolznot authorized_last_init_error_wal_fallback_warned_paths_wal_reset_bug_warned_paths)messages_fts_insertmessages_fts_deletemessages_fts_updatemessages_fts_trigram_insertmessages_fts_trigram_deletemessages_fts_trigram_update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_lockrw   )r   s    r'   _set_last_init_errorr   "  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).
    )rw   r;   r/   r'   get_last_init_errorr   3  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)rU   pheads     r'   	<genexpr>z8_is_background_review_harness_message.<locals>.<genexpr>W  s/      DDatq!!DDDDDDr/   )r+   dictr4   r,   lstripany_REVIEW_HARNESS_PREFIXES)r   r   r   s     @r'   %_is_background_review_harness_messager   G  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   r+   r   r4   append)r   outskip_next_assistantr   s       r'    _strip_background_review_harnessr   Z  s       "C 	 	055 	"& 	"'#t$$ K)G)G

3Jr/   Session database not availablerA   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)rU   markercauses     r'   r   z0format_session_db_unavailable.<locals>.<genexpr>  s0      
G
Gv6U[[]]"
G
G
G
G
G
Gr/   uJ    (state.db may be on NFS/SMB/FUSE — see https://www.sqlite.org/wal.html): )r   r   _WAL_INCOMPAT_MARKERS)rA   hintr   s     @r'   format_session_db_unavailabler   u  sq      !!E |||D

G
G
G
G1F
G
G
GGG \[&&&t&&&&r/   rh   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)re   fetchonesqlite3OperationalErrorr+   bytesdecodeUnicodeDecodeErrorr,   r5   r   )rh   r0   modes      r'   _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platformre   r   r   rh   s    r'   _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    r'   _enforce_macos_synchronous_fullr     sY    , |x./////#   r   version_infoc                     | | nt           j        }|dk     rdS |dk    rdS d|cxk    rdk     rn ndS d|cxk    rdk     rn ndS d	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.
    N)      r   F)r   3   r   )r   2   r   )r   r   r   )r   ,      )r   -   r   T)r   sqlite_version_info)r   infos     r'   is_sqlite_wal_reset_vulnerabler     s     (3<<9TDiuzuT&&&&J&&&&&uT&&&&J&&&&&u4r/   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   connectre   r   closeErrorr,   )rh   r0   s     r'   sqlite_source_idr     s    z**	,,:;;DDFFCJJLLLLDJJLLLLL=   rr #a&.rs1v;;s'   A- 'A A- A))A- -B ?B db_labelr   c                   t                      rt          | |          S 	 |                     d                                          }|r,|d         dk    r t	          |            t          |            dS n# t          j        $ r Y nw xY w	 |                     d           t	          |            t          |            dS # t          j        $ r}t          |          	                                t          fdt          D                       s t          |           }|dk    r 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), SQLite raises
    ``OperationalError("locking protocol")`` when setting WAL.  We fall
    back to DELETE mode — the pre-WAL default, which works on NFS — and
    log one WARNING explaining why.

    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.

    The WARNING is deduplicated per ``db_label``: repeated connections
    to the same underlying DB (e.g. kanban_db.connect() which is called
    on every kanban operation) log once per process, not once per call.
    Different db_labels log independently, so state.db and kanban.db
    each get one warning on the same NFS mount.

    Shared by :class:`SessionDB` and ``hermes_cli.kanban_db.connect`` so
    both databases get identical fallback behavior.

    Never downgrades to DELETE if the on-disk DB header reports WAL — see
    _on_disk_journal_mode.  That holds for both the NFS path and the
    WAL-reset vulnerability path.
    r   r   r   walzPRAGMA journal_mode=WALc              3       K   | ]}|v V  	d S r   r;   )rU   r   r   s     r'   r   z*apply_wal_with_fallback.<locals>.<genexpr>?  s'      EEV6S=EEEEEEr/   PRAGMA journal_mode=DELETENdelete)r   _apply_delete_for_wal_reset_bugre   r   r   r   r   r   r,   r   r   r   r   _log_wal_fallback_once)rh   r   current_modeexcexistingr   s        @r'   apply_wal_with_fallbackr   	  s   B &'' H.thGGGG||$9::CCEE 	LOu44+D111+D1115#   .///'---'---u#   #hhnnEEEE/DEEEEE 	(..ux---1222xxxxxs+   AA8 8B
	B
3C EA=EE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           n# t
          j        $ r Y nw xY w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.
    r   r   r   Nr   T)kept_walr   Fr   )
re   r   r,   r5   r   r   r   _log_wal_reset_bug_oncer   r   )rh   r   currentr0   s       r'   r   r   K  s'    Gll011::<< 	23q6%#a&kk''))//11G#    % 	 48888'---'---u12222#    Hu55558s$   A*A/ /BB>C C&%C&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 WALuE  %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 update` alone may not change python-build-standalone's embedded SQLite. See `hermes doctor`. This warning fires once per process per database.)_wal_reset_bug_warned_lockry   addloggerwarningr   sqlite_version)r   r   actions      r'   r   r   n  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 WARNING per (process, db_label) about WAL fallback.

    Without this dedup, NFS users running kanban (which opens a fresh
    connection on every operation — see hermes_cli/kanban_db.py) would
    fill errors.log with hundreds of identical warnings per hour.
    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). See https://www.sqlite.org/wal.html for details. This warning fires once per process per database.)_wal_fallback_warned_lockrx   r   r   r   )r   r   s     r'   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 NN	/
 	    r   )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   r,   r   )rU   r   r   s     r'   r   z(is_malformed_db_error.<locals>.<genexpr>  s8      RRfvS)))RRRRRRr/   )r+   r   DatabaseErrorr   _MALFORMED_SCHEMA_MARKERS)r   s   `r'   is_malformed_db_errorr     sC     c7011 uRRRR8QRRRRRRr/   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)r,   _repair_attempt_lockr   r   )r   keys     r'   _claim_repair_attemptr     s     g,,C	  )))        	 ##C(((	                 s   AAAAc                    ddl }ddl}|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.
    r   Nz%Y%m%d_%H%M%Sz.malformed-backup-)z-walz-shmz%Could not back up malformed DB %s: %s)datetimeshutilnowstrftime	with_namer   copy2existsr   r   r   )r   r   r   stampbackup_pathsuffixsidecarr   s           r'   _backup_db_filer    s&    OOOMMM!!##,,_==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>C 
DD  D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   okr   rU   rs     r'   r^   z%_db_opens_cleanly.<locals>.<listcomp>  sE    OOO!qOS1YY__5F5F$5N5NC!II5N5N5Nr/   z; r   zSELECT COUNT(*) FROM sessionsmessages_ftsmessages_fts_trigrammessages_fts_cjkSELECT 1 FROM  WHERE z MATCH '""' LIMIT 1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   r,   load_fts5_cjk_extensionre   r   rf   rc   r   r   	SessionDB_is_fts5_unavailable_errorr   r   timetime_nsr   )r   rh   rowsproblems	fts_tabler   r   probe_session_ids           r'   _db_opens_cleanlyr"    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strategyr  errorz does not existr'  Tr%  already_healthyr&  r  r  r  INSERT INTO rI   ) 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: %s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   r   r"  r  r,   r   r   r  re   r   r   r   r   r   commitrf   r'  )r   r#  reportbpathrh   
table_namer   dupestype_r   _countkeepreasons                r'   repair_state_db_schemar;  s  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ar  
CREATE TABLE IF NOT EXISTS schema_version (
    version INTEGER NOT NULL
);

CREATE TABLE IF NOT EXISTS sessions (
    id TEXT PRIMARY KEY,
    source TEXT NOT NULL,
    user_id TEXT,
    session_key TEXT,
    chat_id TEXT,
    chat_type TEXT,
    thread_id TEXT,
    display_name TEXT,
    origin_json TEXT,
    expiry_finalized INTEGER DEFAULT 0,
    model TEXT,
    model_config TEXT,
    system_prompt TEXT,
    parent_session_id TEXT,
    started_at REAL NOT NULL,
    ended_at REAL,
    end_reason TEXT,
    message_count INTEGER DEFAULT 0,
    tool_call_count INTEGER DEFAULT 0,
    input_tokens INTEGER DEFAULT 0,
    output_tokens INTEGER DEFAULT 0,
    cache_read_tokens INTEGER DEFAULT 0,
    cache_write_tokens INTEGER DEFAULT 0,
    reasoning_tokens INTEGER DEFAULT 0,
    cwd TEXT,
    git_branch TEXT,
    git_repo_root TEXT,
    billing_provider TEXT,
    billing_base_url TEXT,
    billing_mode TEXT,
    estimated_cost_usd REAL,
    actual_cost_usd REAL,
    cost_status TEXT,
    cost_source TEXT,
    pricing_version TEXT,
    title TEXT,
    api_call_count INTEGER DEFAULT 0,
    handoff_state TEXT,
    handoff_platform TEXT,
    handoff_error TEXT,
    compression_failure_cooldown_until REAL,
    compression_failure_error TEXT,
    compression_fallback_streak INTEGER NOT NULL DEFAULT 0,
    compression_ineffective_count INTEGER NOT NULL DEFAULT 0,
    profile_name TEXT,
    rewind_count INTEGER NOT NULL DEFAULT 0,
    archived INTEGER NOT NULL DEFAULT 0,
    FOREIGN KEY (parent_session_id) REFERENCES sessions(id)
);

CREATE TABLE IF NOT EXISTS messages (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    session_id TEXT NOT NULL REFERENCES sessions(id),
    role TEXT NOT NULL,
    content TEXT,
    tool_call_id TEXT,
    tool_calls TEXT,
    tool_name TEXT,
    effect_disposition TEXT,
    timestamp REAL NOT NULL,
    token_count INTEGER,
    finish_reason TEXT,
    reasoning TEXT,
    reasoning_content TEXT,
    reasoning_details TEXT,
    codex_reasoning_items TEXT,
    codex_message_items TEXT,
    platform_message_id TEXT,
    observed INTEGER DEFAULT 0,
    active INTEGER NOT NULL DEFAULT 1,
    compacted INTEGER NOT NULL DEFAULT 0,
    api_content TEXT,
    display_kind TEXT,
    display_metadata TEXT
);

CREATE TABLE IF NOT EXISTS session_model_usage (
    session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
    model TEXT NOT NULL,
    billing_provider TEXT NOT NULL DEFAULT '',
    billing_base_url TEXT NOT NULL DEFAULT '',
    billing_mode TEXT NOT NULL DEFAULT '',
    task TEXT NOT NULL DEFAULT '',
    api_call_count INTEGER NOT NULL DEFAULT 0,
    input_tokens INTEGER NOT NULL DEFAULT 0,
    output_tokens INTEGER NOT NULL DEFAULT 0,
    cache_read_tokens INTEGER NOT NULL DEFAULT 0,
    cache_write_tokens INTEGER NOT NULL DEFAULT 0,
    reasoning_tokens INTEGER NOT NULL DEFAULT 0,
    estimated_cost_usd REAL NOT NULL DEFAULT 0,
    actual_cost_usd REAL NOT NULL DEFAULT 0,
    cost_status TEXT,
    cost_source TEXT,
    first_seen REAL,
    last_seen REAL,
    PRIMARY KEY (session_id, model, billing_provider, billing_base_url, billing_mode, task)
);

CREATE TABLE IF NOT EXISTS state_meta (
    key TEXT PRIMARY KEY,
    value TEXT
);

CREATE TABLE IF NOT EXISTS gateway_routing (
    scope TEXT NOT NULL DEFAULT '',
    session_key TEXT NOT NULL,
    entry_json TEXT NOT NULL,
    updated_at REAL NOT NULL,
    PRIMARY KEY (scope, session_key)
);

CREATE TABLE IF NOT EXISTS compression_locks (
    session_id TEXT PRIMARY KEY,
    holder TEXT NOT NULL,
    acquired_at REAL NOT NULL,
    expires_at REAL NOT NULL
);

CREATE TABLE IF NOT EXISTS async_delegations (
    delegation_id TEXT PRIMARY KEY,
    origin_session TEXT NOT NULL,
    origin_ui_session_id TEXT NOT NULL DEFAULT '',
    parent_session_id TEXT,
    state TEXT NOT NULL,
    dispatched_at REAL NOT NULL,
    completed_at REAL,
    updated_at REAL NOT NULL,
    event_json TEXT,
    result_json TEXT,
    delivery_state TEXT NOT NULL DEFAULT 'pending',
    delivery_attempts INTEGER NOT NULL DEFAULT 0,
    delivered_at REAL,
    owner_pid INTEGER,
    owner_started_at INTEGER,
    task_json TEXT,
    delivery_claim TEXT,
    delivery_claimed_at REAL
);

CREATE INDEX IF NOT EXISTS idx_sessions_source ON sessions(source);
CREATE INDEX IF NOT EXISTS idx_sessions_source_id ON sessions(source, id);
CREATE INDEX IF NOT EXISTS idx_sessions_parent ON sessions(parent_session_id);
CREATE INDEX IF NOT EXISTS idx_sessions_started ON sessions(started_at DESC);
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, timestamp);
CREATE INDEX IF NOT EXISTS idx_compression_locks_expires ON compression_locks(expires_at);
CREATE INDEX IF NOT EXISTS idx_session_model_usage_session ON session_model_usage(session_id);
CREATE INDEX IF NOT EXISTS idx_session_model_usage_model ON session_model_usage(model);
CREATE INDEX IF NOT EXISTS idx_async_delegations_delivery
    ON async_delegations(delivery_state, completed_at);
a  
CREATE INDEX IF NOT EXISTS idx_messages_session_active
    ON messages(session_id, active, timestamp);
CREATE INDEX IF NOT EXISTS idx_messages_active_null
    ON messages(active) WHERE active IS NULL;
CREATE INDEX IF NOT EXISTS idx_sessions_session_key
    ON sessions(session_key, started_at DESC);
CREATE INDEX IF NOT EXISTS idx_sessions_gateway_peer
    ON sessions(source, user_id, chat_id, chat_type, thread_id, started_at DESC);
CREATE INDEX IF NOT EXISTS idx_sessions_handoff_state
    ON sessions(handoff_state, started_at);
a  
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
    content,
    tool_name,
    tool_calls,
    content='messages',
    content_rowid='id'
);

CREATE TRIGGER IF NOT EXISTS messages_fts_insert AFTER INSERT ON messages
WHEN (new.id > COALESCE((SELECT CAST(value AS INTEGER) FROM state_meta
                         WHERE key = 'fts_rebuild_high_water'), -1)
   OR new.id <= COALESCE((SELECT CAST(value AS INTEGER) FROM state_meta
                          WHERE key = 'fts_rebuild_progress'), -1))
BEGIN
    INSERT INTO messages_fts(rowid, content, tool_name, tool_calls)
    VALUES (new.id, new.content, new.tool_name, new.tool_calls);
END;

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

CREATE TRIGGER IF NOT EXISTS messages_fts_update AFTER UPDATE ON messages
WHEN (old.content IS NOT new.content
    OR old.tool_name IS NOT new.tool_name
    OR old.tool_calls IS NOT new.tool_calls)
   AND (old.id > COALESCE((SELECT CAST(value AS INTEGER) FROM state_meta
                           WHERE key = 'fts_rebuild_high_water'), -1)
     OR old.id <= COALESCE((SELECT CAST(value AS INTEGER) FROM state_meta
                            WHERE key = 'fts_rebuild_progress'), -1))
BEGIN
    INSERT INTO messages_fts(messages_fts, rowid, content, tool_name, tool_calls)
    VALUES ('delete', old.id, old.content, old.tool_name, old.tool_calls);
    INSERT INTO messages_fts(rowid, content, tool_name, tool_calls)
    VALUES (new.id, new.content, new.tool_name, new.tool_calls);
END;
aO	  
CREATE VIEW IF NOT EXISTS messages_fts_trigram_src AS
    SELECT id, role, content, tool_name, tool_calls
    FROM messages
    WHERE role <> 'tool';

CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts_trigram USING fts5(
    content,
    tool_name,
    tool_calls,
    content='messages_fts_trigram_src',
    content_rowid='id',
    tokenize='trigram'
);

CREATE TRIGGER IF NOT EXISTS messages_fts_trigram_insert AFTER INSERT ON messages
WHEN new.role <> 'tool'
   AND (new.id > COALESCE((SELECT CAST(value AS INTEGER) FROM state_meta
                           WHERE key = 'fts_rebuild_high_water'), -1)
     OR new.id <= COALESCE((SELECT CAST(value AS INTEGER) FROM state_meta
                            WHERE key = 'fts_rebuild_progress'), -1))
BEGIN
    INSERT INTO messages_fts_trigram(rowid, content, tool_name, tool_calls)
    VALUES (new.id, new.content, new.tool_name, new.tool_calls);
END;

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

CREATE TRIGGER IF NOT EXISTS messages_fts_trigram_update AFTER UPDATE ON messages
WHEN (old.content IS NOT new.content
    OR old.tool_name IS NOT new.tool_name
    OR old.tool_calls IS NOT new.tool_calls
    OR old.role IS NOT new.role)
   AND (old.id > COALESCE((SELECT CAST(value AS INTEGER) FROM state_meta
                           WHERE key = 'fts_rebuild_high_water'), -1)
     OR old.id <= COALESCE((SELECT CAST(value AS INTEGER) FROM state_meta
                            WHERE key = 'fts_rebuild_progress'), -1))
BEGIN
    INSERT INTO messages_fts_trigram(messages_fts_trigram, rowid, content, tool_name, tool_calls)
    SELECT 'delete', old.id, old.content, old.tool_name, old.tool_calls
    WHERE old.role <> 'tool';
    INSERT INTO messages_fts_trigram(rowid, content, tool_name, tool_calls)
    SELECT new.id, new.content, new.tool_name, new.tool_calls
    WHERE new.role <> 'tool';
END;
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 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;
)messages_fts_cjk_insertmessages_fts_cjk_deletemessages_fts_cjk_updatefts_cjk_stalec                      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)r   getenvr   
expanduserr   )envs    r'   fts5_cjk_so_pathrF    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)r   rC  r5   r   r;   r/   r'   _cjk_fts_config_enabledrN    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)	rN  rF  r   enable_load_extensionload_extensionr,   r   r   r   )rh   paths     r'   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a,  
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
    content
);

CREATE TRIGGER IF NOT EXISTS messages_fts_insert AFTER INSERT ON messages BEGIN
    INSERT INTO messages_fts(rowid, content) VALUES (
        new.id,
        COALESCE(new.content, '') || ' ' || COALESCE(new.tool_name, '') || ' ' || COALESCE(new.tool_calls, '')
    );
END;

CREATE TRIGGER IF NOT EXISTS messages_fts_delete AFTER DELETE ON messages BEGIN
    DELETE FROM messages_fts WHERE rowid = old.id;
END;

CREATE TRIGGER IF NOT EXISTS messages_fts_update AFTER UPDATE ON messages BEGIN
    DELETE FROM messages_fts WHERE rowid = old.id;
    INSERT INTO messages_fts(rowid, content) VALUES (
        new.id,
        COALESCE(new.content, '') || ' ' || COALESCE(new.tool_name, '') || ' ' || COALESCE(new.tool_calls, '')
    );
END;
a  
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts_trigram USING fts5(
    content,
    tokenize='trigram'
);

CREATE TRIGGER IF NOT EXISTS messages_fts_trigram_insert AFTER INSERT ON messages BEGIN
    INSERT INTO messages_fts_trigram(rowid, content) VALUES (
        new.id,
        COALESCE(new.content, '') || ' ' || COALESCE(new.tool_name, '') || ' ' || COALESCE(new.tool_calls, '')
    );
END;

CREATE TRIGGER IF NOT EXISTS messages_fts_trigram_delete AFTER DELETE ON messages BEGIN
    DELETE FROM messages_fts_trigram WHERE rowid = old.id;
END;

CREATE TRIGGER IF NOT EXISTS messages_fts_trigram_update AFTER UPDATE ON messages BEGIN
    DELETE FROM messages_fts_trigram WHERE rowid = old.id;
    INSERT INTO messages_fts_trigram(rowid, content) VALUES (
        new.id,
        COALESCE(new.content, '') || ' ' || COALESCE(new.tool_name, '') || ' ' || COALESCE(new.tool_calls, '')
    );
END;
c            0           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ddedef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dej        defdZddZedej        ddfd            Zedej        defd            Zedddej        deddfd             Zedddej        deddfd!            Z dej        d"e!de"e         fd#Z#dej        d"e!d$e!defd%Z$d&e%ej&        ge'f         de'fd'Z(edej)        defd(            Z*dej)        defd)Z+dd*Z,dd+Z-d, Z.dZ/d-Z0d.Z1de"e2e!e3f                  fd/Z4dd0Z5d1Z6defd2Z7defd3Z8de"e2e!e3f                  fd4Z9defd5Z:dd6Z;dd7Z<defd8Z=defd9Z>defd:Z?ddd;d<e"e%e2e!e3f         gdf                  d=ede2e!e3f         fd>Z@ed?e!de2e!e2e!e!f         f         fd@            ZAdej        ddfdAZBdB ZC	 	 	 	 	 	 	 	 	 	 	 	 ddCe!dDe!dEe!dFe2e!e3f         dGe!dHe!dIe!dJe!dKe!dLe!dMe!dNe!dOe!dPe!ddfdQZDdCe!dDe!de!fdRZEddddddddSdCe!dDe!dHe!dIe!dJe!dKe!dLe!dTe!dUe!ddfdVZFddCe!dWeddfdXZGdYdZdIe!d[e!d\e!ddfd]ZHdYdZd^e2e!e!f         d\e!ddfd_ZIdYdZd\e!de2e!e!f         fd`ZJdYdZdaeKe!         d\e!ddfdbZLdddcdde"e!         deedeKe2e!e3f                  fdfZMdddgdde!dJe!dLe"e!         dHe"e!         de"e!         f
dhZNdej        ddfdiZOddddddjdDe!dHe"e!         dIe"e!         dJe"e!         dKe"e!         dLe"e!         de"e2e!e3f                  fdkZPdCe!dle!ddfdmZQdCe!ddfdnZR	 ddCe!dpe!defdqZS	 ddCe!dNe!dre!dPe!ddf
dsZTdte2e!e!f         ddfduZU	 ddCe!dveVdwe"e!         ddfdxZWdCe!de"e2e!e3f                  fdyZXdCe!ddfdzZYdCe!defd{ZZdCe!d|eddfd}Z[dCe!defd~Z\dCe!deddfdZ]	 ddCe!de!deVdefdZ^	 ddCe!de!deVdefdZ_dCe!de!ddfdZ`dCe!de"e!         fdZa	 ddCe!de!dEe"e!         ddfdZbdCe!dGe!ddfdZcdCe!dEe!ddfdZddddCe!de!de!de"e!         ddf
dZe	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 ddCe!dededEe!dededede"eV         de"eV         de"e!         de"e!         de"e!         de"e!         de"e!         de"e!         dededdf$dZfdYddCe!dEe"e!         de"e!         de"e!         de"e!         dededededede"eV         de"eV         de"e!         de"e!         dede!ddf"dZg	 	 ddCe!dDe!dEe!de!fdZhdddddddddd	dCe!de!dEe"e!         de"e!         de"e!         dededededede"eV         ddfdZiddddefdZjdefdZkdCe!de"e2e!e3f                  fdZlde!de"e!         fdZmdZnede"e!         de"e!         fd            Zode!de!defdZpdCe!de!dedefdZqdCe!de!defdZrdCe!de!defdZsdCe!de"e!         fdZtdCe!dedefdZude!de"e2e!e3f                  fdZvde!de"e!         fdZwde!de!fdZxdCe!de"e!         fdZy ezdGh          Z{dZ|e"e!         e}d<   e~de!fd            ZddedeKe2e!e3f                  fdĄZ	 	 	 	 	 	 	 	 	 	 	 	 	 	 ddDe!deKe!         de!dedededededededede!de!dedeKe2e!e3f                  fd҄Z	 	 dde!dededeKe2e!e3f                  fdԄZddCe!dede"e2e!e3f                  fdՄZdZe~de3de3fd؄            Ze~de3de3fdل            Z	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 ddCe!de!de!de!de3de!dede!de!de!de3de3de3de!dede"e!         de3de"e!         de"e!         de"e2e!e3f                  def*dZdddCe!de!de!de!de"e2e!e3f                  defdZdCe!deKe2e!e3f                  deeef         fdZ	 ddCe!deKe2e!e3f                  deeddfdZdCe!defdZdCe!deKe2e!e3f                  defdZdCe!de3de!defdZ	 	 	 ddCe!dede"e         dedeKe2e!e3f                  f
dZ	 ddCe!dedede2e!e3f         fdZ	 	 	 ddCe!dededede"ee!d f                  de2e!e3f         fdZdCe!de!fdZ	 	 	 ddCe!dedededeKe2e!e3f                  f
dZdZdCe!dededeKe2e!e3f                  fdZdCe!deeKe2e!e3f                  eKe2e!e3f                  f         fdZdCe!deKe2e!e3f                  fd	ZdCe!de!fd
ZdCe!deKe!         fdZedeKe2e!e3f                  de2e!e3f         defd            ZdCe!dede2e!e3f         fdZdCe!dedefdZ	 	 ddCe!dededeKe2e!e3f                  fdZede!de!fd            Zededefd            Zede!defd            Ze~de!defd            Ze~de!defd            Zede!defd            Zdddddddde!de!d e!ded!eKe!         deKe!         d"eKe!         dedede"eKe2e!e3f                           fd#Z	 	 	 	 	 	 	 dde!d!eKe!         deKe!         d"eKe!         deded$e!dedeKe2e!e3f                  fd%Zde!de!fd&Z	 	 	 	 	 	 	 dde!d!eKe!         deKe!         d"eKe!         deded$e!dedeKe2e!e3f                  fd'Zddddd(d)e!deded!e"eKe!                  de"eKe!                  d"e"eKe!                  deKe2e!e3f                  fd*Z	 	 dde!dededeKe2e!e3f                  fd+Z	 	 	 ddDe!dededeKe2e!e3f                  fd,Z	 	 	 	 	 	 	 ddDe!de!dededed-edeKe!         defd.ZddCe!defd/ZdCe!de!defd0Zd1e2e!e3f         defd2Zd3e2e!e3f         defd4ZdCe!deKe!         fd5ZdCe!de"e2e!e3f                  fd6ZdCe!de"e2e!e3f                  fd7ZddDe!deKe2e!e3f                  fd8Zed9e3d:e!de"e!         fd;            Zed9e3d:e!de"e!         fd<            Zed9e3de"eV         fd=            Zed9e3d:e!de"e         fd>            Zedd9e3d?edefd@            Zed9e3de3fdA            ZedBedCe!dwe!de2e!e3f         fdC            ZdDeKe2e!e3f                  de2e!e3f         fdEZdCe!ddfdFZede"e         dCe!ddfdG            Z	 ddCe!de"e         defdHZ	 ddCe!de"e         defdIZ	 ddJeKe!         de"e         defdKZdefdLZ	 dde"e         defdMZeddddddddddddddddddddddNdOe"eV         dPe"eV         dDe"e!         dQe"e!         dle"e!         de"e!         dRe"e         dSe"e         de"e         dTe"e!         de"e!         dHe"e!         dJe"e!         dKe"e!         dUe"e!         dVe"e         dWe"e         dXe"eV         dYe"eV         dZe"e         d[e"e         dee!ef         f,d\            Z	 	 dd]e"eV         dDe!deKe2e!e3f                  fd^Z	 	 dd]e"eV         dDe!defd_Z	 	 	 dd]e"eV         dDe!de"e         defdaZȐdbe!de"e!         fdcZddddbe!d9e!de"ej                 ddfdeZʐddfZdddgdJe!dHe!dhe"e         die"e         ddf
djZddkdJe!dleddfdmZdJe!dHe!defdnZdJe!dLe!de"e2e!e3f                  fdoZdJe!deKe2e!e3f                  fdpZdCe!de"e2e!e3f                  fdqZdJe!dLe!defdrZҐdsdtdJe!dLe!dHe!dIe!dCe!due!ddfdvZdCe!defdwZԐdxdydJe!dHe!dedeKe2e!e3f                  fdzZՐd{Z֐d|e!defd}Zdefd~ZdefdZdefdZ	 	 	 	 ddeded=ede"e         de2e!e3f         f
dZdCe!dde!defdZdCe!de"e2e!e3f                  fdZdeKe2e!e3f                  fdZdCe!defdZdCe!ddfdZdCe!dwe!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{Gz?g333333?r       i'  iP  i  P i  NFr   	read_onlyc                     |pt            _        | _        t          j                     _        d _        d _        d _        d _	        d _
        d _        d _        d  _        	 |r?t          j        d j         ddddd            _        t          j         j        _        d S  j        j                            dd            fd	}	  |             d S # t          j        $ r}t+          |          rt-           j                  s t.                              d
|           	  j         j                                         n# t4          $ r Y nw xY wt7           j                  }|                    d          s  |             Y d }~d S d }~ww xY w# t4          $ r,}t;          t=          |          j         d|             d }~ww xY w)Nr   Fzfile:z?mode=roT      ?)uricheck_same_threadtimeoutr  )parentsexist_okc                  L   t          j        t           j                  ddd            _        t           j         j        _        t           j        d            j                            d           t           j                   _
                                          d S )NFr[  )r]  r^  r  ru   r   zPRAGMA foreign_keys=ON)r   r   r,   r   _connRowrow_factoryr   re   r  _fts_cjk_loaded_init_schemaselfs   r'   _connect_and_initz-SessionDB.__init__.<locals>._connect_and_initv  s    $_%%&+   %)  
 *1
&'
ZHHHH
""#;<<<'>tz'J'J$!!#####r/   u`   state.db schema is malformed (%s) — attempting automatic repair (a backup copy is made first).r%  r   ) DEFAULT_DB_PATHr   rY  	threadingLock_lock_write_count_fts_runtime_rebuild_attempted_fts_enabled_trigram_availablere  _fts_cjk_available_fts_unavailable_warnedrb  r   r   rc  rd  parentmkdirr   r   r   r   r'  r   r   r;  r4   r   type__name__)rh  r   rY  ri  r   r3  s   `     r'   __init__zSessionDB.__init__J  sK   1/"^%%
 /4+!"'
  %"'',$
X	  %_2DL222&+$(  
 *1
&L%%dT%BBB$ $ $ $ $&$!!#####( $ $ $ -S11 9Nt|9\9\ <=@  z-
((***    D/==zz*-- !!#########-$>  	 	 	 !DII$6!?!?#!?!?@@@	sf   (?F )&F 
C F+?F+ EF
EFE7FF FF 
G('GGr   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  Fr   r   errs     r'   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".
        r{  r  r   r|  s     r'   _is_trigram_unavailable_errorz'SessionDB._is_trigram_unavailable_error  s3     #hhnn(C/ 91S8	
r/   rl   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)re   r   r+   r   rc  )rl   r0   r  s      r'   _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)getattrr  r   r   r   r   r   rh  r   s     r'   _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))rp  rs  r   r   r   r  s     r'   _warn_fts5_unavailablez SessionDB._warn_fts5_unavailable  sQ    !' 	F'+$% L	
 	
 	
 	
 	
r/   c                     	 |                     d           |                     d           dS # t          j        $ r6}|                     |          s |                     |           Y d }~dS d }~ww xY w)Nz:CREATE VIRTUAL TABLE temp._hermes_fts5_probe USING fts5(x)z"DROP TABLE temp._hermes_fts5_probeTF)re   r   r   r  r  )rh  rl   r   s      r'   _sqlite_supports_fts5zSessionDB._sqlite_supports_fts5  s    	NNWXXXNN?@@@4' 	 	 	22377 '',,,55555		s   *. A3+A..A3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   r;   r
  s     r'   r^   z4SessionDB._ensure_fts_cjk_schema.<locals>.<listcomp>9  s)       AaD  r/   zCSELECT name FROM sqlite_master WHERE type = 'trigger' AND name IN (rX   c              3      K   | ]}d V  dS rY   Nr;   rU   _s     r'   r   z3SessionDB._ensure_fts_cjk_schema.<locals>.<genexpr><  s"      0P0P0P0P0P0P0P0Pr/   rF   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   )SELECT COALESCE(MAX(id), 0) FROM messagesfts_cjk_rebuild_high_water)fts_cjk_rebuild_progressrJ  gINSERT INTO state_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value&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/LIKETrP  )boolre   r   re  rc   _FTS_CJK_TRIGGERSrf   r   r   rF  FTS_CJK_STALE_KEYrr  executescriptFTS_CJK_TABLE_SQLr,   FTS_CJK_TRIGGER_SQLr   r   )rh  rl   cjk_presentlivetrign_msgshwkvstalebackfill_pendings              r'   _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 )Nr  )_FTS_TRIGGERSre   r   r   )rl   triggers     r'   _drop_fts_triggerszSessionDB._drop_fts_triggers  sa    $ 	 	GBBBCCCC+   	 	s   $66c                    d                     d t          D                       }|                     d| dt                                                    }t	          t          |t          j                  s|d         n|d                   S )NrX   c              3      K   | ]}d V  dS r  r;   r  s     r'   r   z/SessionDB._fts_trigger_count.<locals>.<genexpr>  s"      ;;;;;;;;r/   zGSELECT COUNT(*) FROM sqlite_master WHERE type = 'trigger' AND name IN (rF   r   )rc   r  re   r   r   r+   r   rc  )rl   placeholdersr0   s      r'   _fts_trigger_countzSessionDB._fts_trigger_count  s    xx;;];;;;;nnC3?C C C
 
 (**	 	
 C!=!=I3q663q6JJJr/   Tinclude_trigramr  c                    |                      d           |r|                      d           |                      d           d S )Nz8INSERT INTO messages_fts(messages_fts) VALUES('rebuild')zHINSERT INTO messages_fts_trigram(messages_fts_trigram) VALUES('rebuild')VDELETE FROM state_meta WHERE key IN ('fts_rebuild_high_water', 'fts_rebuild_progress')re   rl   r  s     r'   _rebuild_fts_indexeszSessionDB._rebuild_fts_indexes  s`     	QRRR 	NNZ   	A	
 	
 	
 	
 	
r/   c                    |                      d           |                      d           |sdS |                      d           |                      d           dS )ag  Rebuild the LEGACY inline FTS indexes (pre-v23) from messages.

        Used only to repair a legacy DB whose triggers degraded under an
        earlier no-FTS5 runtime. Inline tables have no external-content
        'rebuild' source, so we DELETE + reinsert the concatenated content
        the legacy triggers produced. Never touches the v23 shape.
        zDELETE FROM messages_ftszINSERT INTO messages_fts(rowid, content) SELECT id, COALESCE(content, '') || ' ' || COALESCE(tool_name, '') || ' ' || COALESCE(tool_calls, '') FROM messagesNz DELETE FROM messages_fts_trigramzINSERT INTO messages_fts_trigram(rowid, content) SELECT id, COALESCE(content, '') || ' ' || COALESCE(tool_name, '') || ' ' || COALESCE(tool_calls, '') FROM messagesr  r  s     r'   _rebuild_legacy_fts_indexesz%SessionDB._rebuild_legacy_fts_indexes  sy     	1222	
 	
 	
  	F9:::	
 	
 	
 	
 	
r/   r5  c                 r   	 |                     d| d           dS # t          j        $ r}|                     |          rF|                     |          r|                     |           n|                     |           Y d }~d S dt          |                                          v rY d }~dS  d }~ww xY w)NzSELECT * FROM  LIMIT 0Tr  F)	re   r   r   r  r  r  r  r,   r   )rh  rl   r5  r   s       r'   _fts_table_probezSessionDB._fts_table_probe  s    	NN@J@@@AAA4' 	 	 	..s33  55c:: 52237777//444ttttt#c((.."2"222uuuuu	s!    B6AB1#B10B11B6ddlc                 L   |                      ||          }|dS 	 |                    |           dS # t          j        $ ra}|                     |          s |                     |          r|                     |           n|                     |           Y d }~dS d }~ww xY w)NFT)r  r  r   r   r  r  r  r  )rh  rl   r5  r  statusr   s         r'   _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#fnc                    d}t          | j                  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|                                  |c S # t          j        $ rx}t!          |                                          }d|v sd|v rI|}|| j        dz
  k     r9t%          j        | j        | j                  }t-          j        |           Y d}~ d}~wt          j        $ r!}|                     |          s Y d}~d}~ww xY w|pt          j        d          )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 20-150ms, and retry — breaking the convoy pattern that
        SQLite's built-in deterministic backoff creates.

        Returns whatever *fn* returns.
        Nr  r   r   lockedbusyz$database is locked after max retries)range_WRITE_MAX_RETRIESrm  rb  re   r2  BaseExceptionrollbackr   rn  _CHECKPOINT_EVERY_N_WRITES_try_wal_checkpoint_OPTIMIZE_EVERY_N_WRITES_try_optimize_ftsr   r   r,   r   randomuniform_WRITE_RETRY_MIN_S_WRITE_RETRY_MAX_Sr  sleepr   _try_runtime_fts_rebuild)rh  r  last_errattemptresultr   err_msgjitters           r'   _execute_writezSessionDB._execute_write  s   $ )-T455 -	 -	G,Z 
 
J&&'8999!#DJ
))++++(   ! J//1111( ! ! ! D! ,	
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 !!Q&!!$t'FF!KK,,...$t'DDII**,,,+   c((..**w&&&G*;*;"H!81!<<<!' 3 3" " 
6*** (    44S99   
'22
 
 	
s   DB/)A('B/(
B3BB
B	BB	BB/#D/B3	3D6B3	7A"DG+A,FFG0GG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.
        Trz  corrupt)r   r,   r   )r   r   s     r'   _is_fts_write_corruption_errorz(SessionDB._is_fts_write_corruption_errorN  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.)ro  rp  r  r   r   r+  r   r'  )rh  r   rebuiltrebuild_excs       r'   r  z"SessionDB._try_runtime_fts_rebuild^  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 checkpointed   Nz#WAL checkpoint (PASSIVE) failed: %s)rm  rb  re   r   r   debugr   r   )rh  r  r   s      r'   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                 R    	 |                                   dS # t          $ r Y dS w xY w)u0  Best-effort FTS5 segment merge. Never raises.

        Runs on the ``_OPTIMIZE_EVERY_N_WRITES`` cadence from the write hot
        path (off the lock — ``optimize_fts`` re-acquires ``self._lock``
        itself, mirroring ``_try_wal_checkpoint``). ``read_only`` connections
        never reach the write path, so this is implicitly skipped for them.
        Once the index is merged the 'optimize' command is close to free, so
        the steady-state cost is negligible; the expensive case is only the
        first merge of a long-neglected index.
        N)optimize_ftsr   rg  s    r'   r  zSessionDB._try_optimize_fts  sA    	 	 	 	DD	s    
&&c                 0   | j         5  | j        rn	 | 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 )zClose the database connection.

        Attempts a TRUNCATE WAL checkpoint first so that exiting processes
        help shrink the WAL file.
        PRAGMA wal_checkpoint(TRUNCATE)z-WAL checkpoint (TRUNCATE) at close failed: %sN)rm  rb  re   r   r   r  r   r  s     r'   r   zSessionDB.close  s     Z 	" 	"z "WJ&&'HIIII  W W WLL!PRUVVVVVVVVW
  """!
	" 	" 	" 	" 	" 	" 	" 	" 	" 	" 	" 	" 	" 	" 	" 	" 	" 	"s6   B,B
AABA#BBBg      @g?c                     |                      d          }|dS t          |                      d          pd          }t          |          }|dk    rdS t          dt          d|z  |z                      }d|||dS )aW  Return deferred-rebuild progress, or None when no rebuild pending.

        Shape: {"pending": True, "total": <rows at drop time>,
        "indexed": <rows backfilled>, "percent": <0-100 int>}.
        Consumed by search_messages() notes and by status surfaces
        (dashboard/desktop can poll this to render a progress indicator).
        fts_rebuild_high_waterNfts_rebuild_progressr   d   Tpendingtotalindexedpercentget_metar   minrh  
high_waterprogressr  pcts        r'   fts_rebuild_statuszSessionDB.fts_rebuild_status  s     ]]#;<<
4t}}%;<<ABBJA::4#s3>E12233%HQTUUUr/   c                 j    d }|                      |           t                              d           dS )a  Finalize the deferred rebuild: boundary sweep + clear markers.

        The sweep is cheap insurance against any write that slipped through
        the migration-boundary instant (between high_water capture and
        trigger activation): re-index any row near the boundary that the
        index is missing. docsize has one row per indexed doc, so the
        anti-join is exact and runs on a narrow id range.
        c                     |                      d                                          }|Ot          |d                   }|dz
  |dz   }}|                      d||f           |                      d||f           |                      d           d S )NzASELECT value FROM state_meta WHERE key = 'fts_rebuild_high_water'r   rW  zINSERT INTO messages_fts(rowid, content, tool_name, tool_calls) SELECT m.id, m.content, m.tool_name, m.tool_calls FROM messages m WHERE m.id > ? AND m.id <= ? AND NOT EXISTS (SELECT 1 FROM messages_fts_docsize d WHERE d.id = m.id)a  INSERT INTO messages_fts_trigram(rowid, content, tool_name, tool_calls) SELECT m.id, m.content, m.tool_name, m.tool_calls FROM messages m WHERE m.id > ? AND m.id <= ? AND m.role <> 'tool' AND NOT EXISTS (SELECT 1 FROM messages_fts_trigram_docsize d WHERE d.id = m.id)r  re   r   r   rh   hw_rowr  lohis        r'   _doz*SessionDB._fts_rebuild_finish.<locals>._do		  s    \\S hjj  !^^dBIB^
 H   f
 H   LLE    r/   u7   Deferred FTS rebuild complete — all messages indexed.N)r  r   r   rh  r  s     r'   _fts_rebuild_finishzSessionDB._fts_rebuild_finish 	  s@    	 	 	8 	C   MNNNNNr/   fts_v22_trash_c           	           j         5  d  j                            d j                            dd          dz   f                                          D             }ddd           n# 1 swxY w Y   |sdS |d          fd	}	 t                               |                    S # t          j	        $ r&}t                              d
|           Y d}~dS d}~ww xY w)a"  Tear down one chunk of a demoted v22 FTS shadow table.

        The trash tables are PLAIN tables (their vtable parent was demoted
        away during the migration), so chunked DELETE + final DROP involve
        no FTS5 machinery at all. Returns True while teardown work remains.
        c                     g | ]
}|d          S r  r;   r
  s     r'   r^   z6SessionDB._fts_teardown_trash_step.<locals>.<listcomp>7	  s)       !  r/   zNSELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE ? ESCAPE '\'r  \_%NFr   c                 P   d |                      d d          D             }|rd                    |          nd}|                      d d| d| d	 d
j         d          }|j        dk    r3|                      d            t                              d           dS )Nc                 6    g | ]}|d          dk    |d         S )   r   r   r;   r
  s     r'   r^   zCSessionDB._fts_teardown_trash_step.<locals>._do.<locals>.<listcomp>D	  s1       Q4!88 !88r/   zPRAGMA table_info(rF   , rowidzDELETE FROM z WHERE (z) IN (SELECT z FROM z LIMIT r   zDROP TABLE IF EXISTS z"Old FTS shadow table %s torn down.T)re   rc   _FTS_REBUILD_CHUNK_ROWSrowcountr   r   )rh   pk_colsr   currh  tbls       r'   r  z/SessionDB._fts_teardown_trash_step.<locals>._doC	  s    "ll+F+F+F+FGG  G )0<$))G$$$WC,,Rs R RC R RR R&)R R262NR R R C |q  :S::;;;@#FFF4r/   z0FTS trash teardown chunk failed (will retry): %sT)rm  rb  re   _FTS_TRASH_PREFIXreplacerf   r  r  r   r   r   r  )rh  trashr  r   r  s   `   @r'   _fts_teardown_trash_stepz"SessionDB._fts_teardown_trash_step/	  s\    Z 	 	 "j002+33C??#EG  (**  E	 	 	 	 	 	 	 	 	 	 	 	 	 	 	  	5Ah	 	 	 	 	 	 	++C00111' 	 	 	LLKSQQQ44444	s*   AA++A/2A/	!B+ +C :CC c                    | j         sdS |                     d          }|dS t          |          | j        | j        fd}	 |                     |          }n8# t          j        $ r&}t          	                    d|           Y d}~dS d}~ww xY w|du r>| 
                                }|&|d         |d         k    r|                                  dS t          |          S )	a\  Backfill one chunk of the deferred FTS rebuild.

        Returns True when more work remains, False when the rebuild is
        complete (or none is pending). Safe to call from any process at any
        time; chunks are claimed atomically inside the write transaction, so
        concurrent callers interleave instead of duplicating rows.
        Fr  Nc                 r   |                      d                                          }|dS t          |d                   }|k    rdS t          |z             }|                      d||f           r|                      d||f           |                      dt	          |          f           |k     S )Nz?SELECT value FROM state_meta WHERE key = 'fts_rebuild_progress'Fr   zINSERT INTO messages_fts(rowid, content, tool_name, tool_calls) SELECT id, content, tool_name, tool_calls FROM messages WHERE id > ? AND id <= ?zINSERT INTO messages_fts_trigram(rowid, content, tool_name, tool_calls) SELECT id, content, tool_name, tool_calls FROM messages WHERE id > ? AND id <= ? AND role <> 'tool'zBUPDATE state_meta SET value = ? WHERE key = 'fts_rebuild_progress're   r   r   r  r,   )rh   r0   r  upperchunkr  r  s       r'   r  z'SessionDB.fts_rebuild_step.<locals>._doj	  s     ,,Q hjj  {u3q6{{H:%%u 5(*55ELL+ 5!	    B u%   LL5U  
 :%%r/   z)FTS rebuild chunk failed (will retry): %sTr  r  )rp  r  r   rq  r  r  r   r   r   r  r  r  r  )	rh  high_water_rawr  morer   r  r  r  r  s	         @@@r'   fts_rebuild_stepzSessionDB.fts_rebuild_stepY	  s+      	5'?@@!5((
1,%	& %	& %	& %	& %	& %	& %	&N	&&s++DD' 	 	 	LLDcJJJ44444	 5==,,..F!fY&76'?&J&J((***5Dzzs   A! !B0BBc                     |                      d          }|dS t          |                      d          pd          }t          |          }|dk    rdS t          dt          d|z  |z                      }d|||dS )z:CJK-index backfill progress, or None when none is pending.r  Nr  r   r  Tr  r  r  s        r'   fts_cjk_rebuild_statusz SessionDB.fts_cjk_rebuild_status	  s    ]]#?@@
4t}}%?@@EAFFJA::4#s3>E12233%HQTUUUr/   c                    | j         r| j        sdS |                     d          }|dS t          |          | j        fd}	 |                     |          }n8# t          j        $ r&}t          	                    d|           Y d}~dS d}~ww xY w|du r>| 
                                }|&|d         |d         k    r|                                  dS t          |          S )	z=Backfill one chunk of the CJK index. True while work remains.Fr  Nc                 >   |                      d                                          }|dS t          |d                   }|k    rdS t          |z             }|                      d||f           |                      dt	          |          f           |k     S )NzCSELECT value FROM state_meta WHERE key = 'fts_cjk_rebuild_progress'Fr   zINSERT INTO messages_fts_cjk(rowid, content, tool_name, tool_calls) SELECT id, content, tool_name, tool_calls FROM messages WHERE id > ? AND id <= ? AND role <> 'tool'zFUPDATE state_meta SET value = ? WHERE key = 'fts_cjk_rebuild_progress'r  )rh   r0   r  r  r  r  s       r'   r  z+SessionDB.fts_cjk_rebuild_step.<locals>._do	  s    ,,9  hjj  {u3q6{{H:%%u5(*55ELL> 5!	   LL9U  
 :%%r/   z-CJK FTS rebuild chunk failed (will retry): %sTr  r  )rp  re  r  r   r  r  r   r   r   r  r  _fts_cjk_rebuild_finishr  )rh  r  r  r  r   r  r  r  s         @@r'   fts_cjk_rebuild_stepzSessionDB.fts_cjk_rebuild_step	  s$     	(< 	5'CDD!5((
,	& 	& 	& 	& 	& 	&0	&&s++DD' 	 	 	LLH#NNN44444	 5==0022F!fY&76'?&J&J,,...5Dzzs   	A B.BBc                 x    d }|                      |           d| _        t                              d           dS )z?Boundary sweep + clear the cjk markers; index becomes servable.c                     |                      d                                          }|7t          |d                   }|dz
  |dz   }}|                      d||f           |                      d           d S )NzESELECT value FROM state_meta WHERE key = 'fts_cjk_rebuild_high_water'r   rW  a  INSERT INTO messages_fts_cjk(rowid, content, tool_name, tool_calls) SELECT m.id, m.content, m.tool_name, m.tool_calls FROM messages m WHERE m.id > ? AND m.id <= ? AND m.role <> 'tool' AND NOT EXISTS (SELECT 1 FROM messages_fts_cjk_docsize d WHERE d.id = m.id)z^DELETE FROM state_meta WHERE key IN ('fts_cjk_rebuild_high_water', 'fts_cjk_rebuild_progress')r  r  s        r'   r  z.SessionDB._fts_cjk_rebuild_finish.<locals>._do	  s    \\;  hjj  !^^dBIBb
 H   LLM    r/   Tu7   CJK FTS index backfill complete — serving CJK search.N)r  rr  r   r   r   s     r'   r!  z!SessionDB._fts_cjk_rebuild_finish	  sH    	 	 	( 	C   "&MNNNNNr/   c                     | j         sdS d }|                     |          }|rU| j        5  |                     | j                   | j                                         ddd           dS # 1 swxY w Y   dS dS )a  Rebuild path for a stale cjk index (triggers were dropped).

        The gap's extent is unknown, so the only safe recovery is a from-
        scratch rebuild: drop the table + triggers, clear the breadcrumb,
        recreate via ``_ensure_fts_cjk_schema`` (which sets fresh backfill
        markers on a populated DB). Called from ``optimize_fts_storage`` on
        a tokenizer-capable host; no-op when not stale.
        Nc                 >   |                      dt          f                                          }|sdS t          D ]}|                      d|            |                      d           |                      d           |                      dt           d           dS )	Nr  Fr  z%DROP TABLE IF EXISTS messages_fts_cjkz(DROP VIEW IF EXISTS messages_fts_cjk_srcz&DELETE FROM state_meta WHERE key IN ('z<', 'fts_cjk_rebuild_high_water', 'fts_cjk_rebuild_progress')T)re   r  r   r  )rh   r  r  s      r'   r  z.SessionDB._fts_cjk_reset_if_stale.<locals>._do
  s    LL8"$  hjj   u) ? ?=t==>>>>LL@AAALLCDDDLL.&. . .  
 4r/   )re  r  rm  r  rb  r2  )rh  r  	was_stales      r'   _fts_cjk_reset_if_stalez!SessionDB._fts_cjk_reset_if_stale	  s     # 	F	 	 	" '',,	 	$
  $ $++DJ777
!!###$ $ $ $ $ $ $ $ $ $ $ $ $ $ $ $ $ $	$ 	$s   4A,,A03A0c                    | j         r| j        rdS | j        5  |                     | j                  r	 ddd           dS | j                            d                                          r	 ddd           dS | j        rC| j                            dt           d                                          r	 ddd           dS | 	                    | j                  cddd           S # 1 swxY w Y   dS )a  True when `optimize_fts_storage()` has work to do: either this DB
        is a legacy inline-FTS install that can be optimized to the v23
        external-content schema, or a previous optimize run was interrupted
        (legacy vtables already demoted, but backfill markers and/or trash
        tables remain) and re-running would resume it, or the CJK-bigram
        index needs a backfill/rebuild on this tokenizer-capable host.
        False for fresh and fully-optimized installs (and when FTS5 is
        unavailable).FNTESELECT 1 FROM state_meta WHERE key = 'fts_rebuild_high_water' LIMIT 1zFSELECT 1 FROM state_meta WHERE key IN ('fts_cjk_rebuild_high_water', 'z
') LIMIT 1)
rp  rY  rm  r  rb  re   r   re  r  _has_fts_trashrg  s    r'   fts_optimize_availablez SessionDB.fts_optimize_available)
  s      	DN 	5Z 	3 	3--dj99 	3 	3 	3 	3 	3 	3 	3 	3 z!!?  hjj 	3 	3 	3 	3 	3 	3 	3 	3  # 
(:(:Q3DQ Q Q) ) hjj )	3 	3 	3 	3 	3 	3 	3 	3* &&tz22+	3 	3 	3 	3 	3 	3 	3 	3 	3 	3 	3 	3 	3 	3 	3 	3 	3 	3s#   C+-C+;=C+C++C/2C/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  r  r  )r  re   r  r  r   )rh  rh   s     r'   r+  zSessionDB._has_fts_trashK
  sT     DLL2#++C77#=?
 
 (**	  	r/   c                 R      fd}t                               |                    S )u  Demote the legacy inline FTS vtables and stage their shadow tables
        for chunked teardown. Returns MAX(messages.id) as the rebuild high
        water. O(1) schema surgery — the heavy delete is deferred to the
        chunked teardown, exactly as the validated auto path did.c                 >                        |            |                     d           t          |                     d                                                    }|r|                     d           |                     d           |                     d           d |                     d                                          D             }|D ]}|                     d| d	|                                | d
t                                         | dt                     |                     d                                          d         }dt          |          fdfD ]\  }}|                     d||f           |                     d           |S )Nz,DROP VIEW IF EXISTS messages_fts_trigram_srczSELECT 1 FROM sqlite_master WHERE type = 'table' AND name IN ('messages_fts', 'messages_fts_trigram') AND sql LIKE 'CREATE VIRTUAL TABLE%' LIMIT 1r.  zDELETE FROM sqlite_master WHERE type = 'table' AND name IN ('messages_fts', 'messages_fts_trigram') AND sql LIKE 'CREATE VIRTUAL TABLE%'zPRAGMA writable_schema=RESETc                     g | ]
}|d          S r  r;   r
  s     r'   r^   zFSessionDB._demote_legacy_fts_to_trash.<locals>._do.<locals>.<listcomp>i
  s)       AaD  r/   zSELECT name FROM sqlite_master WHERE type = 'table' AND (name LIKE 'messages_fts_%' ESCAPE '\' OR name LIKE 'messages_fts_trigram_%' ESCAPE '\')zALTER TABLE z RENAME TO fts_v22_trash_r  r  r  r   r  )r  rJ  r  ;DELETE FROM state_meta WHERE key = 'fts_optimize_available')	r  re   r  r   rf   r  FTS_SQLFTS_TRIGRAM_SQLr,   )rh   hadshadowsshr  r  r  rh  s          r'   r  z2SessionDB._demote_legacy_fts_to_trash.<locals>._doY
  s   ##D)))LLGHHHt||?  hjj	 C
  S8999;  
 ;<<< "&,,M# # hjj   " S SBLL!Q!Q!QR!Q!QRRRR##D.'BBB##D*@/RRRIJJSSUUVWXB)3r773-  1 LF   
 LLVWWWIr/   )r   r  r   s   ` r'   _demote_legacy_fts_to_trashz%SessionDB._demote_legacy_fts_to_trashT
  s;    
'	 '	 '	 '	 '	P 4&&s++,,,r/   )progress_cbvacuumr8  r9  c                     j         sdddS  j        rdddS  j        5                        j                  }ddd           n# 1 swxY w Y                        d          du}|r|s                                                                     j        rR j        5   	                     j                    j        
                                 ddd           n# 1 swxY w Y   dt          ddf fd	}d
t          ddf fd} |d           	 t          j                    }                                 sn+ |d            |t          j                    |z
             S |d           	 t          j                    }                                 sn+ |d            |t          j                    |z
             S |d           	 t          j                    }                                 sn+ |d            |t          j                    |z
             Sd}|r |d           	  j        5   j                            d           ddd           n# 1 swxY w Y   d}n9# t&          j        $ r'}	t*                              d|	           d}Y d}	~	nd}	~	ww xY wd }
                     |
            |d           t*                              dt2                     d|dS )u  Migrate a legacy v22 inline-FTS DB to the v23 external-content
        schema, foreground and to completion. Safe to re-run: if a previous
        attempt was interrupted it resumes from the progress marker.

        ``progress_cb`` receives {"phase", "percent", "indexed", "total"}
        dicts for a CLI progress bar. Returns a summary dict.

        The trigram tokenizer being unavailable is not fatal — the base index
        is still rebuilt (CJK falls back to LIKE), mirroring normal startup.
        Ffts5_unavailable)r	  r:  rY  Nr  phaser   c                     d S                                  }|                                } | |r|d         nd|r|d         nd|r|d         ndd           d S )Nr  r  r  r   r  )r<  r  r  r  )r  r  )r<  str8  rh  s     r'   _emitz-SessionDB.optimize_fts_storage.<locals>._emit
  s    "((**Bz0022K,.72i==C,.52i==A(*1G	      r/   chunk_secondsc                 f    t          j        t          j        | j        z                       dS )a  Inter-chunk throttle (see the chunk-engine note above).

            The chunk methods themselves never sleep, so this loop is the
            single place the duty cycle is enforced: without it, back-to-back
            BEGIN IMMEDIATE chunks starve any live gateway/CLI process
            sharing the DB out of its lock retries (the measured ~85%
            write-lock ownership that froze concurrent sessions).
            N)r  r  max_FTS_REBUILD_MIN_PAUSE_FTS_REBUILD_DUTY_FACTOR)r@  rh  s    r'   _pausez.SessionDB.optimize_fts_storage.<locals>._pause
  s@     Js+ ==      r/   backfillTteardownr9  r0  z$VACUUM after FTS optimize failed: %sc                     |                      dt          t                    f           |                      d           |                      dt          t          f           d S )Nz{INSERT INTO state_meta (key, value) VALUES ('fts_storage_version', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.valuer1  z7UPDATE schema_version SET version = ? WHERE version < ?)re   r,   FTS_STORAGE_VERSIONSCHEMA_VERSIONr   s    r'   _settlez/SessionDB.optimize_fts_storage.<locals>._settle
  sm    LLH())+  
 LLVWWWLLI0    r/   donez/FTS storage optimization complete (layout v%d).)r	  vacuumed)rp  rY  rm  r  rb  r  r7  r(  re  r  r2  r,   floatr  	monotonicr  r"  r  re   r   r   r   r   r  r   rI  )rh  r8  r9  legacyr  r?  rE  _t0	vacuum_okr   rK  s   ``         r'   optimize_fts_storagezSessionDB.optimize_fts_storage
  s       	?+=>>>> 	8;777 Z 	@ 	@33DJ??F	@ 	@ 	@ 	@ 	@ 	@ 	@ 	@ 	@ 	@ 	@ 	@ 	@ 	@ 	@-- 899E 	/' 	/,,...
 	$$&&&  	$ $ $++DJ777
!!###$ $ $ $ $ $ $ $ $ $ $ $ $ $ $	 	 	 	 	 	 	 	 		% 	D 	 	 	 	 	 	  	j	+.""C((** E*F4>##c)***	+ 	j	+.""C,,.. E*F4>##c)***	+ 	j	+.""C0022 E*F4>##c)***	+ 	 	"E(OOO	"Z 1 1J&&x0001 1 1 1 1 1 1 1 1 1 1 1 1 1 1 		+ " " " EsKKK!						"
	 
	 
	 	G$$$f=?R	
 	
 	
 	222s_   A		AA%4C%%C),C)<I8 I*I8 *I..I8 1I.2I8 8J.J))J.
schema_sqlc                 r   t          j        d          }	 |                    |            i }|                    d                                          D ]\  }i }|                    d| d                                          D ]~}|d         }|d         pd}|d         }|d	         }	|d
         }
|r|gng }|r|
s|                    d           |	|                    d|	            d                    |          ||<   |||<   ||                                 S # |                                 w xY w)u  Extract expected columns per table from SCHEMA_SQL.

        Uses an in-memory SQLite database to parse the SQL — SQLite itself
        handles all syntax (DEFAULT expressions with commas, inline
        REFERENCES, CHECK constraints, etc.) so there are zero regex
        edge cases.  The in-memory DB is opened, the schema DDL is
        executed, and PRAGMA table_info extracts the column metadata.

        Adding a column to SCHEMA_SQL is all that's needed; the
        reconciliation loop picks it up automatically.
        r   zNSELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'PRAGMA table_info("")r   r  r   r      r	  zNOT NULLNzDEFAULT  )r   r   r  re   rf   r   rc   r   )rT  reftable_columnsr  colsr0   col_namecol_typenotnulldefaultpkpartss               r'   _parse_schema_columnszSessionDB._parse_schema_columns  sg    oj))	j)))79M++B  hjj* * (*;;1#111 (**5 5C  #1vH"1v|H!!fG!!fGQB*2:XJJE 1r 1Z000*%9%9%9:::%(XXe__DNN%)c"" IIKKKKCIIKKKKs   C5D   D6c           
         |                      t                    }|                                D ])\  }}	 |                    d| d                                          }n# t
          j        $ r Y Dw xY wt                      }|D ]C}t          |t          t          f          r|d         n|d         }|                    |           D|                                D ]x\  }	}
|	|vro|	                    dd          }	 |                    d| d| d	|
            ?# t
          j        $ r'}t                              d
||	|           Y d}~pd}~ww xY wy+dS )uZ  Ensure live tables have every column declared in SCHEMA_SQL.

        Follows the Beets/sqlite-utils pattern: the CREATE TABLE definition
        in SCHEMA_SQL is the single source of truth for the desired schema.
        On every startup this method diffs the live columns (via PRAGMA
        table_info) against the declared columns, and ADDs any that are
        missing.

        This makes column additions a declarative operation — just add
        the column to SCHEMA_SQL and it appears on the next startup.
        Version-gated migration blocks are no longer needed for ADD COLUMN.
        rV  rW  r   r   """zALTER TABLE "z" ADD COLUMN "z" zreconcile %s.%s: %sN)rc  
SCHEMA_SQLitemsre   rf   r   r   ra   r+   tuplerb   r   r  r   r  )rh  rl   expectedr5  declared_colsr  	live_colsr0   r   r]  r^  	safe_namer   s                r'   _reconcile_columnszSessionDB._reconcile_columns7  s    --j99)1)9)9 	 	%J~~8*888 (**  +   I $ $!+C%!?!?Ps1vvS[d####&3&9&9&;&;  "(9,, ( 0 0d ; ;I]J]]i]]S[]]    #3   
 1:x        -	 	s)   +A""A43A4?DE-EEc                 t   | j                                         }|                    t                     |                     |           	 |                    d           n7# t          j        $ r%}t          	                    d|           Y d}~nd}~ww xY w|                    t                     	 |                    d           n# t          j        $ r Y nw xY w|                     |          }d}|s|                     |           |                    d           |                                }||                    dt          f           nFt          |t          j                  r|d         n|d	         }|d
k     rct          d
k    rX|rT|                     |d          }|du r5|                     |dt&                    r|                    d           n
d}n|d}nd}|dk     rt          dk     r	 |dk     rQ	 |                    dt)          d                      |                    d           n# t          j        $ r Y nw xY w|dk     rI	 |                     |           n2# t,          $ r%}t          	                    d|           Y d}~nd}~ww xY w|dk     r,	 |                    d           n# t          j        $ r Y nw xY w|dk     r	 |                    d                                          d	         }|s~|                    d           |                    d           |                    d           |                    d           |                    d           |                    d           n7# t          j        $ r%}t          	                    d |           Y d}~nd}~ww xY w|dk     r/|r-|                     |          r|                     d!d"|#           |r{|                     |          sf|                    d$                                          ?|                     |          s*|                     d%t5          t6                    |#           |t          k     r |r|r|                    d&t          f           d'}		 |                    |	           n# t          j        $ r~ 	 |                    d(           t                              d)|j                   |                    |	           n/# t          j        $ r t                               d*           Y nw xY wY nt          j        $ r Y nw xY w|rI|                     |          r| !                    |          tE          tF                    k     }
|                     |d+tH                    | _%        | j%        r<|                     |dtL                    }|| _'        |
r| (                    ||,           n| !                    |          tE          tF                    k     }
|                     |d+tR                    | _%        | j%        rQ|                     |dt&                    }|| _'        |
r| *                    ||,           | +                    |           | j         ,                                 dS )-a  Create tables and FTS if they don't exist, reconcile columns.

        Schema management follows the declarative reconciliation pattern
        (Beets, sqlite-utils): SCHEMA_SQL is the single source of truth.
        On existing databases, _reconcile_columns() diffs live columns
        against SCHEMA_SQL and ADDs any missing ones.  This eliminates
        the version-gated migration chain for column additions, making
        it impossible for reordered or inserted migrations to skip columns.

        The schema_version table is retained for future data migrations
        (transforming existing rows) which cannot be handled declaratively.
        zCREATE INDEX IF NOT EXISTS idx_messages_platform_msg_id ON messages(session_id, platform_message_id) WHERE platform_message_id IS NOT NULLz/idx_messages_platform_msg_id create skipped: %sNz3UPDATE messages SET active = 1 WHERE active IS NULLTz*SELECT version FROM schema_version LIMIT 1z/INSERT INTO schema_version (version) VALUES (?)versionr   
   r  FzkINSERT INTO messages_fts_trigram(rowid, content) SELECT id, content FROM messages WHERE content IS NOT NULL   rv      zUPDATE sessions SET model_config = json_set(COALESCE(model_config, '{}'), '$._delegate_from', parent_session_id) WHERE parent_session_id IS NOT NULL AND json_extract(COALESCE(model_config, '{}'), '$._delegate_from') IS NULL AND sessionsa  UPDATE sessions SET model_config = json_set(COALESCE(model_config, '{}'), '$._delegate_from', '__orphaned__') WHERE parent_session_id IS NULL AND json_extract(COALESCE(model_config, '{}'), '$._delegate_from') IS NULL AND json_extract(COALESCE(model_config, '{}'), '$._branched_from') IS NULL AND title IS NULL AND message_count <= 25 AND EXISTS (SELECT 1 FROM messages m             WHERE m.session_id = sessions.id AND m.role = 'tool') AND NOT EXISTS (SELECT 1 FROM sessions ch                 WHERE ch.parent_session_id = sessions.id)   z)v18 gateway metadata backfill skipped: %s   a  INSERT OR IGNORE INTO session_model_usage (
                               session_id, model, billing_provider,
                               billing_base_url, billing_mode,
                               api_call_count, input_tokens,
                               output_tokens, cache_read_tokens,
                               cache_write_tokens, reasoning_tokens,
                               estimated_cost_usd, actual_cost_usd,
                               cost_status, cost_source, first_seen, last_seen
                           )
                           SELECT id, COALESCE(model, 'unknown'),
                                  COALESCE(billing_provider, ''),
                                  COALESCE(billing_base_url, ''),
                                  COALESCE(billing_mode, ''),
                                  COALESCE(api_call_count, 0),
                                  COALESCE(input_tokens, 0),
                                  COALESCE(output_tokens, 0),
                                  COALESCE(cache_read_tokens, 0),
                                  COALESCE(cache_write_tokens, 0),
                                  COALESCE(reasoning_tokens, 0),
                                  COALESCE(estimated_cost_usd, 0),
                                  COALESCE(actual_cost_usd, 0),
                                  cost_status, cost_source,
                                  started_at, COALESCE(ended_at, started_at)
                           FROM sessions
                           WHERE COALESCE(input_tokens, 0)
                                 + COALESCE(output_tokens, 0)
                                 + COALESCE(cache_read_tokens, 0)
                                 + COALESCE(cache_write_tokens, 0)
                                 + COALESCE(reasoning_tokens, 0) > 0   z\SELECT COUNT(*) FROM pragma_table_info('session_model_usage') WHERE name = 'task' AND pk > 0zAALTER TABLE session_model_usage RENAME TO session_model_usage_v21a  CREATE TABLE session_model_usage (
                                   session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
                                   model TEXT NOT NULL,
                                   billing_provider TEXT NOT NULL DEFAULT '',
                                   billing_base_url TEXT NOT NULL DEFAULT '',
                                   billing_mode TEXT NOT NULL DEFAULT '',
                                   task TEXT NOT NULL DEFAULT '',
                                   api_call_count INTEGER NOT NULL DEFAULT 0,
                                   input_tokens INTEGER NOT NULL DEFAULT 0,
                                   output_tokens INTEGER NOT NULL DEFAULT 0,
                                   cache_read_tokens INTEGER NOT NULL DEFAULT 0,
                                   cache_write_tokens INTEGER NOT NULL DEFAULT 0,
                                   reasoning_tokens INTEGER NOT NULL DEFAULT 0,
                                   estimated_cost_usd REAL NOT NULL DEFAULT 0,
                                   actual_cost_usd REAL NOT NULL DEFAULT 0,
                                   cost_status TEXT,
                                   cost_source TEXT,
                                   first_seen REAL,
                                   last_seen REAL,
                                   PRIMARY KEY (session_id, model, billing_provider, billing_base_url, billing_mode, task)
                               )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
                               )
                               SELECT session_id, model, billing_provider, billing_base_url,
                                      billing_mode, '', api_call_count, input_tokens,
                                      output_tokens, cache_read_tokens, cache_write_tokens,
                                      reasoning_tokens, estimated_cost_usd, actual_cost_usd,
                                      cost_status, cost_source, first_seen, last_seen
                               FROM session_model_usage_v21z"DROP TABLE session_model_usage_v21z]CREATE INDEX IF NOT EXISTS idx_session_model_usage_session ON session_model_usage(session_id)zVCREATE INDEX IF NOT EXISTS idx_session_model_usage_model ON session_model_usage(model)z+v22 session_model_usage rebuild skipped: %sr,  rI  rl   r*  fts_storage_versionz%UPDATE schema_version SET version = ?zfCREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_title_unique ON sessions(title) WHERE title IS NOT NULLag  UPDATE sessions AS older
                       SET title = NULL
                       WHERE title IS NOT NULL
                         AND EXISTS (
                             SELECT 1 FROM sessions AS newer
                             WHERE newer.title = older.title
                               AND newer.rowid > older.rowid
                         )zFCleared %d duplicate session title(s) while restoring the unique indexzICould not repair duplicate session titles; unique title index not createdr  r  )-rb  rl   r  rg  rn  re   r   r   r   r  DEFERRED_INDEX_SQLr  r  r   rJ  r+   rc  r  r  r3  rP   -_backfill_gateway_metadata_from_sessions_jsonr   r  set_metar+  r,   rI  IntegrityErrorr   r  r   	exceptionr  rd   r  LEGACY_FTS_SQLrp  LEGACY_FTS_TRIGRAM_SQLrq  r  r2  r  r  r2  )rh  rl   r   fts5_availablefts_migrations_completer0   current_version_fts_trigram_exists	legacy_pktitle_index_sqltriggers_need_repairtrigram_enableds               r'   rf  zSessionDB._init_schemac  s    ""$$Z((( 	'''	QNN8   
 ' 	Q 	Q 	QLLJCPPPPPPPP	Q
 	/000	NNE    ' 	 	 	D	 33F;;"& 	, ##F+++
 	CDDDoo;NNA!   
 1;30L0LXc)nnRUVWRXO
 ##"(<(< " 4*.*?*? 6+ +' +e3322"$:O  < #NN!]   
 7<33,427/.3+##(;(; ##NNB  4J??	B B   NN
T    /   D##
SFFvNNNN  S S S
 LL!LcRRRRRRRRS ##!NNH   > /   D##7U &9! ! hjj!$I % 0'jkkk$  . ?   'KLLLA   <   / U U ULL!NPSTTTTTTTTU##: " Pd&D&DV&L&L PMM":CMOOO 66v>> NNC  (**% ++F33	% )3/B+C+CF      .00+ 1" 1 ;#%  9 		NN?++++% 	 	 		 	 	 \O   ////=     5    
 ' 	 	 	D	  4	8 --f55 '8++F33c-6H6HH % %)$;$;NN% %! $ &*&=&= 68N' 'O />D++ 88"O 9   
 ++F33c-6H6HH % %)$;$;NG% %! $ 8&*&=&= 6' 'O />D++ 11",; 2    //777
s   
A   B/BB2C CC :H; ;III- -
J7JJ&J< <KKB-N N:N55N:R3 3UA
TU)T:7U9T::U?UU
session_idsourcemodelr8   system_promptuser_idsession_keychat_id	chat_type	thread_idparent_session_idr3   profile_namer2   c                 p   	
 	
fd}|                      |           dS )ud  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`` 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). This only fills NULLs — an explicit ``cwd``/``git_repo_root``
        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 )NaI  INSERT INTO sessions (
                   id, source, user_id, session_key, chat_id, chat_type, thread_id,
                   model, model_config, system_prompt, parent_session_id, cwd,
                   profile_name, git_repo_root, started_at
                )
                   VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
                   ON CONFLICT(id) DO UPDATE SET
                       model = COALESCE(sessions.model, excluded.model),
                       model_config = COALESCE(sessions.model_config, excluded.model_config),
                       system_prompt = COALESCE(sessions.system_prompt, excluded.system_prompt),
                       session_key = COALESCE(sessions.session_key, excluded.session_key),
                       chat_id = COALESCE(sessions.chat_id, excluded.chat_id),
                       chat_type = COALESCE(sessions.chat_type, excluded.chat_type),
                       thread_id = COALESCE(sessions.thread_id, excluded.thread_id),
                       parent_session_id = COALESCE(sessions.parent_session_id, excluded.parent_session_id),
                       cwd = COALESCE(sessions.cwd, excluded.cwd),
                       profile_name = COALESCE(sessions.profile_name, excluded.profile_name),
                       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))
                     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'
                       ))re   jsondumpsr  )rh   r  r  r3   r2   r  r8   r  r  r  r  r  r  r  r  s    r'   r  z*SessionDB._insert_session_row.<locals>._doK  s    LLc& 0<FDJ|,,,$!% !IKK'$ $ $J ! 8
G  M  2 8  M;    58 8r/   Nr  )rh  r  r  r  r8   r  r  r  r  r  r  r  r3   r  r2   r  s    `````````````` r'   _insert_session_rowzSessionDB._insert_session_row  s    d^	 ^	 ^	 ^	 ^	 ^	 ^	 ^	 ^	 ^	 ^	 ^	 ^	 ^	 ^	 ^	 ^	 ^	~ 	C     r/   c                 $     | j         ||fi | |S )z4Create a new session record. Returns the session_id.r  )rh  r  r  kwargss       r'   create_sessionzSessionDB.create_session  s%      V>>v>>>r/   )r  r  r  r  r  display_nameorigin_jsonr  r  c          	      h   	 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.
        Nc                 F   	 |                      d	f	           d S )Na1  UPDATE sessions
                   SET session_key = ?, source = ?, user_id = ?, chat_id = ?,
                       chat_type = ?, thread_id = ?,
                       display_name = COALESCE(?, display_name),
                       origin_json = COALESCE(?, origin_json)
                   WHERE id = ?r  )
rh   r  r  r  r  r  r  r  r  r  s
    r'   r  z2SessionDB.record_gateway_session_peer.<locals>._do  sJ    LL#   
    r/   r  )rh  r  r  r  r  r  r  r  r  r  r  s    ````````` r'   record_gateway_session_peerz%SessionDB.record_gateway_session_peer  s~    *  	 	F	 	 	 	 	 	 	 	 	 	 	 	 	* 	C     r/   	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  )rh   r  r  s    r'   r  z+SessionDB.set_expiry_finalized.<locals>._do  s7    LLG&Q
3    r/   r  )rh  r  r  r  s    `` r'   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re   r  )rh   r  r  r  s    r'   r  z1SessionDB.save_gateway_routing_entry.<locals>._do  s<    LL;
 Z=    r/   r  )rh  r  r  r  r  s    ``` r'   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 r;   r;   )rU   r  r  r   r  s      r'   r^   zJSessionDB.replace_gateway_routing_entries.<locals>._do.<locals>.<listcomp>!  s0    OOODAqqOQOeQ3'OOOr/   )re   executemanyrh  )rh   r  r   r  s    r'   r  z6SessionDB.replace_gateway_routing_entries.<locals>._do  su    LLFQQQ   *OOOOOGMMOOOOO     r/   Nr  r  )rh  r  r  r  r   s    `` @r'   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  r;   r
  s     r'   
<dictcomp>z:SessionDB.load_gateway_routing_entries.<locals>.<dictcomp>-  s#    @@@a- !L/@@@r/   rm  rb  re   rf   )rh  r  r  s      r'   load_gateway_routing_entriesz&SessionDB.load_gateway_routing_entries&  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 r;   r;   )rU   r  r  s     r'   r^   zISessionDB.delete_gateway_routing_entries.<locals>._do.<locals>.<listcomp>9  s    222%222r/   )r  )rh   r  r  s    r'   r  z5SessionDB.delete_gateway_routing_entries.<locals>._do6  s?    Q2222\222    r/   r  )rh  r  r  r  s    `` r'   delete_gateway_routing_entriesz(SessionDB.delete_gateway_routing_entries/  sM      	F	 	 	 	 	 	 	C     r/   )r   active_onlyr   r  c                
   d}g }|r|dz  }|                     |           |r|dz  }|dz  }| j        5  | j                            ||                                          }ddd           n# 1 swxY w Y   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.
        a  
            SELECT sessions.*,
                   COALESCE(
                       (SELECT MAX(m.timestamp) FROM messages m
                        WHERE m.session_id = sessions.id),
                       sessions.started_at
                   ) AS last_active
            FROM sessions
            WHERE session_key IS NOT NULL
              AND started_at = (
                  SELECT MAX(s2.started_at) FROM sessions s2
                  WHERE s2.session_key = sessions.session_key
              )
        z AND LOWER(source) = LOWER(?)z AND ended_at IS NULLz ORDER BY last_active DESCNc                 ,    g | ]}t          |          S r;   r   r
  s     r'   r^   z3SessionDB.list_gateway_sessions.<locals>.<listcomp>b      &&&AQ&&&r/   )r   rm  rb  re   rf   )rh  r   r  queryparamsr  s         r'   list_gateway_sessionszSessionDB.list_gateway_sessions>  s      	$44EMM(### 	-,,E--Z 	@ 	@:%%eV44==??D	@ 	@ 	@ 	@ 	@ 	@ 	@ 	@ 	@ 	@ 	@ 	@ 	@ 	@ 	@&&&&&&s   .A..A25A2)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 r;   r  r
  s     r'   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   )r,   r4   )rU   r  r  s     r'   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  )r,   r4   r5   r
  s     r'   rW   z3SessionDB.find_session_by_origin.<locals>.<setcomp>  sx       quuY''-2..4466AEE)$$*++1133  r/   )r,   r   rm  rb  re   rf   rd   )
rh  r   r  r  r  r  r  r  exactdistinct_userss
       `     r'   find_session_by_originz SessionDB.find_session_by_origind  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c                 ,   t                      dz  dz  }|                                sdS t          |dd          5 }t          j        |          }ddd           n# 1 swxY w Y   t          |t                    sdS |                                D ]~\  }}t          |          	                    d          st          |t                    s>|
                    d          }|sV|
                    d	          }|                    d
|
                    d          p|t          |t                    r|pi 
                    d          nd|
                    d          t          |t                    r|pi 
                    d          nd|
                    d          t          |t                    rt          j        |          nd|
                    d          s|
                    d          rdndt          |          f           dS )uv  One-time v18 backfill of gateway metadata from sessions.json.

        Existing gateway sessions predate the display_name / origin_json /
        expiry_finalized columns; copy what sessions.json knows so consumers
        can switch to state.db without losing pre-migration sessions.
        Only fills NULL columns — never overwrites data written by newer code.
        rt  zsessions.jsonNr  utf-8)encodingr  r  originai  UPDATE sessions
                   SET session_key = COALESCE(session_key, ?),
                       chat_id = COALESCE(chat_id, ?),
                       chat_type = COALESCE(chat_type, ?),
                       thread_id = COALESCE(thread_id, ?),
                       display_name = COALESCE(display_name, ?),
                       origin_json = COALESCE(origin_json, ?),
                       expiry_finalized = CASE
                           WHEN COALESCE(expiry_finalized, 0) = 0 AND ? = 1 THEN 1
                           ELSE expiry_finalized
                       END
                   WHERE id = ?r  r  r  r  r  expiry_finalizedmemory_flushedr   r   )r   r   openr  loadr+   r   rh  r,   r   r4   re   r  )	rh  rl   sessions_filefdatar   entryr  r  s	            r'   r{  z7SessionDB._backfill_gateway_metadata_from_sessions_json  sB    ())J6H##%% 	F-w777 	 19Q<<D	  	  	  	  	  	  	  	  	  	  	  	  	  	  	 $%% 	F**,, 	 	JC3xx""3'' z%/F/F <00J YYx((FNN# IIm,,35?5M5MWV\r&&y111SWIIk**7A&$7O7OYV\r&&{333UYIIn--*4VT*B*BLDJv&&&#566\%))DT:U:U\AA[\
OO	   	 	s   AA"%A")r  r  r  r  r  c          	         |sdS | j         5  | j                            d||f                                          }|t	          |          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t	          |          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 * FROM sessions
                WHERE session_key = ?
                  AND source = ?
                  AND (ended_at IS NULL OR end_reason IN ('agent_close', 'ws_orphan_reap'))
                  AND (COALESCE(message_count, 0) > 0 OR EXISTS (
                      SELECT 1 FROM messages WHERE messages.session_id = sessions.id LIMIT 1
                  ))
                ORDER BY started_at DESC
                LIMIT 1
                a  
                SELECT * FROM sessions
                WHERE source = ?
                  AND COALESCE(user_id, '') = COALESCE(?, '')
                  AND COALESCE(chat_id, '') = COALESCE(?, '')
                  AND COALESCE(chat_type, '') = COALESCE(?, '')
                  AND COALESCE(thread_id, '') = COALESCE(?, '')
                  AND (ended_at IS NULL OR end_reason IN ('agent_close', 'ws_orphan_reap'))
                  AND (COALESCE(message_count, 0) > 0 OR EXISTS (
                      SELECT 1 FROM messages WHERE messages.session_id = sessions.id LIMIT 1
                  ))
                ORDER BY started_at DESC
                LIMIT 1
                rm  rb  re   r   r   )rh  r  r  r  r  r  r  r0   s           r'   $find_latest_gateway_session_for_peerz.SessionDB.find_latest_gateway_session_for_peer  se   *  	4Z '	 '	*$$
 f%  hjj  Cyy!'	 '	 '	 '	 '	 '	 '	 '	* )"3-'	 '	 '	 '	 '	 '	 '	 '	. *$$ '9i@   hjj! /'	 '	 '	 '	 '	 '	 '	 '	 '	 '	 '	 '	 '	 '	 '	P  )tCyyyT)s   A B)B)+2B))B-0B-
end_reasonc                 @    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  )rh   r  r  s    r'   r  z"SessionDB.end_session.<locals>._do  s8    LL4j*5    r/   Nr  )rh  r  r  r  s    `` r'   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  rh   r  s    r'   r  z%SessionDB.reopen_session.<locals>._do  s+    LLU    r/   Nr  rh  r  r  s    ` r'   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'))re   r  )rh   rl   r   r:  r  s     r'   r  z/SessionDB.promote_to_session_reset.<locals>._doE  s2    \\F fj)	 F ?"r/   )r  r  r  r   )rh  r  r:  r  r  r   s    ``  @r'   promote_to_session_resetz"SessionDB.promote_to_session_reset#  s    <  	5ikk	# 	# 	# 	# 	# 	# 	#	&&s++D:: 	 	 	55	s   #A 
AA
git_branchc                   	 |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 r
  z WHERE id = ?)re   rc   )rh   r  setss    r'   r  z)SessionDB.update_session_cwd.<locals>._dou  s4    LLN		$NNNPVWWWWWr/   )r5   r   r  )
rh  r  r3   r  r2   rN   	repo_rootr  r  r  s
           @@r'   update_session_cwdzSessionDB.update_session_cwdT  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 r;   r;   )rU   r3   r6   s      r'   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  )rh   r6   r3   pairss      r'   r  z*SessionDB.backfill_repo_roots.<locals>._do  sH    "  	cI3K    r/   )rh  r  )rh  r  r  r  s      @r'   backfill_repo_rootszSessionDB.backfill_repo_rootsz  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 )NzfUPDATE sessions SET compression_failure_cooldown_until = ?, compression_failure_error = ? WHERE id = ?r  )rh   r  r'  r  s    r'   r  z:SessionDB.record_compression_failure_cooldown.<locals>._do  s2    LL=
3    r/   z2record_compression_failure_cooldown(%s) failed: %sr  r   r   r   r   )rh  r  r  r'  r  r   s    ```  r'   #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``.Nz_SELECT compression_failure_cooldown_until, compression_failure_error FROM sessions WHERE id = ?"compression_failure_cooldown_untilr   compression_failure_errorr   )r  remaining_secondsr'  )	r  rm  rb  re   r   r+   r   rc  rN  )rh  r  r   r0   r  r'  s         r'    get_compression_failure_cooldownz*SessionDB.get_compression_failure_cooldown  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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    r'   r  z9SessionDB.clear_compression_failure_cooldown.<locals>._do  s.    LL@    r/   z1clear_compression_failure_cooldown(%s) failed: %sr  )rh  r  r  r   s    `  r'   "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rm  rb  re   r   r+   r   rc  rB  r   r   r   rh  r  rh   r0   r)   s        r'   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  rh   
normalizedr  s    r'   r  z6SessionDB.set_compression_fallback_streak.<locals>._do  s.    LLRZ(    r/   rB  r   r  )rh  r  r  r  r  s    `  @r'   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_countr  r  s        r'   !get_compression_ineffective_countz+SessionDB.get_compression_ineffective_count  sO     	1Z 	 	:D|	 	 	 	 	 	 	 	 ,,Q  hjj 		 	 	 	 	 	 	 	 	 	 	 	 	 	 	 ;1 #w{++C/00Q 	
	q#ejq//***:& 	 	 	11	r
  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  r  s    r'   r  z8SessionDB.set_compression_ineffective_count.<locals>._do,  s.    LLTZ(    r/   r  )rh  r  r  r  r  s    `  @r'   !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)z>Extend the compression lock lease if ``holder`` still owns it.Fc                 N    |                      df          }|j        dk    S )NzcUPDATE compression_locks SET expires_at = ? WHERE session_id = ? AND holder = ? AND expires_at >= ?r   r  )rh   r  
expires_atr   r   r  s     r'   r  z/SessionDB.refresh_compression_lock.<locals>._doU  s7    ,,JZ5 C
 <!##r/   z'refresh_compression_lock(%s) failed: %sN)r  r  r  r   r   r   r   )rh  r  r   r  r  r   r  r   s    ``   @@r'   refresh_compression_lockz"SessionDB.refresh_compression_lockI  s      	 	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 )	NzESELECT holder, expires_at FROM compression_locks WHERE session_id = ?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 = ?)re   r   r+   r   rc  r(   )
rh   reclaimed_holderr0   current_holdercurrent_expires_atacquiredr  r   r   r  s
         r'   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   r  r   r   )
rh  r  r   r  r  r%  r"  r   r  r   s
    ``     @@r'   try_acquire_compression_lockz&SessionDB.try_acquire_compression_lockf  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 )Nr!  r  )rh   r   r  s    r'   r  z/SessionDB.release_compression_lock.<locals>._do  s0    LL6V$    r/   z'release_compression_lock(%s) failed: %sr  )rh  r  r   r  r   s    ``  r'   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  rb  re   r   r+   r   rc  )rh  r  r   r0   s       r'   get_compression_lock_holderz%SessionDB.get_compression_lock_holder  s}    
  	4ikkj  7
 
 (**	 	
 ;4 *3 < <Hs8}}#a&Hr/   model_config_jsonc                 D    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  )rh   r  r,  r  s    r'   r  z*SessionDB.update_session_meta.<locals>._do  s0    LL_"E:6    r/   Nr  )rh  r  r,  r  r  s    ``` r'   update_session_metazSessionDB.update_session_meta  sD    	 	 	 	 	 	 	
 	C     r/   c                 @    fd}|                      |           dS )z0Store the full assembled system prompt snapshot.c                 8    |                      df           d S )Nz2UPDATE sessions SET system_prompt = ? WHERE id = ?r  )rh   r  r  s    r'   r  z+SessionDB.update_system_prompt.<locals>._do  s.    LLD
+    r/   Nr  )rh  r  r  r  s    `` r'   update_system_promptzSessionDB.update_system_prompt  s>    	 	 	 	 	 	
 	C     r/   c                 @    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.
        c                 8    |                      df           d S )Nz*UPDATE sessions SET model = ? WHERE id = ?r  )rh   r  r  s    r'   r  z+SessionDB.update_session_model.<locals>._do  s.    LL<
#    r/   Nr  )rh  r  r  r  s    `` r'   update_session_modelzSessionDB.update_session_model  s>    	 	 	 	 	 	
 	C     r/   )billing_modeproviderbase_urlr6  c                H    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                 <    |                      df           d S )NzUPDATE sessions SET
                   billing_provider = ?,
                   billing_base_url = ?,
                   billing_mode = COALESCE(?, billing_mode),
                   system_prompt = NULL
                   WHERE id = ?r  )rh   r8  r6  r7  r  s    r'   r  z3SessionDB.update_session_billing_route.<locals>._do%  s6    LL# 8\:>    r/   Nr  )rh  r  r7  r8  r6  r  s    ```` r'   update_session_billing_routez&SessionDB.update_session_billing_route  sJ    $		 		 		 		 		 		 		 		 	C     r/   r   input_tokensoutput_tokenscache_read_tokenscache_write_tokensreasoning_tokensestimated_cost_usdactual_cost_usdcost_statuscost_sourcepricing_versionbilling_providerbilling_base_urlapi_call_countabsolutec                 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).
        unknown)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  rF  rH  r   zUPDATE sessions
                       SET model = ?, billing_provider = ?,
                       billing_base_url = ?, billing_mode = ?
                       WHERE id = ?)r  rF  rG  r6  r<  r=  r>  r?  r@  rA  rB  rC  rD  rH  )re   r   r   r  _record_model_usage)rh   r0   existing_modelexisting_providerexisting_api_callsfirst_accounted_routerB  rH  rG  r6  rF  r>  r?  rD  rC  rA  has_accounted_usager<  r  r=  r  r@  record_model_usagerh  r  r  s         r'   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  r  r  )rh  r  r<  r=  r  r>  r?  r@  rA  rB  rC  rD  rE  rF  rG  r6  rH  rI  r  rR  r  rS  r  s   ```````````` ````  @@@@r'   update_token_countszSessionDB.update_token_counts1  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/   )taskrU  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  rF  rG  r6  rK  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   g        )re   r   r  rN  )rh  rh   r  r  rF  rG  r6  r<  r=  r>  r?  r@  rA  rB  rC  rD  rH  rU  r0   
sess_modelsess_providersess_base_urlsess_billing_mode	eff_modeleff_providereff_base_urleff_billing_moder   s                               r'   rM  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/   rK  c                 (     | j         ||fd|i| |S )zHEnsure a session row exists (INSERT OR IGNORE). Accepts optional kwargs.r  r  )rh  r  r  r  r  s        r'   ensure_sessionzSessionDB.ensure_session@  s,     	! VKK5KFKKKr/   )	r  rF  rG  r<  r=  r>  r?  r@  rA  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.
        NrK  c                 h    
                     | d pdpdpdpd	pdd d d d           d S )Nr   r   )r  rF  rG  r6  r<  r=  r>  r?  r@  rA  rB  rC  rD  rH  rU  )rM  )rh   rG  rF  r>  r?  rA  r<  r  r=  r@  rh  r  rU  s    r'   r  z-SessionDB.record_auxiliary_usage.<locals>._dop  sv    $$!1!1!).Q+0q"3"8q#5#:!1!6Q#5 $   # %     r/   )r  r  )rh  r  rU  r  rF  rG  r<  r=  r>  r?  r@  rA  r  s   ```````````` r'   record_auxiliary_usagez SessionDB.record_auxiliary_usageK  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             }|r?d                    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\   )r+   ri  rb   r
  s     r'   r^   zESessionDB.prune_empty_ghost_sessions.<locals>._do.<locals>.<listcomp>  s7    SSS:a%77D1Q44QtWSSSr/   rX   rY   rq   rF   re   rf   rc   rd   )rh   r  rr   r  cutoffs       r'   r  z1SessionDB.prune_empty_ghost_sessions.<locals>._do  s    << 	! 	 	 %HJJ  TSdSSSC "xxc#hh77HHHH#   Jr/   )r  r  _remove_session_filesrd   )rh  rd  r  removed_idsrV   rj  s        @r'   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  re   r  )rh   r   r  rj  s      r'   r  z=SessionDB.finalize_orphaned_compression_sessions.<locals>._do  s9    )++C\\( f+ F. ?"r/   r   r  )rh  r  rj  s     @r'   &finalize_orphaned_compression_sessionsz0SessionDB.finalize_orphaned_compression_sessions  sI     v%	# 	# 	# 	# 	#6 ""3'',1,r/   c                     | j         5  | j                            d|f          }|                                }ddd           n# 1 swxY w Y   |rt	          |          ndS )zGet a session by ID.z#SELECT * FROM sessions WHERE id = ?Nr  rh  r  rl   r0   s       r'   get_sessionzSessionDB.get_session  s    Z 	$ 	$Z''5
} F //##C		$ 	$ 	$ 	$ 	$ 	$ 	$ 	$ 	$ 	$ 	$ 	$ 	$ 	$ 	$
  )tCyyyT)   1A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  r?   r  r  zSSELECT id FROM sessions WHERE id LIKE ? ESCAPE '\' ORDER BY started_at DESC LIMIT 2c                     g | ]
}|d          S r[   r;   rU   r0   s     r'   r^   z0SessionDB.resolve_session_id.<locals>.<listcomp>  s    >>>Ss4y>>>r/   Nr   r   )rs  r  rm  rb  re   rf   rd   )rh  ru  r  escapedrl   matchess         r'   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+rY  zTitle too long (z chars, max rF   )r   resubr5   rd   r  MAX_TITLE_LENGTHr   )r~  cleaneds     r'   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                               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childrD   a9  
            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)rM   rL   re   r   )rh  rh   r  r  edger0   s         r'   _is_compression_ancestorz"SessionDB._is_compression_ancestor"  s      	- 	;-3O3O5 &,,w,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 = ?)re   r   r  r   r  )
rh   r   rl   conflictconflict_id	predicater  rh  r  r~  s
         r'   r  z)SessionDB._set_session_title.<locals>._doP  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  )rh  r  r~  r  r  r  s   ````  r'   _set_session_titlezSessionDB._set_session_titleG  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  rh  r  r~  s      r'   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      r'   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~  rm  rb  re   r   rr  s       r'   get_session_titlezSessionDB.get_session_title  s    Z 	$ 	$Z''9J= F //##C		$ 	$ 	$ 	$ 	$ 	$ 	$ 	$ 	$ 	$ 	$ 	$ 	$ 	$ 	$
  #,s7||,rt  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   zSELECT changes())re   r  r   )rh   rl   r  r  r  s      r'   r  z+SessionDB.set_session_archived.<locals>._do  sp    \\: Zh)=A>= F@ H8a<<<<(:;;DDFFqIOr/   r   r  )rh  r  r  r  r  s    ``  r'   set_session_archivedzSessionDB.set_session_archived  sB    $	 $	 $	 $	 $	 $	J &&s++!|r/   c                     | j         5  | j                            d|f          }|                                }ddd           n# 1 swxY w Y   |rt	          |          ndS )z?Look up a session by exact title. Returns session dict or None.z&SELECT * FROM sessions WHERE title = ?Nr  )rh  r~  rl   r0   s       r'   get_session_by_titlezSessionDB.get_session_by_title  s    Z 	$ 	$Z''85( F //##C		$ 	$ 	$ 	$ 	$ 	$ 	$ 	$ 	$ 	$ 	$ 	$ 	$ 	$ 	$
  )tCyyyT)rt  c                    |                      |          }|                    dd                              dd                              dd          }| j        5  | j                            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).
        rw  rx  r  r?   r  r  zaSELECT id, title, started_at FROM sessions WHERE title LIKE ? ESCAPE '\' ORDER BY started_at DESC #%Nr   r\   )r  r  rm  rb  re   rf   )rh  r~  r  r{  rl   numbereds         r'   resolve_session_by_titlez"SessionDB.resolve_session_by_title  s    ))%00 --f--55c5AAII#uUUZ 	) 	)Z''J" F
 ((H	) 	) 	) 	) 	) 	) 	) 	) 	) 	) 	) 	) 	) 	) 	)  	A;t$$ 	;ts   4B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   rw  rx  r  r?   r  r  zESELECT title FROM sessions WHERE title = ? OR title LIKE ? ESCAPE '\'r  c                     g | ]
}|d          S )r~  r;   rz  s     r'   r^   z7SessionDB.get_next_title_in_lineage.<locals>.<listcomp>  s    BBBGBBBr/   Nz^.* #(\d+)$z #)
r  r%   r   r  rm  rb  re   rf   rB  r   )
rh  r  r%   baser{  rl   r   max_numtms
             r'   get_next_title_in_lineagez#SessionDB.get_next_title_in_lineage  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                 P   |}|r|hnt                      }t          d          D ]}| j        5  | j                            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  aI  
                    SELECT child.id
                    FROM sessions parent
                    JOIN sessions child ON child.parent_session_id = parent.id
                    WHERE parent.id = ?
                      AND parent.end_reason = 'compression'
                      AND json_extract(COALESCE(child.model_config, '{}'), '$._branched_from') IS NULL
                      AND json_extract(COALESCE(child.model_config, '{}'), '$._delegate_from') IS NULL
                      AND COALESCE(child.source, '') != 'tool'
                    ORDER BY
                      CASE
                        WHEN child.end_reason = 'compression' THEN 0
                        WHEN child.ended_at IS NULL THEN 1
                        ELSE 2
                      END,
                      COALESCE(
                        (SELECT MAX(m.timestamp) FROM messages m WHERE m.session_id = child.id),
                        child.started_at
                      ) DESC,
                      child.started_at DESC,
                      child.id DESC
                    LIMIT 1
                    Nr\   )ra   r  rm  rb  re   r   r   )rh  r  r   seenr  rl   r0   child_ids           r'   get_compression_tipzSessionDB.get_compression_tip  s   * #.yy s #	 #	A ( (++. J1 4 oo''7( ( ( ( ( ( ( ( ( ( ( ( ( ( (8 {4yH x4//HHXGGs   1A++A/	2A/	_session_compact_cols_sqlc                       j         F                     t                    d         }d                     fd|D                        _          j         S )zSELECT list for compact_rows: every ``sessions`` column declared in
        SCHEMA_SQL except the ``system_prompt`` blob, aliased with the ``s``
        prefix used by list_sessions_rich/_get_session_rich_row queries.Nrt  r
  c              3   4   K   | ]}|j         vd | V  dS )zs.N)_SESSION_COMPACT_EXCLUDED)rU   r   clss     r'   r   z2SessionDB._compact_session_cols.<locals>.<genexpr>h  sC       6 6 $s<<< T<<<<6 6r/   )r  rc  rg  rc   )r  declareds   ` r'   _compact_session_colszSessionDB._compact_session_colsa  sm    
 (000<<ZHH,0II 6 6 6 6(06 6 6 - -C) ,,r/   include_archivedc                     d}|s|dz  }| j         5  | j                            d| d                                          }ddd           n# 1 swxY w Y   d |D             S )u  Distinct non-empty session cwds with usage stats, for repo discovery.

        Aggregates across ALL session history (not a single page), so the desktop
        can surface every git repo the user has worked in — not just the repos
        that happen to be in the currently-loaded recents. Children/branches
        count: a worktree session is still a real workspace signal.
        z#cwd IS NOT NULL AND TRIM(cwd) != ''z AND archived = 0zsSELECT cwd AS cwd, COUNT(*) AS sessions, MAX(COALESCE(ended_at, started_at, 0)) AS last_active FROM sessions WHERE z GROUP BY cwdNc                 z    g | ]8}|d          t          |d         pd          t          |d         pd          d9S )r3   rt  r   last_active)r3   rt  r  )r   rN  r
  s     r'   r^   z3SessionDB.distinct_session_cwds.<locals>.<listcomp>  sa     
 
 
 	 x* 233$Q}%5%:;; 
 
 
r/   r  )rh  r  wherer  s       r'   distinct_session_cwdszSessionDB.distinct_session_cwdsn  s     6 	)((EZ 	 	:%%<',< < <  hjj	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	
 
 
 
 
 	
s   1AAArv  exclude_sourcesr=   limitoffsetinclude_childrenmin_message_countproject_compression_tipsorder_by_last_activearchived_onlyid_querysearch_querycompact_rowsc                 
   g }g }|s?|                     t                     |                     t          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}|pd                                                                }|pd                                                                }|	r6|}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| d| d} ||z   |z   ||gz   }n8|r|                                 nd}d | d!| d"} |                    ||g           | j        5  | j                            | |          }!|!                                }"d#d#d#           n# 1 swxY w Y   g }#|"D ]}$t#          |$          }%|%                    d$d                                          }&|&r(|&d#d%         }'|'t'          |&          d%k    rd&ndz   |%d'<   nd|%d'<   |%                    d(d#           |#                     |%           |r|sg }(|#D ]}%|%                    d)          d*k    r|(                     |%           1|                     |%d+                   })|)|%d+         k    r|(                     |%           n|                     |)|,          }*|*s|(                     |%           t#          |%          }+d-D ]},|,|*v r|*|,         |+|,<   |%d+         |+d.<   |(                     |+           |(}#|#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 (timestamp of last message).

        Uses a single query with correlated subqueries instead of N+2 queries.

        By default, child sessions (subagent runs, compression continuations)
        are excluded.  Pass ``include_children=True`` to include them.

        With ``project_compression_tips=True`` (default), sessions that are
        roots of compression chains are projected forward to their latest
        continuation — one logical conversation = one list entry, showing the
        live continuation's id/message_count/title/last_active. This prevents
        compressed continuations from being invisible to users while keeping
        delegate subagents and branches hidden. Pass ``False`` to return the
        raw root rows (useful for admin/debug UIs).

        Pass ``order_by_last_active=True`` to sort by most-recent activity
        instead of original conversation start time. For compression chains,
        the "most-recent activity" is taken from the live tip (not the root),
        so an old conversation that was compressed and continued recently
        surfaces in the correct slot. Ordering is computed at SQL level via
        a recursive CTE that walks compression-continuation edges, so LIMIT
        and OFFSET still apply efficiently.

        ``search_query`` matches case-insensitive substrings against each
        surfaced row's title and id (and, like ``id_query``, every title/id in
        its forward compression chain). A punctuation-stripped variant is also
        matched so e.g. ``an94`` finds ``AN-94``. Only honored in the
        ``order_by_last_active`` path.

        Pass ``compact_rows=True`` for dashboard and picker callers that only
        need lightweight metadata. This omits the ``system_prompt`` blob from
        the SELECT so SQLite never copies it out of the B-tree page — a
        significant I/O saving on large databases where the blob routinely
        runs to tens of kilobytes per row.
        s.model_config IS NULLs.source = ?rX   c              3      K   | ]}d V  dS r  r;   r  s     r'   r   z/SessionDB.list_sessions_rich.<locals>.<genexpr>  "      #A#AAC#A#A#A#A#A#Ar/   s.source NOT IN (rF   r   s.message_count >= ?s.archived = 1s.archived = 0zWHERE  AND r   needler   c                     |                      dd                               dd                               dd          }d| dS )Nrw  rx  r  r?   r  r  )r  )r  r{  s     r'   _like_patternz3SessionDB.list_sessions_rich.<locals>._like_pattern  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   OR zcs.titlez LIKE ? ESCAPE '\'rJ   s.*zr
                WITH RECURSIVE chain(root_id, cur_id) AS (
                    SELECT s.id, s.id FROM sessions s a5  
                    UNION ALL
                    SELECT c.root_id, child.id
                    FROM chain c
                    JOIN sessions parent ON parent.id = c.cur_id
                    JOIN sessions child ON child.parent_session_id = c.cur_id
                    WHERE parent.end_reason = 'compression'
                      AND json_extract(COALESCE(child.model_config, '{}'), '$._branched_from') IS NULL
                      AND json_extract(COALESCE(child.model_config, '{}'), '$._delegate_from') IS NULL
                      AND COALESCE(child.source, '') != 'tool'
                ),
                chain_max AS (
                    SELECT
                        root_id,
                        MAX(COALESCE(
                            (SELECT MAX(m.timestamp) FROM messages m WHERE m.session_id = cur_id),
                            (SELECT started_at FROM sessions ss WHERE ss.id = cur_id)
                        )) AS effective_last_active
                    FROM chain
                    GROUP BY root_id
                )
                SELECT a!  ,
                    COALESCE(
                        (SELECT SUBSTR(REPLACE(REPLACE(m.content, X'0A', ' '), X'0D', ' '), 1, 63)
                         FROM messages m
                         WHERE m.session_id = s.id AND m.role = 'user' AND m.content IS NOT NULL
                         ORDER BY m.timestamp, m.id LIMIT 1),
                        ''
                    ) AS _preview_raw,
                    COALESCE(
                        (SELECT MAX(m2.timestamp) FROM messages m2 WHERE m2.session_id = s.id),
                        s.started_at
                    ) AS last_active,
                    COALESCE(cm.effective_last_active, s.started_at) AS _effective_last_active
                FROM sessions s
                LEFT JOIN chain_max cm ON cm.root_id = s.id
                z
                ORDER BY _effective_last_active DESC, s.started_at DESC, s.id DESC
                LIMIT ? OFFSET ?
            z
                SELECT a  ,
                    COALESCE(
                        (SELECT SUBSTR(REPLACE(REPLACE(m.content, X'0A', ' '), X'0D', ' '), 1, 63)
                         FROM messages m
                         WHERE m.session_id = s.id AND m.role = 'user' AND m.content IS NOT NULL
                         ORDER BY m.timestamp, m.id LIMIT 1),
                        ''
                    ) AS _preview_raw,
                    COALESCE(
                        (SELECT MAX(m2.timestamp) FROM messages m2 WHERE m2.session_id = s.id),
                        s.started_at
                    ) AS last_active
                FROM sessions s
                zY
                ORDER BY s.started_at DESC
                LIMIT ? OFFSET ?
            N_preview_raw<   ...preview_effective_last_activer  rO   r\   )r  )r\   ended_atr  message_counttool_call_countr~  r  r  r  r  r3   r  r2   _lineage_root_id)r   _LISTABLE_CHILD_SQLr<   rc   extendrB   r5   r   r,   r  r  rL   r  rm  rb  re   rf   r   poprd   r4   r  _get_session_rich_row)-rh  r  r  r=   r  r  r  r  r  r  r  r  r  r  r  where_clausesr  r  clauseclause_params	where_sql	id_needlesearch_needleouter_where	id_paramsfilter_clausesr  compact_needlecompact_sqlsearch_clausecombined_selr  rl   r  rt  r0   rC   rawtext	projectedtip_idtip_rowmergedr   s-                                                r'   list_sessions_richzSessionDB.list_sessions_rich  s   p  	U   !4555  $78H$I$I!S!S!STTT 	"  000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	 ^**,,2244	%+2244::<< B	+ $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@) ). /) )L M) ) )EX f_y0E6?BFF3?J4--///UD    E$ MM5&/***Z 	% 	%Z''v66F??$$D	% 	% 	% 	% 	% 	% 	% 	% 	% 	% 	% 	% 	% 	% 	%  	 	CS		A%%++1133C "3B3x#C2uu2F)!)EE*D111OOA $ 	!,< 	!I ) )55&&-77$$Q'''11!D'::QtW$$$$Q'''44V,4WW $$Q''' a 3 3C
 g~~&-cls-.tW)*  (((( Hs   0M>>NNjob_idc                    d| d}|dd         t          t          |d                   dz             z   }d}| j        5  | j                            |||||f          }|                                }ddd           n# 1 swxY w Y   g }	|D ]}}
t          |
          }|                    dd                                          }|r(|dd	         }|t          |          d	k    rd
ndz   |d<   nd|d<   |	
                    |           ~|	S )u[  List the run sessions produced by a single cron job, newest first.

        Cron runs are flat, independent sessions whose id is
        ``cron_{job_id}_{timestamp}`` (see ``cron/scheduler.run_job``). They are
        never compression roots and never branch, so this deliberately skips the
        ``list_sessions_rich`` recursive compression-chain CTE / leading-wildcard
        ``id_query`` path — that path seeds from *every* ``source='cron'`` row in
        the DB and only filters to one job's runs after the scan, so it scales
        with the whole cron pile (a heavy history makes the desktop run-history
        endpoint time out before it eventually populates).

        Instead this binds to one job with a ``[prefix, prefix_hi)`` range over
        the id (an index range scan, not a ``%...%`` substring), filters
        ``source='cron'``, and orders by ``started_at DESC``. Work scales with
        the requested window, not the total cron history.

        Returns the same enriched row shape as ``list_sessions_rich`` (adds
        ``preview`` + ``last_active``) so callers can reuse it.
        cron_r  Nr   a  
            SELECT s.*,
                COALESCE(
                    (SELECT SUBSTR(REPLACE(REPLACE(m.content, X'0A', ' '), X'0D', ' '), 1, 63)
                     FROM messages m
                     WHERE m.session_id = s.id AND m.role = 'user' AND m.content IS NOT NULL
                     ORDER BY m.timestamp, m.id LIMIT 1),
                    ''
                ) AS _preview_raw,
                COALESCE(
                    (SELECT MAX(m2.timestamp) FROM messages m2 WHERE m2.session_id = s.id),
                    s.started_at
                ) AS last_active
            FROM sessions s
            WHERE s.source = 'cron' AND s.id >= ? AND s.id < ?
            ORDER BY s.started_at DESC, s.id DESC
            LIMIT ? OFFSET ?
        r  r   r  r  r  )chrordrm  rb  re   rf   r   r  r5   rd   r   )rh  r  r  r  rA   	prefix_hir  rl   r  runsr0   rC   r  r  s                 r'   list_cron_job_runszSessionDB.list_cron_job_runs  sj   2 #"""
 3B3K#c&*oo&9":"::	$ Z 	% 	%Z''	5&/QRRF??$$D	% 	% 	% 	% 	% 	% 	% 	% 	% 	% 	% 	% 	% 	% 	% &( 	 	CS		A%%++1133C "3B3x#C2uu2F)!)KKNNNNs    4B  BBc                    |r|                                  nd}d| d}| j        5  | j                            ||f          }|                                }ddd           n# 1 swxY w Y   |sdS t          |          }|                    dd                                          }|r(|dd         }	|	t          |          dk    rdndz   |d	<   nd|d	<   |S )
a%  Fetch a single session with the same enriched columns as
        ``list_sessions_rich`` (preview + last_active). Returns None if the
        session doesn't exist.

        Pass ``compact_rows=True`` to omit the ``system_prompt`` blob (see
        ``list_sessions_rich`` for details).
        r  z
            SELECT ah  ,
                COALESCE(
                    (SELECT SUBSTR(REPLACE(REPLACE(m.content, X'0A', ' '), X'0D', ' '), 1, 63)
                     FROM messages m
                     WHERE m.session_id = s.id AND m.role = 'user' AND m.content IS NOT NULL
                     ORDER BY m.timestamp, m.id LIMIT 1),
                    ''
                ) AS _preview_raw,
                COALESCE(
                    (SELECT MAX(m2.timestamp) FROM messages m2 WHERE m2.session_id = s.id),
                    s.started_at
                ) AS last_active
            FROM sessions s
            WHERE s.id = ?
        Nr  r   r  r  r  )	r  rm  rb  re   r   r   r  r5   rd   )
rh  r  r  r  r  rl   r0   rC   r  r  s
             r'   r  zSessionDB._get_session_rich_row  s=    0<Ft))+++    Z 	$ 	$Z''
}==F//##C	$ 	$ 	$ 	$ 	$ 	$ 	$ 	$ 	$ 	$ 	$ 	$ 	$ 	$ 	$  	4IIeeNB''--// 	ss8DCHHrMM55rBAiLLAiLs   1A##A'*A'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.
        )r+   r,   r   r   r   rN  _CONTENT_JSON_PREFIXr  r  r   r   r  r   s     r'   _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)r+   r,   r   r  r  loadsrd   JSONDecodeErrorr   r   r   r	  s     r'   _decode_contentzSessionDB._decode_contentB  s     gs## 	(:(:3;S(T(T 	z'#c.F*G*G*H*H"IJJJ()4   +    s   -A 2B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_kinddisplay_metadatac                   	
 |rt          j        |          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|                     |          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}|                     |          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 = ?)re   r.   r+   r,   	lastrowid)rh   rl   msg_idr  codex_items_jsoncodex_message_items_jsonr  display_metadata_jsonr  r  message_timestampnum_tool_callsr  r  r  r  reasoning_details_jsonr   r  stored_contentr  r  tool_calls_jsonr  s      r'   r  z%SessionDB.append_message.<locals>._do  s(   \\] " #%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,   r  r  r   r
  r  hasattrrN  r  r   r   r  rb   rd   r  )rh  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    `` ` `````   ``` ``  @@@@@@@@r'   append_messagezSessionDB.append_messageP  sx   Z AQ Z
+; < < <VZ !+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-	 -	 -	 -	 -	 -	 -	 -	 -	 -	 -	 -	 -	 -	 -	 -	 -	 -	 -	 -	 -	 -	 -	 -	 -	^ ""3'''s%   B! !B<;B<AE ,E32E3)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                    rt	          j                  nd |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)re   r
  r   r.   r  r  )rh   r0   r   r  r  r   rh  r  s     r'   r  z?SessionDB.set_latest_matching_message_display_kind.<locals>._do  s    ,,JT4#7#7#@#@A  hjj	 
 {uLLY%l334DNDJ/000$F   4r/   )r  r  )rh  r  r   r   r  r  r  s   `````` r'   (set_latest_matching_message_display_kindz2SessionDB.set_latest_matching_message_display_kind  su      	 	 	5	 	 	 	 	 	 	 	 	 	$ D'',,---r/   r   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          rt          j
        |d                   n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   rK  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  r4   r-  rN  r  r   r   r   r  r  r  r+   r,   r  r  re   r
  r.   rb   rd   rB  )rh  rh   r  r   now_tsinsertedtool_calls_totalr   r   r  r(  ts_valuer  r  r  r*  r%  r&  r,  platform_msg_idr  s                        r'   _insert_message_rowszSessionDB._insert_message_rows  s     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;>77CU;V;V`DJs#56777\`+  < 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                     |                      d f           |                      df                               |           \  }}|                      d||f           d S )N)DELETE FROM messages WHERE session_id = ?GUPDATE sessions SET message_count = 0, tool_call_count = 0 WHERE id = ?GUPDATE sessions SET message_count = ?, tool_call_count = ? WHERE id = ?re   r9  )rh   total_messagestotal_tool_callsactive_clauser   rh  r  s      r'   r  z'SessionDB.replace_messages.<locals>._dox  s    LLKMKK   LLY   04/H/Hj(0 0,N, LLY!1:>    r/   Nr  )rh  r  r   r  r  rC  s   ```  @r'   replace_messageszSessionDB.replace_messages[  s\    6 .9@))b	 	 	 	 	 	 	 	" 	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 1Nr  rh  r  rl   s      r'   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@  )rh   r5  r6  rH  rh  r  s      r'   r  z*SessionDB.archive_and_compact.<locals>._do  sw     LL6  
 *.)B)Bj"4* *&H&
 LLY+Z8   Or/   r  )rh  r  rH  r  s   ``` r'   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 ?)re   r.   r  )rh   rl   r  encodedr  s     r'   r  z2SessionDB.set_latest_user_api_content.<locals>._do  s9    \\%
 #;//WE F ?"r/   )r
  r  )rh  r  r   r  r  rN  s    ` ` @r'   set_latest_user_api_contentz%SessionDB.set_latest_user_api_content  sS    " &&w//		# 		# 		# 		# 		# 		# 		# ""3'''r/   include_inactivec                    |rdnd}d| d}|g}||r |dz  }|                     |dn||g           | j        5  | j                            ||          }|                                }	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|
                    |           |
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  rm  rb  re   rf   r   r  r4   r  r  r  r   r   r   r   )rh  r  rP  r  r  rC  r  r  rl   r  r  r0   r   s                r'   get_messageszSessionDB.get_messages  s   . /E4E++ + + 	 #|&&CMM22E6BCCCZ 	% 	%Z''V44F??$$D	% 	% 	% 	% 	% 	% 	% 	% 	% 	% 	% 	% 	% 	% 	%  
	 
	Cs))CC!%!5!5c)n!E!EIww|$$ ++(,
3|3D(E(EC%%,i8 + + +NN#ijjj(*C%%%+ MM#s#   0A77A;>A;C--5D%$D%r	  around_message_idwindowc                    |dk     rd}| j         5  | j                            d||f                                          }|sg dddcddd           S | j                            d|||dz   f                                          }| j                            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|                    |
           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)rU  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 [])rm  rb  re   r   rf   rb   reversedr   r  r4   r  r  r  r   r   r   r   rB  rd   )rh  r  rT  rU  anchor_existsbefore_rows
after_rowsr  r  r0   r   rW  rX  s                r'   get_messages_aroundzSessionDB.get_messages_around  s   2 A::FZ 	 	 J..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%%%	+
 MM# a[!1!1A!566Z.,
 
 	
s*   7CA#CC
CE%%5FFr   r   r   bookend
keep_roles.c                 `    |dk     rd}                      ||          }|d         }|sg ddg g dS |t          |          fd|D             }n|}|d         d         }	|d         d         }
g }g }|dk    r j        5  d	}g }|4d
                    d |D                       }d| d}t	          |          } j                            d| d||	g||R                                           } j                            d| d||
g||R                                           }t	          t          |                    }ddd           n# 1 swxY w Y   dt          t          t          f         f fd||d         |d         fd|D             fd|D             dS )u)  Return an anchored window plus session bookends.

        Built on top of ``get_messages_around``. Three slices:

          - ``window``: messages immediately surrounding the anchor. Filtered
            to ``keep_roles`` (tool-response noise dropped by default), EXCEPT
            the anchor itself is always preserved regardless of role.
          - ``bookend_start``: first ``bookend`` user/assistant messages of the
            session — but only those whose id is strictly before the window's
            first message id. Empty when the window already overlaps the
            session head. Empty-content messages (tool-call-only assistant
            turns) are skipped so they don't crowd out actual prose openings.
          - ``bookend_end``: last ``bookend`` user/assistant messages of the
            session, same non-overlap rule at the tail.

        Bookends let an FTS5 hit anywhere in a long session yield the goal
        (opening) and the resolution (closing) on a single call — without
        loading the whole transcript.

        Returns ``{"window": [], "messages_before": 0, "messages_after": 0,
        "bookend_start": [], "bookend_end": []}`` when the anchor isn't in
        the session.

        ``keep_roles=None`` disables role filtering (raw window + raw
        bookends).
        r   )rU  rU  )rU  rW  rX  bookend_startbookend_endNc                 t    g | ]4}|                     d           k    s|                     d          v 2|5S )r\   r   r4   )rU   r  rT  keep_sets     r'   r^   z/SessionDB.get_anchored_view.<locals>.<listcomp>  sM       55;;"333quuV}}7P7P 7P7P7Pr/   r\   r   r   rX   c              3      K   | ]}d V  dS r  r;   r  s     r'   r   z.SessionDB.get_anchored_view.<locals>.<genexpr>  s"      0I0I0I0I0I0I0I0Ir/   z AND role IN (rF   z6SELECT * FROM messages WHERE session_id = ? AND id < ?z0 AND length(content) > 0 ORDER BY id ASC LIMIT ?z6SELECT * FROM messages WHERE session_id = ? AND id > ?z1 AND length(content) > 0 ORDER BY id DESC LIMIT ?r   c                 H   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|S )Nr   r  zIFailed to deserialize tool_calls in get_anchored_view, falling back to [])	r   r  r4   r  r  r  r   r   r   )r0   r   rh  s     r'   _hydratez-SessionDB.get_anchored_view.<locals>._hydrate  s    s))CC!%!5!5c)n!E!EIww|$$ ++(,
3|3D(E(EC%%,i8 + + +NNc   )+C%%%	+
 Js   	A' '5BBrW  rX  c                 &    g | ]} |          S r;   r;   rU   r  ri  s     r'   r^   z/SessionDB.get_anchored_view.<locals>.<listcomp>  s!    FFFahhqkkFFFr/   c                 &    g | ]} |          S r;   r;   rk  s     r'   r^   z/SessionDB.get_anchored_view.<locals>.<listcomp>  s!    BBBAHHQKKBBBr/   )r]  ra   rm  rc   rb   rb  re   rf   rY  r	   r,   r   )rh  r  rT  rU  r_  r`  	primitivewindow_rowsfiltered_windowwindow_min_idwindow_max_idbookend_start_rowsbookend_end_rowsrole_clauserole_paramsrole_placeholdersri  rf  s   ` `             @@r'   get_anchored_viewzSessionDB.get_anchored_viewm  s   D Q;;G ,,)& - 
 
	  ) 	#$"#!#!   !:H    &  OO
 *O#At,#B- )+&(Q;; D D $&)(+0I0Ij0I0I0I(I(I%"G3D"G"G"GK"&z"2"2K%)Z%7%7/6A/ / /  FFgFF& & (** # $(:#5#506A0 0 0  FFgFF$ $ (** ! $(1A(B(B#C#C 1D D D D D D D D D D D D D D D4	T#s(^ 	 	 	 	 	 	 &():;'(89FFFF3EFFFBBBB1ABBB
 
 	
s   CEE!E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   )	r  r   rm  r  rb  re   r   r-  r   )
rh  r  tipr   r  bestr  r0   	child_rowr  s
             r'   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_alternationc                 r   |g}|r|                      |          }|rdnd}| j        5  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;  rX   c              3      K   | ]}d V  dS r  r;   r  s     r'   r   z9SessionDB.get_messages_as_conversation.<locals>.<genexpr>]  "      #=#=AC#=#=#=#=#=#=r/   a4  SELECT role, content, tool_call_id, tool_calls, tool_name, effect_disposition, finish_reason, reasoning, reasoning_content, reasoning_details, codex_reasoning_items, codex_message_items, platform_message_id, observed, timestamp, api_content, display_kind, display_metadata FROM messages WHERE session_id IN (rF   rR  Nr  r  r  )_session_lineage_root_to_tiprm  rc   rb  re   ri  rf   _rows_to_conversation)	rh  r  r  rP  r  session_idsrC  r  r  s	            r'   get_messages_as_conversationz&SessionDB.get_messages_as_conversation>  s9   2 "l 	H;;JGGK.E4EZ 	 	88#=#=#=#=#===L:%%/ 7C	/ / !/ / / k""   hjj! 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	( ))!/1	 * 
 
 	
s   A!BBBa	  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_metadatac                B   g }|D ]U}|                      |d                   }|d         dv r6t          |t                    r!t          |                                          }|d         |d}|d         r|d         |d<   |d         r|d         |d<   |d         rU	 t          j        |d                   |d<   n6# t          t
          j        f$ r t          
                    d           Y nw xY w|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@|                    |           Wt          |          }|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  r  r  z0Ignoring invalid display metadata on message rowr  r  r  r  r  zKFailed to deserialize tool_calls in conversation replay, falling back to []r  r3  r  Tr   r  r  r  Nr  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  r+   r,   r   r5   r  r  r   r  r   r   #_is_duplicate_replayed_user_messager   r   agent.agent_runtime_helpersr  r   )rh  r  r  r  r  r   r0   r   r   r  r%  s              r'   r  zSessionDB._rows_to_conversation  s     I	! I	!C**3y>::G6{333
7C8P8P3*73399;;v;7;;C =! 8%(%7M">" :&).&9N#%& WW.2j=O9P.Q.QC*++!4#78 W W WNN#UVVVVVW; 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    sZ   B;;0C.-C.E$$5FFH,,5I$#I$0J5KKK005L('L(c                    |                                }| j        5  d                    d |D                       }| j                            d| j         d| dt          |                                                    }ddd           n# 1 swxY w Y   f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).
        rX   c              3      K   | ]}d V  dS r  r;   r  s     r'   r   z5SessionDB.get_resume_conversations.<locals>.<genexpr>  r  r/   SELECT session_id, $ FROM messages WHERE session_id IN () AND active = 1 ORDER BY idNc                 ,    g | ]}|d          k    |S r  r;   rU   r  r  s     r'   r^   z6SessionDB.get_resume_conversations.<locals>.<listcomp>  s'    EEE!q*'D'DA'D'D'Dr/   FTr  )	r  rm  rc   rb  re   _CONVERSATION_ROW_COLUMNSri  rf   r  )rh  r  r  r  r  tip_rowsmodel_historydisplay_historys    `      r'   get_resume_conversationsz"SessionDB.get_resume_conversations  sd   & 77
CCZ 		 		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 | j        5  d                    d |D                       }| j                            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   rX   c              3      K   | ]}d V  dS r  r;   r  s     r'   r   z8SessionDB.get_ancestor_display_prefix.<locals>.<genexpr>@  r  r/   r  r  r  Nc                 ,    g | ]}|d          k    |S r  r;   r  s     r'   r^   z9SessionDB.get_ancestor_display_prefix.<locals>.<listcomp>G  s'    JJJqAlOz,I,I,I,I,Ir/   TFr  )
r  rd   rm  rc   rb  re   r  ri  rf   r  )rh  r  r  r  r  ancestor_rowss    `    r'   get_ancestor_display_prefixz%SessionDB.get_ancestor_display_prefix(  sW   ( 77
CC{q  IZ 	 	88#=#=#=#=#===L:%%d&D  6B   k""	 
 hjj 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 KJJJDJJJ 	I))!"$	 * 
 
 	
s   A&B%%B),B)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   )r  )rh  r  chains      r'   get_conversation_rootzSessionDB.get_conversation_rootQ  s3     11*==!>eAh>aJ?r/   c                    |s|gS g }|}t                      }| j        5  t          d          D ]}|r||v r n}|                    |           |                    |           | j                            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 = ?rz  r  r   )ra   rm  r  r   r   rb  re   r   r-  rb   rY  )rh  r  r  r   r  r  r0   s          r'   r  z&SessionDB._session_lineage_root_to_tip_  sa    	 <uuZ 	W 	W3ZZ W W 'T//E!!!W%%%j((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  )r4   r+   r,   rY  )r   r   r   prevs       r'   r  z-SessionDB._is_duplicate_replayed_user_messageu  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=rF   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 r  r;   r
  s     r'   r^   z<SessionDB.rewind_to_message.<locals>._do.<locals>.<listcomp>      333A1Q4333r/   rX   c              3      K   | ]}d V  dS r  r;   r  s     r'   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 (rF   zMUPDATE sessions SET rewind_count = COALESCE(rewind_count, 0) + 1 WHERE id = ?)re   rf   rc   )rh   rl   rr   r  r  r  s       r'   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)
rm  rb  re   r   r   r   r4   r  r  rd   )	rh  r  r  r0   
target_rowrewoundr  head_rowr  s	    ``      r'   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 r  r;   r
  s     r'   r^   z:SessionDB.restore_rewound.<locals>._do.<locals>.<listcomp>  r  r/   rX   c              3      K   | ]}d V  dS r  r;   r  s     r'   r   z9SessionDB.restore_rewound.<locals>._do.<locals>.<genexpr>  r  r/   z,UPDATE messages SET active = 1 WHERE id IN (rF   ri  )rh   rl   rr   r  r  r  s       r'   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  )rh  r  r  r  s    `` r'   restore_rewoundzSessionDB.restore_rewound  s8    	 	 	 	 	 	 ""3'''r/   c                    |rdnd}| j         5  | j                            d| d|t          |          f          }|                                }ddd           n# 1 swxY w Y   g }|D ]}|                     |d                   }	t          |	t                    rBd |	D             }
d                    d	 |
D                       	                                }|sd
}nt          |	t                    r|	}nd}d                    |                                          }t          |          dk    r|dd         dz   }|                    |d         |d         |d           |S )ao  Return the *limit* most-recent user messages, newest first.

        Each entry is a dict with keys ``id``, ``timestamp``, ``preview``.
        ``preview`` is the first 80 characters of the message content
        (with line breaks collapsed to spaces). Used by the /rewind
        slash command picker.

        By default only active messages are returned.
        r   r;  zRSELECT id, timestamp, content FROM messages WHERE session_id = ? AND role = 'user'z ORDER BY id DESC LIMIT ?Nr   c                     g | ]F}t          |t                    r/|                    d           dk    0|                    dd          GS rv  r  r   r+   r   r4   rU   r   s     r'   r^   z7SessionDB.list_recent_user_messages.<locals>.<listcomp>  sW       *+!!T**/0uuV}}/F/F EE&"%%/F/F/Fr/   rY  c              3      K   | ]}||V  	d S r   r;   rU   r  s     r'   r   z6SessionDB.list_recent_user_messages.<locals>.<genexpr>  s'      ">">A">1">">">">">">r/   [multimodal content]P   M   r  r\   r  )r\   r  r  )rm  rb  re   r   rf   r  r+   rb   rc   r5   r,   splitrd   r   )rh  r  r  rP  rC  rl   r  r  r0   decoded
text_partsr  s               r'   list_recent_user_messagesz#SessionDB.list_recent_user_messages  s    /E4EZ 	% 	%Z''+ + + + SZZ( F ??$$D	% 	% 	% 	% 	% 	% 	% 	% 	% 	% 	% 	% 	% 	% 	% (* 	 	C**3y>::G'4((  /6  
 ((">">j">">">>>DDFF 54GGS)) !hhw}}//G7||b  !#2#,.MMd)!$[!1&     s   AAA!$A!r  c                    | dt                    } g }g }d}|t          |           k     r| |         }|dk    r|                    |           |dz  }<|                     d|dz             }|dk    r|                    d           |dz  }v|                    | ||dz                       |                    dt          |          dz
   d           |dz   }|t          |           k     d	                    |          }t          j        d
d|          }t          j        dd|          }t          j        dd|          }t          j        dd	|                                          }t          j        dd	|                                          }t          j        dd|          }t          |          D ]\  }}|	                    d| d|          } |                                S )a  Sanitize user input for safe use in FTS5 MATCH queries.

        FTS5 has its own query syntax where characters like ``"``, ``(``, ``)``,
        ``+``, ``*``, ``{``, ``}``, the column-filter operator ``:`` and bare
        boolean operators (``AND``, ``OR``, ``NOT``) have special meaning.
        Passing raw user input directly to MATCH can cause
        ``sqlite3.OperationalError``.

        Strategy:
        - Preserve properly paired quoted phrases (``"exact phrase"``)
        - Strip unmatched FTS5-special characters that would cause errors
        - Wrap unquoted hyphenated and dotted terms in quotes so FTS5
          matches them as exact phrases instead of splitting on the
          hyphen/dot (e.g. ``chat-send``, ``P2.2``, ``my-app.config.ts``)
        Nr   re  r   r   rY  z Q r   z[+{}():\"^]z\*+*z(^|\s)\*z\1z(?i)^(AND|OR|NOT)\b\s*z(?i)\s+(AND|OR|NOT)\s*$z\b(\w+(?:[._-]\w+)+)\bz"\1")
MAX_FTS5_QUERY_CHARSrd   r   findrc   r  r  r5   	enumerater  )r  _quoted_partspiecesichend	sanitizedquoteds           r'   _sanitize_fts5_queryzSessionDB._sanitize_fts5_query0  s   ( +++, !#e**nnqBSyyb!!!Q**S!a%((Cbyy c"""Q  qqy!1222MM>#m"4"4q"8>>>???aA #e**nn" GGFOO	 F>3	::	 F63	22	F;y99	 F4b)//:K:KLL	F5r9??;L;LMM	 F4gyII	 #=11 	C 	CIAv!))/!///6BBII   r/   cpc                     d| cxk    odk    nc p_d| cxk    odk    nc pOd| cxk    odk    nc p?d| cxk    odk    nc p/d	| cxk    od
k    nc pd| cxk    odk    nc pd| cxk    odk    nc S )N N     4  M     ߦ  0  ?0  @0  0  0  0       r;   )r  s    r'   _is_cjk_codepointzSessionDB._is_cjk_codepoint  s   "&&&&&&&& '"&&&&&&&&'2((((((((' "&&&&&&&&' "&&&&&&&&	'
 "&&&&&&&&' "&&&&&&&&	(r/   r  c                    | D ]~}t          |          }d|cxk    rdk    s]n d|cxk    rdk    sNn d|cxk    rdk    s?n d|cxk    rdk    s0n d	|cxk    rd
k    s!n d|cxk    rdk    sn d|cxk    rdk    rn { dS dS )zBCheck if text contains CJK (Chinese, Japanese, Korean) characters.r  r  r  r  r  r  r  r  r  r  r  r  r  r  TF)r  )r  r  r  s      r'   _contains_cjkzSessionDB._contains_cjk  s     		 		BRB"&&&&&&&&"&&&&&&&&2(((((((("&&&&&&&&"&&&&&&&&"&&&&&&&&"&&&&&&&&&tt 'ur/   c                 :     t           fd|D                       S )zCount CJK characters in text.c              3   `   K   | ](}                     t          |                    $d V  )dS )r   Nr  r  )rU   r  r  s     r'   r   z'SessionDB._count_cjk.<locals>.<genexpr>  s<      FFs'<'<SWW'E'EF1FFFFFFr/   )sum)r  r  s   ` r'   
_count_cjkzSessionDB._count_cjk  s(     FFFFtFFFFFFr/   c                     d}|D ]5}|                      t          |                    r|dz  }*|dk    r dS d}6|dk    S )u&  True when any maximal CJK run in the query is a single char.

        The cjk-bigram index stores bigrams for runs >=2 chars and unigrams
        only for isolated chars, so a 1-char CJK term can't match inside
        longer runs there — those queries keep the LIKE substring route.
        r   r   Tr  )r  r  runr  s       r'   _has_lone_cjk_runzSessionDB._has_lone_cjk_run  s_      	 	B$$SWW-- q!8844axr/   c                     d |                      d                                                                           D             }t          |          ot          d |D                       S )a  True when every non-operator token is long enough for the trigram
        tokenizer to match (>=3 chars).

        The trigram tokenizer indexes overlapping 3-character sequences, so a
        token shorter than 3 chars produces no trigrams and can never match.
        With FTS5's implicit-AND between tokens, a single short token makes the
        whole MATCH return nothing, so the trigram path is only worth taking
        when every searchable token qualifies.
        c                 >    g | ]}|                                 d v|S >   ORANDNOTr  r  s     r'   r^   z6SessionDB._trigram_eligible_tokens.<locals>.<listcomp>  s6     
 
 
wwyy 444 444r/   re  c              3   <   K   | ]}t          |          d k    V  dS r   N)rd   r  s     r'   r   z5SessionDB._trigram_eligible_tokens.<locals>.<genexpr>  s,      #@#@ACFFaK#@#@#@#@#@#@r/   )r5   r  r  all)r  tokenss     r'   _trigram_eligible_tokensz"SessionDB._trigram_eligible_tokens  sn    
 
{{3''--//5577
 
 
 F||@#@#@#@#@#@ @ @@r/   r  )tablesource_filterr  role_filterr  r  	raw_queryr  order_by_sqlr  r  c                   |                                 }
g }|
D ]]}|                                dv r|                    |           .|                    d|                    dd          z   dz              ^d                    |          }| dg}|g}|s|                    d           |K|                    dd	                    d
 |D                        d           |                    |           |K|                    dd	                    d |D                        d           |                    |           |rK|                    dd	                    d |D                        d           |                    |           d| d| d| dd                    |           d| d}|                    ||	g           | j        5  	 | j                            ||          }n!# t          j
        $ r Y ddd           dS w xY wd |                                D             cddd           S # 1 swxY w Y   dS )u  Run a search against a substring-capable FTS index.

        ``table`` is ``messages_fts_trigram`` (default) or
        ``messages_fts_cjk``. The trigram tokenizer indexes overlapping
        3-byte sequences, so it matches substrings regardless of word
        boundaries — both CJK phrases the unicode61 tokenizer splits into
        single characters and Latin runs the unicode61 tokenizer fuses onto
        adjacent CJK (e.g. ``修改youer服务端``). The cjk-bigram tokenizer
        splits Latin runs off adjacent CJK, giving the same recovery as an
        exact ranked token match. Each non-operator token is quoted to
        neutralise FTS5 special characters while boolean operators
        (AND/OR/NOT) are preserved.

        Returns the matching rows, or ``None`` when the query cannot be
        executed (e.g. the tokenizer is unavailable at runtime) so the
        caller can fall back to another strategy.
        >   r  r  r  re  rf  rY  z MATCH ?!(m.active = 1 OR m.compacted = 1)Ns.source IN (rX   c              3      K   | ]}d V  dS r  r;   r  s     r'   r   z0SessionDB._run_trigram_search.<locals>.<genexpr>  s"      5Q5Qac5Q5Q5Q5Q5Q5Qr/   rF   r  c              3      K   | ]}d V  dS r  r;   r  s     r'   r   z0SessionDB._run_trigram_search.<locals>.<genexpr>  s"      9W9W!#9W9W9W9W9W9Wr/   m.role IN (c              3      K   | ]}d V  dS r  r;   r  s     r'   r   z0SessionDB._run_trigram_search.<locals>.<genexpr>  s"      3M3MAC3M3M3M3M3M3Mr/   zx
            SELECT
                m.id,
                m.session_id,
                m.role,
                snippet(z, -1, '>>>', '<<<', '...', 40) AS snippet,
                m.content,
                m.timestamp,
                m.tool_name,
                s.source,
                s.model,
                s.started_at AS session_started
            FROM z'
            JOIN messages m ON m.id = zL.rowid
            JOIN sessions s ON s.id = m.session_id
            WHERE r  
            &
            LIMIT ? OFFSET ?
        c                 ,    g | ]}t          |          S r;   r  rz  s     r'   r^   z1SessionDB._run_trigram_search.<locals>.<listcomp>  s    ???#DII???r/   )r  r  r   r  rc   r  rm  rb  re   r   r   rf   )rh  r   r  r  rP  r  r  r  r  r  r  rb  toktrigram_query	tri_where
tri_paramstri_sql
tri_cursors                     r'   _run_trigram_searchzSessionDB._run_trigram_search  sQ   < "" 	A 	ACyy{{222S!!!!S3;;sD#9#99C?@@@@'''(	)?
 	B@AAA$TSXX5Q5Q=5Q5Q5Q-Q-QTTTUUUm,,,&Z9W9W9W9W9W1W1WZZZ[[[o... 	+P3883M3M3M3M3M+M+MPPPQQQk***
     (-  <<	**   !  & 	5&/***Z 	@ 	@!Z//DD

+   	@ 	@ 	@ 	@ 	@ 	@ 	@ 	@ @?)<)<)>)>???	@ 	@ 	@ 	@ 	@ 	@ 	@ 	@ 	@ 	@ 	@ 	@ 	@ 	@ 	@ 	@ 	@ 	@s6   H?G43H?4HH?H H??IIsortc	                    t          j                     }	d}
	 |                     ||||||||          }
|
	 t          t          j        dd                    }n# t
          t          f$ r d}Y nw xY wt          j                     |	z
  dz  }||k    rKt                              d| 	                    |          ||
t          |
          nd|dd                    S S # 	 t          t          j        dd                    }n# t
          t          f$ r d}Y nw xY wt          j                     |	z
  dz  }||k    rKt                              d| 	                    |          ||
t          |
          nd|dd                    w w xY w)	a  Instrumented wrapper around :meth:`_search_messages_impl`.

        Logs one line per slow search with the routing path taken, so
        production latency stays attributable per query shape (the 2026-07
        session_search investigation needed trace archaeology to discover
        the LIKE full scans; this makes the next regression a grep).
        Threshold: HERMES_SEARCH_SLOW_MS (default 1000; 0 logs every call).
        N)r  r  r  r  r  r  rP  HERMES_SEARCH_SLOW_MS1000g     @@z<slow session search: path=%s elapsed=%.0fms rows=%s query=%rr}     )r  _search_messages_implrN  r   rC  r   r   r   r   _describe_search_pathrd   )rh  r  r  r  r  r  r  r  rP  startedr  	threshold
elapsed_mss                r'   search_messageszSessionDB.search_messages  s   & )++	--+ /'!1 . 	 	D #!"),CV"L"LMM		z* # # #"			#)++/69JY&&R..u55!%!1CIIIu$3$K    '#!"),CV"L"LMM		z* # # #"			#)++/69JY&&R..u55!%!1CIIIu$3$K    'sA   C "AA/.A/F"DFDFDA.Fc                     	                       |pd          }|sdS                      |          sdS |                    d                                          } j        r                     |          sdS  fd|                                D             }t           fd|D                       }                     |          dk    r|s	 j        rd	S d
S # t          $ r Y dS w xY w)z>Best-effort name of the routing path a query takes (log-only).r   emptyrz  re  fts_cjkc                 j    g | ]/}|                                 d v                    |          -|0S r  r  r  rU   r  rh  s     r'   r^   z3SessionDB._describe_search_path.<locals>.<listcomp>K  sK       7799$888T=O=OPQ=R=R8 888r/   c              3   J   K   | ]}                     |          d k     V  dS r  r  r#  s     r'   r   z2SessionDB._describe_search_path.<locals>.<genexpr>O  s4      ??1**Q.??????r/   r   trigram	like_scanrK  )
r  r  r5   rr  r  r  r   r  rq  r   )rh  r  r  r  r  shorts   `     r'   r  zSessionDB._describe_search_path@  s-   	11%+2>>I w%%i00 v//#&&,,..C& !t/E/Ec/J/J ! y   99;;  F ?????????Es##q(((4;R( y; 	 	 	99	s#   C C AC :AC 
C)(C)c	                    >  j         sg S |r|                                sg S                      |          }|sg S t          |t                    r-|                                                                }	|	dvrd}	nd}	|	dk    rd}
n|	dk    rd}
nd}
dg}|g}|s|                    d	           |Md
                    d |D                       }|                    d| d           |                    |           |Md
                    d |D                       }|                    d| d           |                    |           |rMd
                    d |D                       }|                    d| d           |                    |           d                    |          }|                    ||g           d| d|
 d} 	                    |          }|	r-|                    d                                          } 
                    |          } fd|                                D             }t           fd|D                       }d}t          |          od|v } j        r;|s8                     |          s"|                                }g }|D ]]}|                                dv r|                    |           .|                    d|                    dd          z   dz              ^d                    |          }dg}|g}|s|                    d	           |K|                    dd
                    d |D                        d           |                    |           |K|                    dd
                    d  |D                        d           |                    |           |rK|                    dd
                    d! |D                        d           |                    |           d"d                    |           d#|
 d$}|                    ||g           	  j        5   j                            ||          } d% |                                 D             }!d&}ddd           n# 1 swxY w Y   n# t,          j        $ r t0                              d'd&(           Y nt,          j        $ r}"                     |"          r	  j        5   j                            ||          } d) |                                 D             }!d&}ddd           n# 1 swxY w Y   nJ# t,          j        $ r t0                              d*           Y nw xY wt0                              d+|"           Y d}"~"nd}"~"ww xY w|s|d,k    r|s j        r|s|                                }g }|D ]]}|                                dv r|                    |           .|                    d|                    dd          z   dz              ^d                    |          }#d-g}$|#g}%|s|$                    d	           |K|$                    dd
                    d. |D                        d           |%                    |           |K|$                    dd
                    d/ |D                        d           |%                    |           |rK|$                    dd
                    d0 |D                        d           |%                    |           d1d                    |$           d#|
 d$}&|%                    ||g           	  j        5   j                            |&|%          }'d2 |'                                D             }!d&}ddd           n# 1 swxY w Y   n# t,          j        $ r Y nt,          j        $ r}"                     |"          r	  j        5   j                            |&|%          }'d3 |'                                D             }!d&}ddd           n# 1 swxY w Y   nJ# t,          j        $ r t0                              d4           Y nw xY wt0                              d5|"           Y d}"~"nd}"~"ww xY w|s=d6 |                                D             p|g}(g })g }*|(D ]i}|                    d7d8                              d9d:                              d;d<          }+|)                    d=           |*d9|+ d9d9|+ d9d9|+ d9gz  }*jd>d?                    |)           dg},|s|,                    d	           |K|,                    dd
                    d@ |D                        d           |*                    |           |K|,                    dd
                    dA |D                        d           |*                    |           |rK|,                    dd
                    dB |D                        d           |*                    |           dCd                    |,           dD}-|*                    ||g           |(dE         g|*z   }* j        5   j                            |-|*          }.dF |.                                D             }!ddd           n# 1 swxY w Y   n	  j        5   j                            ||          }/dG |/                                D             }!ddd           n# 1 swxY w Y   n# t,          j        $ r g cY S t,          j        $ rx}"                     |"          s  j        5   j                            ||          }/dH |/                                D             }!ddd           n# 1 swxY w Y   Y d}"~"nd}"~"ww xY w                                 }0|0t?          |!          |k     r	                       ||t?          |!          z
  ||||I          }1dJ |!D             >|!                    >fdK|1D                        n7# t,          j        $ r%}"t0                              dL|"           Y d}"~"nd}"~"ww xY w|!s|st          |          rd|v s|                    d                                          }2 j        r" !                    |2dM|
||||||N	  	        }3|3r|3}!|!s= j        r6 "                    |          r! !                    |2|
||||||O          }4|4r|4}!|!D ]5}5	  j        5   j                            dP|5dQ         |5dQ         f          }6g }7|6                                D ]}8|8dR         }9 #                    |9          }:t          |:tH                    rBdS |:D             };d                    dT |;D                                                       }<|<pdU}=nt          |:t                    r|:}=ndV}=|7                    |8dW         |=ddX         dY           	 ddd           n# 1 swxY w Y   |7|5dZ<   !# tJ          $ r	 g |5dZ<   Y 3w xY w|!D ]}5|5&                    dRd           |!S )[u?  
        Full-text search across session messages using FTS5.

        Supports FTS5 query syntax:
          - Simple keywords: "docker deployment"
          - Phrases: '"exact phrase"'
          - Boolean: "docker OR kubernetes", "python NOT java"
          - Prefix: "deploy*"

        Returns matching messages with session metadata, content snippet,
        and surrounding context (1 message before and after the match).

        ``sort`` controls temporal ordering:
          - ``None`` (default): FTS5 BM25 relevance only. Time-neutral.
          - ``"newest"``: order by message timestamp DESC, then by rank.
          - ``"oldest"``: order by message timestamp ASC, then by rank.

        The short-CJK LIKE fallback already orders by timestamp DESC and
        ignores ``sort``. The trigram CJK path honours ``sort`` like the main
        FTS5 path.

        Rewound (``active=0``, ``compacted=0``) rows are excluded by default —
        the user took those back. Compaction-archived rows (``active=0``,
        ``compacted=1``) ARE included by default: they were summarized away from
        the live context but remain part of the conversation's record, so the
        pre-compaction transcript stays discoverable after in-place compaction
        (#38763). Pass ``include_inactive=True`` to search every row regardless.
        )newestoldestNr*  zORDER BY m.timestamp DESC, rankr+  zORDER BY m.timestamp ASC, rankzORDER BY rankzmessages_fts MATCH ?r  rX   c              3      K   | ]}d V  dS r  r;   r  s     r'   r   z2SessionDB._search_messages_impl.<locals>.<genexpr>  s"      *F*F13*F*F*F*F*F*Fr/   r  rF   c              3      K   | ]}d V  dS r  r;   r  s     r'   r   z2SessionDB._search_messages_impl.<locals>.<genexpr>  s"      +I+IAC+I+I+I+I+I+Ir/   r  c              3      K   | ]}d V  dS r  r;   r  s     r'   r   z2SessionDB._search_messages_impl.<locals>.<genexpr>  s"      (B(B(B(B(B(B(B(Br/   r  r  a  
            SELECT
                m.id,
                m.session_id,
                m.role,
                snippet(messages_fts, -1, '>>>', '<<<', '...', 40) AS snippet,
                m.content,
                m.timestamp,
                m.tool_name,
                s.source,
                s.model,
                s.started_at AS session_started
            FROM messages_fts
            JOIN messages m ON m.id = messages_fts.rowid
            JOIN sessions s ON s.id = m.session_id
            WHERE r	  r
  re  c                 j    g | ]/}|                                 d v                    |          -|0S r  r"  r#  s     r'   r^   z3SessionDB._search_messages_impl.<locals>.<listcomp>  sK     ! ! !7799$888T=O=OPQ=R=R8 888r/   c              3   J   K   | ]}                     |          d k     V  dS r  r%  r#  s     r'   r   z2SessionDB._search_messages_impl.<locals>.<genexpr>  sE       ! !+,""Q&! ! ! ! ! !r/   Ftool>   r  r  r  rf  rY  zmessages_fts_cjk MATCH ?c              3      K   | ]}d V  dS r  r;   r  s     r'   r   z2SessionDB._search_messages_impl.<locals>.<genexpr>  "      =Y=Yac=Y=Y=Y=Y=Y=Yr/   c              3      K   | ]}d V  dS r  r;   r  s     r'   r   z2SessionDB._search_messages_impl.<locals>.<genexpr>	  "      A_A_!#A_A_A_A_A_A_r/   c              3      K   | ]}d V  dS r  r;   r  s     r'   r   z2SessionDB._search_messages_impl.<locals>.<genexpr>  "      ;U;UAC;U;U;U;U;U;Ur/   a  
                    SELECT
                        m.id,
                        m.session_id,
                        m.role,
                        snippet(messages_fts_cjk, -1, '>>>', '<<<', '...', 40) AS snippet,
                        m.content,
                        m.timestamp,
                        m.tool_name,
                        s.source,
                        s.model,
                        s.started_at AS session_started
                    FROM messages_fts_cjk
                    JOIN messages m ON m.id = messages_fts_cjk.rowid
                    JOIN sessions s ON s.id = m.session_id
                    WHERE z
                    z6
                    LIMIT ? OFFSET ?
                c                 ,    g | ]}t          |          S r;   r  rz  s     r'   r^   z3SessionDB._search_messages_impl.<locals>.<listcomp>%      "N"N"N499"N"N"Nr/   Tz;messages_fts_cjk query failed; falling back to trigram/LIKErP  c                 ,    g | ]}t          |          S r;   r  rz  s     r'   r^   z3SessionDB._search_messages_impl.<locals>.<listcomp>7  +     +" +" +"25DII+" +" +"r/   zYCJK-bigram FTS search still failing after in-place rebuild; falling back to trigram/LIKE.zuCJK-bigram FTS search hit a corruption error (%s) and no in-place rebuild was possible; falling back to trigram/LIKE.r   zmessages_fts_trigram MATCH ?c              3      K   | ]}d V  dS r  r;   r  s     r'   r   z2SessionDB._search_messages_impl.<locals>.<genexpr>_  r3  r/   c              3      K   | ]}d V  dS r  r;   r  s     r'   r   z2SessionDB._search_messages_impl.<locals>.<genexpr>b  r5  r/   c              3      K   | ]}d V  dS r  r;   r  s     r'   r   z2SessionDB._search_messages_impl.<locals>.<genexpr>e  r7  r/   a  
                    SELECT
                        m.id,
                        m.session_id,
                        m.role,
                        snippet(messages_fts_trigram, -1, '>>>', '<<<', '...', 40) AS snippet,
                        m.content,
                        m.timestamp,
                        m.tool_name,
                        s.source,
                        s.model,
                        s.started_at AS session_started
                    FROM messages_fts_trigram
                    JOIN messages m ON m.id = messages_fts_trigram.rowid
                    JOIN sessions s ON s.id = m.session_id
                    WHERE c                 ,    g | ]}t          |          S r;   r  rz  s     r'   r^   z3SessionDB._search_messages_impl.<locals>.<listcomp>~  r9  r/   c                 ,    g | ]}t          |          S r;   r  rz  s     r'   r^   z3SessionDB._search_messages_impl.<locals>.<listcomp>  r;  r/   zNTrigram FTS search still failing after in-place rebuild; falling back to LIKE.zjTrigram FTS search hit a corruption error (%s) and no in-place rebuild was possible; falling back to LIKE.c                 >    g | ]}|                                 d v|S r  r  r  s     r'   r^   z3SessionDB._search_messages_impl.<locals>.<listcomp>  s6     ! ! !wwyy(<<< <<<r/   rw  rx  r  r?   r  r  `(m.content LIKE ? ESCAPE '\' OR m.tool_name LIKE ? ESCAPE '\' OR m.tool_calls LIKE ? ESCAPE '\')rI   r  c              3      K   | ]}d V  dS r  r;   r  s     r'   r   z2SessionDB._search_messages_impl.<locals>.<genexpr>  s"      >Z>Zqs>Z>Z>Z>Z>Z>Zr/   c              3      K   | ]}d V  dS r  r;   r  s     r'   r   z2SessionDB._search_messages_impl.<locals>.<genexpr>  s"      B`B`13B`B`B`B`B`B`r/   c              3      K   | ]}d V  dS r  r;   r  s     r'   r   z2SessionDB._search_messages_impl.<locals>.<genexpr>  s"      <V<VQS<V<V<V<V<V<Vr/   a  
                    SELECT m.id, m.session_id, m.role,
                           substr(m.content,
                                  max(1, instr(m.content, ?) - 40),
                                  120) AS snippet,
                           m.content, m.timestamp, m.tool_name,
                           s.source, s.model, s.started_at AS session_started
                    FROM messages m
                    JOIN sessions s ON s.id = m.session_id
                    WHERE zd
                    ORDER BY m.timestamp DESC
                    LIMIT ? OFFSET ?
                r   c                 ,    g | ]}t          |          S r;   r  rz  s     r'   r^   z3SessionDB._search_messages_impl.<locals>.<listcomp>  s    KKKStCyyKKKr/   c                 ,    g | ]}t          |          S r;   r  rz  s     r'   r^   z3SessionDB._search_messages_impl.<locals>.<listcomp>      FFFStCyyFFFr/   c                 ,    g | ]}t          |          S r;   r  rz  s     r'   r^   z3SessionDB._search_messages_impl.<locals>.<listcomp>  rH  r/   rP  r  r  r  c                     h | ]
}|d          S r[   r;   )rU   r  s     r'   rW   z2SessionDB._search_messages_impl.<locals>.<setcomp>  s    555AdG555r/   c              3   0   K   | ]}|d          v|V  dS )r\   Nr;   )rU   r  seen_idss     r'   r   z2SessionDB._search_messages_impl.<locals>.<genexpr>  s1      QQQ49P9Pq9P9P9P9PQQr/   z$Unindexed-gap supplement skipped: %sr  )r  r  rP  r  r  r  r  r  )r  rP  r  r  r  r  r  a  WITH target AS (
                               SELECT session_id, timestamp, id
                               FROM messages
                               WHERE id = ?
                           )
                           SELECT role, content
                           FROM (
                               SELECT m.id, m.timestamp, m.role, m.content
                               FROM messages m
                               JOIN target t ON t.session_id = m.session_id
                               WHERE (m.timestamp < t.timestamp)
                                  OR (m.timestamp = t.timestamp AND m.id < t.id)
                               ORDER BY m.timestamp DESC, m.id DESC
                               LIMIT 1
                           )
                           UNION ALL
                           SELECT role, content
                           FROM messages
                           WHERE id = ?
                           UNION ALL
                           SELECT role, content
                           FROM (
                               SELECT m.id, m.timestamp, m.role, m.content
                               FROM messages m
                               JOIN target t ON t.session_id = m.session_id
                               WHERE (m.timestamp > t.timestamp)
                                  OR (m.timestamp = t.timestamp AND m.id > t.id)
                               ORDER BY m.timestamp ASC, m.id ASC
                               LIMIT 1
                           )r\   r   c                     g | ]F}t          |t                    r/|                    d           dk    0|                    dd          GS r  r  r  s     r'   r^   z3SessionDB._search_messages_impl.<locals>.<listcomp>j  sW     * * *67#-a#6#6*;<55==F;R;R !"fb 1 1;R;R;Rr/   c              3      K   | ]}||V  	d S r   r;   r  s     r'   r   z2SessionDB._search_messages_impl.<locals>.<genexpr>n  s'      +G+G!Q+GA+G+G+G+G+G+Gr/   r  r   r   r  r  context)'rp  r5   r  r+   r,   r   r   rc   r  r  r  r  r   r  rr  r  r  r  rm  rb  re   rf   r   r   r   r  r   r  r   rq  r  rd   _search_unindexed_gapr  r  r  rb   r   r  )?rh  r  r  r  r  r  r  r  rP  	sort_normr  r  r  source_placeholdersexclude_placeholdersrv  r  r  is_cjkr   	cjk_count_tokens_for_check_any_short_cjk_trigram_succeeded_wants_tool_rowsr  rb  r  	cjk_query	cjk_where
cjk_paramscjk_sql
cjk_cursorr|  r   r  r  r  r  r  non_op_tokenstoken_clauseslike_paramsesc
like_wherelike_sqllike_cursorrl   rebuild_statusgap_matches	_fb_querycjk_fbtri_matchesr%   
ctx_cursorcontext_msgsr  r  r  r  r  r  rM  s?   `                                                             @r'   r  zSessionDB._search_messages_implV  s   N   	I 	EKKMM 	I))%00 	I
 dC   	

**,,I 444 	I   <LL("";LL*L 00w 	F   !DEEE$"%((*F*F*F*F*F"F"F  !G1D!G!G!GHHHMM-(((&#&88+I+I+I+I+I#I#I   !L5I!L!L!LMMMMM/*** 	' #(B(Bk(B(B(B B B  !C/@!C!C!CDDDMM+&&&LL//	ufo&&&    !  : ##E** Z	GC((..00I	22I! ! ! !$??,,! ! ! ! ! ! ! !0A! ! !  N "'
  $K00JV{5J 'R(R ..y99R
 #**! I ICyy{{&:::S))))S3;;sD+A+A%AC%GHHHHHHUOO	78	$-;
' J$$%HIII ,$$%\SXX=Y=Y==Y=Y=Y5Y5Y%\%\%\]]]%%m444".$$%bA_A_A_A_A_9_9_%b%b%bccc%%o666 3$$%X388;U;U;U;U;U3U3U%X%X%XYYY%%k222 #<<	22   "!  & !!5&/222$ 2 2%)Z%7%7%L%L
"N"N
8K8K8M8M"N"N"N-1*2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 /    LL'15 !      ,    44S99 !% : :-1Z-?-?$+Z." ."
+" +"9C9L9L9N9N+" +" +" 6: 2: : : : : : : : : : : : : : :  '4   "NN!0     <=@  )6 '[NN& #+ # ) # #**! I ICyy{{&:::S))))S3;;sD+A+A%AC%GHHHH #;<	$1?
' J$$%HIII ,$$%\SXX=Y=Y==Y=Y=Y5Y5Y%\%\%\]]]%%m444".$$%bA_A_A_A_A_9_9_%b%b%bccc%%o666 3$$%X388;U;U;U;U;U3U3U%X%X%XYYY%%k222 #<<	22   "!  & !!5&/222( 2 2%)Z%7%7%L%L
"N"N
8K8K8M8M"N"N"N-1*2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 /   D,       44S99 !% : :-1Z-?-?$+Z." ."
+" +"9C9L9L9N9N+" +" +" 6: 2: : : : : : : : : : : : : : :  '4   "NN!J     ,-0  9 B & 3L! !(00! ! ! !!  [  !#$&( H HC++dF33;;CGGOOPSUZ[[C!((}    JJJJ
C


JJJJ#GGKK?&++m"<"<???@
' K %%&IJJJ ,%%&]chh>Z>ZM>Z>Z>Z6Z6Z&]&]&]^^^&&}555".%%&c#((B`B`P_B`B`B`:`:`&c&c&cddd&&777 4%%&YCHH<V<V+<V<V<V4V4V&Y&Y&YZZZ&&{333 #<<
33   ""E6?333,Q/0;>Z L L"&*"4"4X{"K"KKKKK4H4H4J4JKKKGL L L L L L L L L L L L L L LGZ G G!Z//V<<FFFFOO4E4EFFFGG G G G G G G G G G G G G G G +   			( G G G 44S99 Z G G!Z//V<<FFFFOO4E4EFFFGG G G G G G G G G G G G G G GG, 0022%#g,,*>*>J"88CLL(%5"/$3 + 9   65W555QQQQ+QQQQQQQ+ J J JCSIIIIIIIIJ* $	*$	* +&&$	* ,2[+@+@C((..00I& %11,!-%5"/$3 +! 2 
 
  %$G*+* 11%88*
 #66!-%5"/$3 +! 7 	 	  *)G  :	& :	&E9&Z 5 5!%!3!3 < teDk2? "  "JB $&L'0022  	l"&"6"6s";"; &gt44 
)* *;B* * *J $'88+G+Gz+G+G+G#G#G#M#M#O#OD&*&D.DGG'55 )&-GG&(G$++%&vY74C4=II   !G5 5 5 5 5 5 5 5 5 5 5 5 5 5 5l $0i   & & &#%i   &  	' 	'EIIi&&&&s  R8 #<R+R8 +R//R8 2R/3R8 8+V8%V83V3	U$<UU$U	U$U	 U$#V3$)VV3VV33V8_1 <_%_1 %_))_1 ,_)-_1 1ccc&b-<a5)b5a9	9b<a9	=b c)b-*c,b--cc:lllm3 !:m'm3 'm++m3 .m+/m3 3ppp1:o7+p7o;	;p>o;	?pp9Ar s!ss5z<C7z 4z z	zz	zz%$z%rJ  	fts_queryc                @   |                                  }||dk    rg S |d         |d         }	}g }
t          j        d|          D ]j}|                    d                              d                                          }|r|                                dv rU|
                    |           k|
sg S d	g}||	g}|
D ]b}|                    d
d                              dd                              dd          }|                    d           |d| dgdz  z  }c|s|                    d           |K|                    dd                    d |D                        d           |                    |           |K|                    dd                    d |D                        d           |                    |           |rK|                    dd                    d |D                        d           |                    |           dd                    |           d}|
d         g|z   |gz   }| j	        5  | j
                            ||                                          }ddd           n# 1 swxY w Y   d |D             S )u  LIKE-scan the rows the deferred rebuild hasn't indexed yet.

        Only touches ids in (fts_rebuild_progress, fts_rebuild_high_water] —
        a range that shrinks to nothing as the backfill advances. The FTS
        query is degraded to per-token substring terms (AND-joined; quoted
        phrases kept whole), which is deliberately recall-over-precision:
        temporary results beat silently missing ones mid-rebuild.
        Nr   r  r  z"[^"]+"|\S+re  r  >   r  r  r  NEARzm.id > ? AND m.id <= ?rw  rx  r  r?   r  r  rB  r   r  r  rX   c              3      K   | ]}d V  dS r  r;   r  s     r'   r   z2SessionDB._search_unindexed_gap.<locals>.<genexpr>  s"      1M1M!#1M1M1M1M1M1Mr/   rF   r  c              3      K   | ]}d V  dS r  r;   r  s     r'   r   z2SessionDB._search_unindexed_gap.<locals>.<genexpr>  s"      5S5Sac5S5S5S5S5S5Sr/   r  c              3      K   | ]}d V  dS r  r;   r  s     r'   r   z2SessionDB._search_unindexed_gap.<locals>.<genexpr>  s"      /I/I/I/I/I/I/I/Ir/   a  
            SELECT m.id, m.session_id, m.role,
                   substr(m.content,
                          max(1, instr(m.content, ?) - 40),
                          120) AS snippet,
                   m.content, m.timestamp, m.tool_name,
                   s.source, s.model, s.started_at AS session_started
            FROM messages m
            JOIN sessions s ON s.id = m.session_id
            WHERE r  zC
            ORDER BY m.timestamp DESC
            LIMIT ?
        c                 ,    g | ]}t          |          S r;   r  r
  s     r'   r^   z3SessionDB._search_unindexed_gap.<locals>.<listcomp>  r  r/   )r  r  findallr5   r  r   r  rc   r  rm  rb  re   rf   )rh  rn  r  rP  r  r  r  r  r  r  termsraw_tokr  r  r  termrc  r  r  s                      r'   rQ  zSessionDB._search_unindexed_gap  s6   $ ((**>UaZZI%i0&/* z.)<< 	 	G--$$**3//5577C #))++)EEELL 	I)* *- 	' 	'D,,tV,,44S%@@HHeTTCLL6   z3zzzlQ&&FF 	>LL<===$LLP1M1M}1M1M1M)M)MPPPQQQMM-(((&LLVSXX5S5S?5S5S5S-S-SVVVWWWMM/*** 	'LLLsxx/I/I[/I/I/I'I'ILLLMMMMM+&&& <<&&   (f$w.Z 	> 	>:%%c622;;==D	> 	> 	> 	> 	> 	> 	> 	> 	> 	> 	> 	> 	> 	> 	>&&&&&&s   .J		JJc                 v   |pd                                                                 r|dk    rg S |                     t          |dz  |          d|d          }dt          t
          t          f         dt          ffdt          t          |          fd	
          }d |d|         D             S )a  Search surfaced sessions by exact/prefix/substring session id.

        Desktop search uses this alongside FTS message search so users can paste
        a session id from logs, CLI output, or another Hermes surface and jump
        straight to that conversation.  Matching also checks ``_lineage_root_id``
        for projected compression-chain tips, so an old root id still resolves to
        the live continuation row.
        r   r   rX  T)r  r  r  r  r  r0   r   c                 $   t          |                     d          pd          t          |                     d          pd          g}d |D             }t          fd|D                       rdS t          fd|D                       rdS d	S )
Nr\   r   r  c                 :    g | ]}||                                 S r;   r   )rU   r)   s     r'   r^   zBSessionDB.search_sessions_by_id.<locals>.score.<locals>.<listcomp>  s%    BBBEEB%++--BBBr/   c              3   $   K   | ]
}|k    V  d S r   r;   rU   r)   r  s     r'   r   zASessionDB.search_sessions_by_id.<locals>.score.<locals>.<genexpr>  s'      ;;u5F?;;;;;;r/   r   c              3   B   K   | ]}|                               V  d S r   r   r}  s     r'   r   zASessionDB.search_sessions_by_id.<locals>.score.<locals>.<genexpr>  s1      DD5##F++DDDDDDr/   r   r  )r,   r4   r   )r0   rr   r  r  s      r'   scorez.SessionDB.search_sessions_by_id.<locals>.score  s    swwt}}*++S9K1L1L1RPR-S-STCBBSBBBJ;;;;
;;;;; qDDDDDDDDD q1r/   c                 6     | d                   | d         fS Nr   r   r;   )itemr  s    r'   <lambda>z1SessionDB.search_sessions_by_id.<locals>.<lambda>  s    eeDGnnd1g6 r/   )r   c                     g | ]\  }}|S r;   r;   )rU   r  r0   s      r'   r^   z3SessionDB.search_sessions_by_id.<locals>.<listcomp>  s    1113111r/   N)
r5   r   r  rB  r	   r,   r   r   sortedr  )rh  r  r  r  
candidatesrankedr  r  s         @@r'   search_sessions_by_idzSessionDB.search_sessions_by_id  s     +2$$&&,,.. 	!I ,,eai''-!% - 
 

	tCH~ 	# 	 	 	 	 	 	 j!!6666
 
 
 21&%.1111r/   c                    d}| j         5  |r"| j                            | d|||f          }n | j                            | d||f          }d |                                D             cddd           S # 1 swxY w Y   dS )zList sessions, optionally filtered by source.

        Returns rows enriched with a computed ``last_active`` column (latest
        message timestamp for the session, falling back to ``started_at``),
        ordered by most-recently-used first.
        zSELECT s.*, COALESCE(m.last_active, s.started_at) AS last_active FROM sessions s LEFT JOIN (SELECT session_id, MAX(timestamp) AS last_active FROM messages GROUP BY session_id) m ON m.session_id = s.id z[WHERE s.source = ? ORDER BY last_active DESC, s.started_at DESC, s.id DESC LIMIT ? OFFSET ?zHORDER BY last_active DESC, s.started_at DESC, s.id DESC LIMIT ? OFFSET ?c                 ,    g | ]}t          |          S r;   r  rz  s     r'   r^   z-SessionDB.search_sessions.<locals>.<listcomp>      ;;;#DII;;;r/   Nr  )rh  r  r  r  select_with_last_activerl   s         r'   search_sessionszSessionDB.search_sessions  s   * 	  Z 	< 	< ++. _ _ _ UF+	  ++. _ _ _FO 
 <;):):;;;	< 	< 	< 	< 	< 	< 	< 	< 	< 	< 	< 	< 	< 	< 	< 	< 	< 	<s   A"A99A= A=exclude_childrenc                    g }g }	|r?|                     t                     |                     t          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}| 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).
        r  r  r  rX   c              3      K   | ]}d V  dS r  r;   r  s     r'   r   z*SessionDB.session_count.<locals>.<genexpr>F  r  r/   r  rF   r   r  r  r  r  r  r   zSELECT COUNT(*) FROM sessions sN)
r   r  r<   rc   r  rB   rm  rb  re   r   )rh  r  r=   r  r  r  r  r  r  r  r  r  r  r  rl   s                  r'   session_countzSessionDB.session_count!  sQ   0  	U   !4555  $78H$I$I!S!S!STTT 	"  000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G
G
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   Nr  rF  s      r'   r  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 1Nr  )rh  r  r  rl   s       r'   has_platform_message_idz!SessionDB.has_platform_message_idf  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sessionc                     |                     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 )Nr8   F_branched_from)r4   r+   r,   r  r  r   r  r   )rh  r  r  cfgs       r'   _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  rO   )r4   r  rs  r  )rh  r  	parent_idrt  s       r'   _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  rO   z
                    SELECT * FROM sessions
                    WHERE parent_session_id = ?
                    ORDER BY started_at ASC
                    N)rs  r  r  r   r4   rm  rb  re   rf   r   r   )rh  r  r  r6   	ancestorsrt  lineager  r   r  
next_childr0   	candidates                r'   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                 n    |                      |          }|sdS |                     |          }i |d|iS )z8Export a single session with all its messages as a dict.Nr   )rs  rS  )rh  r  r  r   s       r'   export_sessionzSessionDB.export_session  sJ    "":.. 	4$$Z000'0:x000r/   c                 R   |                      |          }|sdS g }|D ].}|                     |          }|r|                    |           /|sdS t          |d                   }t	          d |D                       }||d<   d |D             |d<   ||d<   d |D             |d	<   |S )
z9Export a compression lineage as one logical session dict.Nr   c              3   ^   K   | ](}t          |                    d           pg           V  )dS )r   N)rd   r4   rU   segs     r'   r   z3SessionDB.export_session_lineage.<locals>.<genexpr>  s;      PPS!4!4!:;;PPPPPPr/   segmentsc                     g | ]
}|d          S r[   r;   r  s     r'   r^   z4SessionDB.export_session_lineage.<locals>.<listcomp>  s    &E&E&ESs4y&E&E&Er/   lineage_session_idsr  c                 F    g | ]}|                     d           pg D ]}|S )r   re  )rU   r  r   s      r'   r^   z4SessionDB.export_session_lineage.<locals>.<listcomp>  s7    [[[C
@S@S@YWY[[C[[[[r/   r   )r  r  r   r   r  )rh  r  lineage_idsr  rV   segmentr  rA  s           r'   export_session_lineagez SessionDB.export_session_lineage  s    22:>> 	4 	) 	)C))#..G )((( 	4HRL!!PPxPPPPP#Z&E&EH&E&E&E"# ._[[8[[[Zr/   c                     |                      |d          }g }|D ]8}|                     |d                   }|                    i |d|i           9|S )z
        Export all sessions (with messages) as a list of dicts.
        Suitable for writing to a JSONL file for backup/analysis.
        i )r  r  r\   r   )r  rS  r   )rh  r  rt  resultsr  r   s         r'   
export_allzSessionDB.export_all  sq    
 ''vV'DD 	> 	>G((77HNN<g<z8<<====r/   r)   fieldc                 \    | d S t          | t                    r| S t          | d          )Nz must be a string)r+   r,   r   )r)   r  s     r'   _import_text_or_nonezSessionDB._import_text_or_none  s:    =4eS!! 	LE444555r/   c                    | d S t          | t                    ri	 t          j        |           }n*# t          j        $ r}t          | d          |d }~ww xY wt          |t                    st          | d          | S t          | t                    st          | d          	 t          j        |           S # t          t
          f$ r}t          | d          |d }~ww xY w)Nz must be valid JSONz must be a JSON objectz must be JSON serializable)	r+   r,   r  r  r  r   r   r  r   )r)   r  parsedr   s       r'   _import_json_object_or_nonez%SessionDB._import_json_object_or_none  s#   =4eS!! 	IE**' I I I E!>!>!>??SHIfd++ C E!A!A!ABBBL%&& 	?===>>>	L:e$$$:& 	L 	L 	LAAABBK	Ls,   0 AAA+B? ?C(C##C(c                 Z    | d S 	 t          |           S # t          t          f$ r Y d S w xY wr   )rN  r   r   r-   s    r'   _float_or_nonezSessionDB._float_or_none  sD    =4	<<:& 	 	 	44	s    **c                     | d S 	 t          |           S # t          t          f$ r}t          | d          |d }~ww xY w)Nz must be an integerr   r   r   )r)   r  r   s      r'   _import_int_or_nonezSessionDB._import_int_or_none   s_    =4	Eu:::& 	E 	E 	E:::;;D	Es    >9>r`  c                 \    | |S 	 t          |           S # t          t          f$ r |cY S w xY wr   r  )r)   r`  s     r'   _int_or_defaultzSessionDB._int_or_default   sF    =N	u:::& 	 	 	NNN	s    ++c                     t          | t                    s| S 	 t          j        |           S # t          j        t
          f$ r | cY S w xY wr   )r+   r,   r  r  r  r   r-   s    r'   _reasoning_json_valuezSessionDB._reasoning_json_value   sZ    %%% 	L	:e$$$$i0 	 	 	LLL	s   - AAindexc                     | |d}|r||d<   |S )N)r  r'  r  r;   )r  r  r'  r  s       r'   _import_errorzSessionDB._import_error!   s&    ).?? 	,!+Dr/   rt  c                 "    t          |t                    st          d          t          |           j        k    rt          d j         d          g g }t                      }d}d}d}d}t          |          D ]\  }}	t          |	t                    s+|                     	                    |dd                     Ft          |	                    d	          pd                                          }
|
s+|                     	                    |dd
                     |
|v r+|                     	                    ||
d                     |	                    d          pg }t          |t                    s,|                     	                    ||
d                     0t          |           j        k    r,|                     	                    ||
d                     tt          d |D                       r,|                     	                    ||
d                     	 t          t          j        |	dd                              d                    }nB# t$          t          f$ r. |                     	                    ||
d                     Y 0w xY w| j        k    r,|                     	                    ||
d                     k||z  }| j        k    r,|                     	                    ||
d                     	 t          |	          }|
|d	<                        |                    d          d          |d<                        |                    d          d          |d<   |D ].}                     |                    |          |          ||<   /g }t          |          D ]\  }}t          |          }|                    d          }t          |t                    r|st          d| d          |D ]5}|dk    r	                     |                    |          |          ||<   6                     |                    d          d          |d<   |                    |           nO# t          $ rB}|                     	                    ||
t          |                               Y d}~kd}~ww xY w|t          |          z  }| j        k    r,|                     	                    ||
d                     |                    |
                               |||d            |rdddd|d!S  fd"}                     |          S )#a  Import sessions exported by :meth:`export_session` or ``export_all``.

        Existing session IDs are skipped. Imported child sessions keep their
        parent only when that parent already exists or is included in the same
        import payload; otherwise the child is detached so partial imports don't
        fail foreign-key validation. Gateway routing, handoff, rewind, and other
        live runtime state are intentionally reset: this restores conversation
        history, not ownership of a live channel or process.
        zsessions must be a listzsessions must contain at most z entriesr   )r  r  r  r  r  r3   r  r2   rF  rG  r6  rC  rD  rE  r~  )	r   r  r  r  r  r  r  r  r3  r   zsession must be an objectr\   zsession id is requiredzduplicate session idr   zmessages must be a listz-messages exceeds the per-session import limitc              3   B   K   | ]}t          |t                     V  d S r   )r+   r   )rU   r   s     r'   r   z,SessionDB.import_sessions.<locals>.<genexpr>s   s/      AAz#t,,,AAAAAAr/   z"messages must contain only objectsF)rX   :)ensure_ascii
separatorsr  z!session must be JSON serializablez%session exceeds the import size limitz#import exceeds the total size limitr8   r  r   z	messages[z!].role must be a non-empty stringr  Nz'messages exceeds the total import limit)r  r  r   )r	  importedskippeddetachederrorsc                      g }g }g }d}D ]}|d         }|d         }t          |                    d          pd                                          }                     d|f                                          }	|	r|                    |                               |                    d                    }
|
t          j                    }
|                    d          rd	nd}                     d
i d|dt          |                    d          pd          d|                    d          d|                    d          d|                    d          d|                    d          d|
d                    |                    d                    d|                    d          d                    |                    d                    d                    |                    d                    d                    |                    d                    d                    |                    d                    d                    |                    d                    d|                    d          d|                    d          d|                    d          |                    d          |                    d          |                    d                              |                    d                                        |                    d                    |                    d           |                    d!          |                    d"          |                    d#                              |                    d$                    |d%           g }|D ]V}t          |          }d&D ]-}
                    |                    |                    ||<   .|                    |           W                     ||          \  }}                     d'|||f           t          |                    d(          pd                                          }|r|                    ||f           |                    |           t          |          d)t           d*t           d+t          f fd,}|D ]p\  }}                     d|f                                          }|r% |||          s                     d-||f           U                    |d            |d	z  }qd.t          |          t          |          |||g d/S )0Nr   r  r   r\   r   z+SELECT 1 FROM sessions WHERE id = ? LIMIT 1
started_atr  r   at  INSERT INTO sessions (
                           id, source, user_id, model, model_config, system_prompt,
                           parent_session_id, started_at, ended_at, end_reason,
                           message_count, tool_call_count, input_tokens, output_tokens,
                           cache_read_tokens, cache_write_tokens, reasoning_tokens,
                           cwd, git_branch, git_repo_root,
                           billing_provider, billing_base_url, billing_mode,
                           estimated_cost_usd, actual_cost_usd, cost_status, cost_source,
                           pricing_version, title, api_call_count, archived
                       )
                       VALUES (
                           :id, :source, :user_id, :model, :model_config,
                           :system_prompt, NULL, :started_at, :ended_at,
                           :end_reason, 0, 0, :input_tokens, :output_tokens,
                           :cache_read_tokens, :cache_write_tokens,
                           :reasoning_tokens, :cwd, :git_branch, :git_repo_root,
                           :billing_provider, :billing_base_url, :billing_mode,
                           :estimated_cost_usd, :actual_cost_usd, :cost_status,
                           :cost_source, :pricing_version, :title,
                           :api_call_count, :archived
                       )r  importr  r  r8   r  r  r  r<  r=  r>  r?  r@  r3   r  r2   rF  rG  r6  rA  rB  rC  rD  rE  r~  rH  )rF  rG  r6  rA  rB  rC  rD  rE  r~  rH  r  )r  r  r  r?  r  r  r  r   c                     | h}|}|r_||v rdS |                     |           |v r	|         }*                    d|f                                          }|dS |d         }|_dS )NTz;SELECT parent_session_id FROM sessions WHERE id = ? LIMIT 1Fr  )r   re   r   )r  r  r  r   r0   rh   parent_by_childs        r'   _would_create_cyclezCSessionDB.import_sessions.<locals>._do.<locals>._would_create_cycle?!  s    "|# 7$#tHHW%%%/11"1'": ,,U 
  hjj  {$u!"56G  7 ur/   z6UPDATE sessions SET parent_session_id = ? WHERE id = ?T)r	  r  r  r  imported_idsskipped_idsr  )r,   r4   r5   re   r   r   r  r  r  r   r  r9  r  r  rd   )rh   r  r  parent_updatesr  r  r  r   r  r   r  r  sanitized_messagesr   cleanr   rA  rB  r  r  parent_existsr  r  rh  s   `                    @r'   r  z&SessionDB.import_sessions.<locals>._do   s3   &(L%'K46NH" i0 i09o
+ !4"55;;==
AM  (**   &&z222!001F1FGG
%!%J #
 3 3:11*'j' #cggh&7&7&C8"D"D' "3779#5#5'  !1!1	'
 '(?(?' ()A)A' %j' #D$7$7
8K8K$L$L' %cggl&;&;' '(<(<SWW^=T=T(U(U' ()=)=cggo>V>V)W)W' ,T-A-AGG$788. .' -d.B.BGG$899/ /'$ +D,@,@GG$677- -%'* swwu~~+', %cggl&;&;-'. ()A)A/'0 -0GG4F,G,G,/GG4F,G,G(+(?(?.2.A.AGG$899/ / ,0+>+>GG$566, , (+ww}'='='*ww}'='=+.773D+E+E!$!1!1*.*>*>swwGW?X?X*Y*Y$,M' ' '-> > >@ <>"# 5 5C IIE  P P
 &*%?%?		#%O%Oc

&--e4444373L3L&4 40 0
 ]#%5zB  
  (; < < BCCIIKK	 C")):y*ABBB##J////">22O         & *8 " "%
I $AL! ! (**  ! 
")<)<Z)S)S 
"LLP"J/    $''
D999MHH --{++$ ,*  r/   )r+   rb   r   rd   _IMPORT_MAX_SESSIONSra   r  r   r   r  r,   r4   r5    _IMPORT_MAX_MESSAGES_PER_SESSIONr   r  r  encoder   _IMPORT_MAX_SESSION_BYTES_IMPORT_MAX_TOTAL_BYTESr  r  r  _IMPORT_MAX_TOTAL_MESSAGESr   r  )rh  rt  r  rM  rA  total_bytessession_text_fieldsmessage_text_fieldsr  r  r  r   session_bytesclean_sessionr  clean_messagesmessage_indexmessageclean_messager   r   r  r  s   `                     @r'   import_sessionszSessionDB.import_sessions(   s    (D)) 	86777x==4444T1JTTT   ,.
') UU
"

 $H-- f	 f	JE3c4(( d00<WXXYYYSWWT]]0b117799J d00<TUUVVVX%%d00
DZ[[\\\wwz**0bHh-- d00
D]^^___8}}tDDD&&"G    AAAAAAA &&"<     #Js:NNNUUV]^^! ! z*   &&uj:]^^   	
 t===&&uj:abb   =(KT999&&uj:_``     $S		&0d#040P0P!%%n55~1 1n- 6:5N5N!%%&9::<O6 612 1  E+/+D+D%))%00%, ,M%(( 8:.7.A.A 9 9*M7$(MMM(,,V44D%dC00 g g()e])e)e)efff!4   F??$/3/H/H)--e44e0 0e,, 483K3K%))-88-4 4M-0 #))-88889    d00
CHHMMNNN c.111N ???&&"A    LL$$$M~VV     	   _	 _	 _	 _	 _	 _	B ""3'''s+   :7I22;J10J1(E;R$$
S0.7S++S0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    r'   r  z%SessionDB.clear_messages.<locals>._doq!  sJ    LL;j]   LLY    r/   Nr  r  s    ` r'   clear_messageszSessionDB.clear_messageso!  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)unlinkr#   glob)rd  r  r  r   s       r'   rk  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                     g 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. Returns True if the session was found and deleted.
        c                 H   |                      df          }|                                d         dk    rdS                     t          | g                     |                      df           |                      df           |                      df           dS )Nz*SELECT COUNT(*) FROM sessions WHERE id = ?r   FzHUPDATE sessions SET parent_session_id = NULL WHERE parent_session_id = ?r=  !DELETE FROM sessions WHERE id = ?T)re   r   r  rs   )rh   rl   removed_delegate_idsr  s     r'   r  z%SessionDB.delete_session.<locals>._do!  s    \\<zm F   #q((u ''(A$(U(UVVVLL.  
 LLDzmTTTLL<zmLLL4r/   r  rk  r  )rh  r  rd  r  deleteddelegate_idr  s    `    @r'   delete_sessionzSessionDB.delete_session!  s     +-	 	 	 	 	 	" %%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                 H    |                      df          }|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  )rh   rl   r  s     r'   r  z.SessionDB.delete_session_if_empty.<locals>._do!  s1    \\  F ?Q&&r/   r  )rh  r  rd  r  r  s    `   r'   delete_session_if_emptyz!SessionDB.delete_session_if_empty!  s\    &	' 	' 	' 	' 	'$ %%c** 	A&&|Z@@@G}}r/   r  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 r;   )r+   r,   rT   s     r'   rW   z,SessionDB.delete_sessions.<locals>.<setcomp>"  s-    VVV3C9M9MVRUV3VVVr/   c                 :   d                     dt                    z            }|                     d| d          }d |                                D             }|sdS d                     dt          |          z            }                    t          | |                     |                     d| d|           |                     d| d|           |                     d	| d|                               |           t          |          S )
NrX   rY   z%SELECT id FROM sessions WHERE id IN (rF   c                     g | ]
}|d          S r[   r;   rz  s     r'   r^   z:SessionDB.delete_sessions.<locals>._do.<locals>.<listcomp>"  s    ???cD	???r/   r   rp   ro   rq   )rc   rd   re   rf   r  rs   )rh   r  rl   r   existing_placeholdersr  rl  
unique_idss        r'   r  z&SessionDB.delete_sessions.<locals>._do"  sZ   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   x(((x== r/   )rb   r  rk  )	rh  r  rd  r  r  rV   r  rl  r  s	         @@@r'   delete_sessionszSessionDB.delete_sessions!  s    :  	1 VV+VVVWW
 	1!#*,!	! !	! !	! !	! !	! !	! !	!F ##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   Nr  )rh  rl   s     r'   count_empty_sessionszSessionDB.count_empty_sessions<"  s      Z 	( 	(Z''# F ??$$Q'	( 	( 	( 	( 	( 	( 	( 	( 	( 	( 	( 	( 	( 	( 	( 	( 	( 	(s   4A		AAc                 v    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                               |           Ft          |          S )
NzYSELECT id FROM sessions WHERE message_count = 0 AND ended_at IS NOT NULL AND archived = 0c                     h | ]
}|d          S r[   r;   rz  s     r'   rW   z?SessionDB.delete_empty_sessions.<locals>._do.<locals>.<setcomp>{"      BBB3t9BBBr/   r   rX   rY   rp   rF   r=  r  re   rf   rc   rd   rb   r   )rh   rl   r  r  rV   rl  s        r'   r  z,SessionDB.delete_empty_sessions.<locals>._dot"  s   \\# F CB0A0ABBBK q88C#k*:*:$:;;LLL?/;? ? ?[!!   # 	( 	(
 ?#   @3&III""3''''{###r/   )r  rk  )rh  rd  r  r  rV   rl  s        @r'   delete_empty_sessionszSessionDB.delete_empty_sessionsU"  se    : "$	$ 	$ 	$ 	$ 	$> ##C(( 	: 	:C&&|S9999r/   )started_beforestarted_afterr  
title_liker  r=   min_messagesmax_messagesr  
model_liker7  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  c                 L   dg}g }| *|                     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 NULLNzs.started_at < ?zs.started_at >= ?r  z#LOWER(COALESCE(s.title, '')) LIKE ?r  zs.end_reason = ?r  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) <= ?Tr  Fr  r  )r   r   rB   r  rc   )r  r  r  r  r  r=   r  r  r  r  r7  r  r  r  r  r  r  r  r  r  r  clausesr  r  r  s                            r'   _prune_filter_wherezSessionDB._prune_filter_where"  sX   V ,,%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                 H   |                     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 )	a  Return the sessions a matching :meth:`prune_sessions` /
        :meth:`archive_sessions` call would touch, without modifying anything.

        Backs ``--dry-run`` and pre-confirmation counts. Accepts the same
        keyword filters as :meth:`_prune_filter_where` (unknown names raise
        ``TypeError`` there). Rows are ordered oldest-first and carry
        ``id, source, title, model, started_at, ended_at, message_count,
        archived``.
        r  Nrf  r  zSELECT s.id, s.source, s.title, s.model, s.started_at,
                           s.ended_at, s.message_count, s.archived
                    FROM sessions s WHERE z.
                    ORDER BY s.started_at ASCc                 ,    g | ]}t          |          S r;   r  rz  s     r'   r^   z3SessionDB.list_prune_candidates.<locals>.<listcomp>*#  r  r/   r;   )r4   r  r  rm  rb  re   rf   )rh  r  r  filtersr  r  rl   s          r'   list_prune_candidateszSessionDB.list_prune_candidates#  s    ;;'((0_5P(,	7N(OG$%00JJJ'JJvZ 	< 	<Z''1+01 1 1  F <;):):;;;	< 	< 	< 	< 	< 	< 	< 	< 	< 	< 	< 	< 	< 	< 	< 	< 	< 	<s   =BBB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.
        r  F)r  r  r\   Tr;   )
setdefaultr   r  rd   )rh  r  r  r  r  r0   s         r'   archive_sessionszSessionDB.archive_sessions,#  s    $ 	:u---)t) 
+F
 
>E
 
  	7 	7C%%c$i66664yyr/   Z   c                   	
 |                     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.

        Default behavior (no keyword filters) is unchanged: delete ended
        sessions older than ``older_than_days`` days, optionally restricted
        to ``source``. Additional keyword filters AND together — the full
        set is defined by :meth:`_prune_filter_where`:

        * ``started_before`` / ``started_after`` — epoch bounds on
          ``started_at``. ``started_before`` overrides ``older_than_days``;
          pass ``older_than_days=None`` for no upper age bound (e.g. when
          only pruning a recent window via ``started_after``).
        * ``title_like`` / ``model_like`` / ``branch_like`` —
          case-insensitive substring matches.
        * ``end_reason`` / ``provider`` / ``user_id`` / ``chat_id`` /
          ``chat_type`` — exact matches (provider case-insensitive, against
          ``billing_provider``).
        * ``cwd_prefix`` — session cwd equals or is under this path.
        * ``min_messages`` / ``max_messages`` — bounds on message_count.
        * ``min_tokens`` / ``max_tokens`` — bounds on input+output tokens.
        * ``min_cost`` / ``max_cost`` — bounds on USD cost
          (actual, falling back to estimated).
        * ``min_tool_calls`` / ``max_tool_calls`` — bounds on tool_call_count.
        * ``archived`` — tri-state: None = both (default), True = only
          archived, False = only unarchived.

        Only prunes ended sessions (not active ones).  Child sessions outside
        the prune window are orphaned (parent_session_id set to NULL) rather
        than cascade-deleted.  When *sessions_dir* is provided, also removes
        on-disk transcript files (``.json`` / ``.jsonl`` /
        ``request_dump_*``) for every pruned session, outside the DB
        transaction.
        r  Nrf  r  c                    |                      d           }d |                                D             }|sdS d                    dt          |          z            }|                      d| dt	          |                     |D ]E}|                      d|f           |                      d	|f                               |           Ft          |          S )
Nz"SELECT s.id FROM sessions s WHERE c                     h | ]
}|d          S r[   r;   rz  s     r'   rW   z8SessionDB.prune_sessions.<locals>._do.<locals>.<setcomp>v#  r	  r/   r   rX   rY   rp   rF   r=  r  r
  )rh   rl   r  r  rV   rl  r  where_paramss        r'   r  z%SessionDB.prune_sessions.<locals>._dor#  s   \\<U<<l F CB0A0ABBBK q 88C#k*:*:$:;;LLL?/;? ? ?[!!   # ( (H3&QQQ@3&III""3''''{###r/   r;   )r4   r  r  r  rk  )rh  r  r  rd  r  r  r  rV   rl  r  r(  s           @@@r'   prune_sessionszSessionDB.prune_sessionsF#  s    N ;;'((0_5P(,	7N(OG$%6d6PPfPPP|!#	$ 	$ 	$ 	$ 	$ 	$ 	$. ##C(( 	: 	:C&&|S9999r/   r   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 = ?Nr)   r   )rm  rb  re   r   r+   r   rc  )rh  r   r0   s      r'   r  zSessionDB.get_meta#  s    Z 	 	*$$<sf hjj 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 ;4)#w{;;Gs7||QGr  rx  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.
        Nr  c                 8    |                      df           d S )Nr  r  )rh   r   r)   s    r'   r  zSessionDB.set_meta.<locals>._do#  s0    LLHe    r/   )re   r  )rh  r   r)   rl   r  s    ``  r'   r|  zSessionDB.set_meta#  sp     NNHe  
 F	 	 	 	 	 	 	C     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  rt  r   r   CASCADENr;   rz  s     r'   r   zHSessionDB.apply_telegram_topic_migration.<locals>._do.<locals>.<genexpr>#  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);
                        r  )r0  2)r  re   r   r,   isdigitr   rf   r   )rh   r   r  fk_rowsneeds_rebuilds        r'   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   s     r'   apply_telegram_topic_migrationz(SessionDB.apply_telegram_topic_migration#  s1    T	 T	 T	j 	C     r/   )has_topics_enabledallows_users_to_create_topicsr8  r9  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.
        r)   r   c                     | d S | rdndS r  r;   r-   s    r'   _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
                )re   r,   )rh   r<  r9  r  r8  r   r  s    r'   r  z1SessionDB.enable_telegram_topic_mode.<locals>._do/$  sf    LL LLLLG.//G9::    r/   N)r7  r  r   r  r   r  )rh  r  r  r8  r9  r  r<  r   s    ```` @@r'   enable_telegram_topic_modez$SessionDB.enable_telegram_topic_mode$  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 = ?)re   r  r,   r   r   )rh   r  r?  s    r'   r  z2SessionDB.disable_telegram_topic_mode.<locals>._do[$  s    (Y[[#g,,/  
 " LLRW     
 +   s   AA# #A65A6Nr  )rh  r  r?  r  s    `` r'   disable_telegram_topic_modez%SessionDB.disable_telegram_topic_modeK$  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 = ?
                    NFenabledr   )
rm  rb  re   r,   r   r   r   r+   rc  r  )rh  r  r  r0   rE  s        r'   is_telegram_topic_mode_enabledz(SessionDB.is_telegram_topic_mode_enabledl$  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rm  rb  re   r,   r   r   r   r   )rh  r  r  r0   s       r'   get_telegram_topic_bindingz$SessionDB.get_telegram_topic_binding~$  s    Z 
	 
		j(( \\3y>>2  (**  +   
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	  )tCyyyT)rG  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 r;   r  rz  s     r'   r^   zCSessionDB.list_telegram_topic_bindings_for_chat.<locals>.<listcomp>$  s    ***cS		***r/   )rm  rb  re   r,   rf   r   r   )rh  r  r  s      r'   %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 = ?
                    NrI  rh  r  r0   s      r'   %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
                    rB  )re   r  r   r   r   r  )rh   rl   	remainingr  r  r  s      r'   r  z4SessionDB.delete_telegram_topic_binding.<locals>._do$  s     i(  $*?#7a  +   #$  7#  LL J  (**  $LLLg.     %$ +   s!   $( A A AB& &B98B9)r,   r  )rh  r  r  r  r  s    `` @r'   delete_telegram_topic_bindingz'SessionDB.delete_telegram_topic_binding$  sl    B g,,	NN	A,#	 #	 #	 #	 #	 #	 #	J 	C   wr/   auto)managed_moderW  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
                )re   r   r+   r   rc  r,   r   )rh   existing_sessionlinked_chatlinked_threadr  rW  r   r  r  r  r  s       r'   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)r7  r  r,   r  )	rh  r  r  r  r  r  rW  r  r   s	    `````` @r'   bind_telegram_topiczSessionDB.bind_telegram_topic%  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)rm  rb  re   r,   r   r   r   rO  s      r'   #is_telegram_session_linked_to_topicz-SessionDB.is_telegram_session_linked_to_topicL%  s     Z 	 	
j((
 __&  (**  +   	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 $rQ  rq  )r  c          	         | j         5  	 | j                            dt          |          t	          |          f                                          }n^# t          j        $ rL | j                            dt          |          t	          |          f                                          }Y nw xY wddd           n# 1 swxY w Y   g }|D ]}t          |          }t          |	                    dd          pd          
                                }|r"|dd         t          |          dk    rdndz   nd|d<   |                    |           |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.
        aE  
                    SELECT s.*,
                        COALESCE(
                            (SELECT SUBSTR(REPLACE(REPLACE(m.content, X'0A', ' '), X'0D', ' '), 1, 63)
                             FROM messages m
                             WHERE m.session_id = s.id AND m.role = 'user' AND m.content IS NOT NULL
                             ORDER BY m.timestamp, m.id LIMIT 1),
                            ''
                        ) AS _preview_raw,
                        COALESCE(
                            (SELECT MAX(m2.timestamp) FROM messages m2 WHERE m2.session_id = s.id),
                            s.started_at
                        ) AS last_active
                    FROM sessions s
                    WHERE s.source = 'telegram'
                      AND s.user_id = ?
                      AND NOT EXISTS (
                          SELECT 1 FROM telegram_dm_topic_bindings b
                          WHERE b.session_id = s.id
                      )
                    ORDER BY last_active DESC, s.started_at DESC
                    LIMIT ?
                    a  
                    SELECT s.*,
                        COALESCE(
                            (SELECT SUBSTR(REPLACE(REPLACE(m.content, X'0A', ' '), X'0D', ' '), 1, 63)
                             FROM messages m
                             WHERE m.session_id = s.id AND m.role = 'user' AND m.content IS NOT NULL
                             ORDER BY m.timestamp, m.id LIMIT 1),
                            ''
                        ) AS _preview_raw,
                        COALESCE(
                            (SELECT MAX(m2.timestamp) FROM messages m2 WHERE m2.session_id = s.id),
                            s.started_at
                        ) AS last_active
                    FROM sessions s
                    WHERE s.source = 'telegram'
                      AND s.user_id = ?
                    ORDER BY last_active DESC, s.started_at DESC
                    LIMIT ?
                    Nr  r   r  r  r  )rm  rb  re   r,   r   rf   r   r   r   r  r5   rd   r   )	rh  r  r  r  r  rt  r0   r  r  s	            r'   (list_unlinked_telegram_sessions_for_userz2SessionDB.list_unlinked_telegram_sessions_for_userb%  s    Z 4	 4	3z)). \\3u::.1 2 (**3 4 +    z))& \\3u::.) * (**+ 94	 4	 4	 4	 4	 4	 4	 4	 4	 4	 4	 4	 4	 4	 4	l *, 	% 	%C3iiGgkk."55;<<BBDDCPS![SbSc#hhmmUU!L!LY[GIOOG$$$$s6   B>A	AB>AB/,B>.B//B>>CCr  r   c                 p    	 | j                             d| d           dS # t          j        $ r Y dS w xY w)z6True if an FTS5 virtual table is queryable in this DB.r  r  TF)rb  re   r   r   )rh  r   s     r'   _fts_table_existszSessionDB._fts_table_exists%  sU    	J>>>>???4$ 	 	 	
 55	s   " 55c           	      H   d}| j         5  | j        D ]w}|                     |          s	 | j                            d| d| d           |dz  }@# t
          j        $ r&}t                              d||           Y d}~pd}~ww xY w	 ddd           n# 1 swxY w Y   |S )u  Merge fragmented FTS5 b-tree segments into one per index.

        FTS5 indexes grow as a series of incremental segments — one per
        ``INSERT`` batch driven by the message triggers. Over tens of
        thousands of messages these segments accumulate, which both bloats
        the ``*_data`` shadow tables and slows ``MATCH`` queries that must
        scan every segment. The special ``'optimize'`` command rewrites each
        index as a single merged segment.

        This is purely a maintenance operation — it changes neither search
        results nor ``snippet()`` output, only on-disk layout and query
        speed. It is complementary to VACUUM: ``optimize`` compacts the FTS
        index internally, then VACUUM returns the freed pages to the OS.

        Skips any FTS table that does not exist (e.g. the trigram index when
        disabled via ``HERMES_DISABLE_FTS_TRIGRAM`` or not yet created), so
        it is safe to call unconditionally.

        Returns the number of FTS indexes that were optimized.
        r   r)  rI   z) VALUES('optimize')r   zFTS optimize failed for %s: %sN)	rm  _FTS_TABLESrc  rb  re   r   r   r   r   )rh  	optimizedr  r   s       r'   r  zSessionDB.optimize_fts%  s:   * 	Z 	 	'  --c22 
 J&&FsFFSFFF   NII/   NN8#s       	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 s:    B&ABB!B=BBBBBc           	         d}| j         5  | j        D ]}|                     |          s	 | j                            d| d| d           | j                                         |dz  }Y# t          j        $ r?}| j                                         t          
                    d||           Y d}~d}~ww xY w	 ddd           n# 1 swxY w Y   |S )aN  Rebuild FTS5 indexes from the canonical ``messages`` table.

        Uses the FTS5 ``'rebuild'`` command, which rewrites the internal
        b-tree segments from the content rows. This is the documented
        recovery for a corrupt FTS index that rejects message writes while
        reads still succeed (issue #50502). Unlike ``optimize_fts`` (which
        merges existing segments), ``rebuild`` discards and recreates the
        index data entirely.

        Safe to call when FTS tables don't exist (skips them).
        Returns the number of FTS indexes that were rebuilt.
        r   r)  rI   r*  r   zFTS rebuild failed for %s: %sN)rm  re  rc  rb  re   r2  r   r   r  r   r   )rh  r  r  r   s       r'   r+  zSessionDB.rebuild_fts%  s_    Z 	 	'  --c22 
J&&EsEESEEE   J%%'''qLGG/   J'')))NN7c       	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 s:    C	?A+*C	+B9:5B4/C	4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: %sr0  )r  r   r   r   rm  rb  re   r  )rh  rf  r   s      r'   r9  zSessionDB.vacuum&  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_hoursc                    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	<   |rS|	dk    rM	 |                                  d|d
<   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 )u  Idempotent auto-maintenance: prune old sessions + optional VACUUM.

        Records the last run timestamp in state_meta so subsequent calls
        within ``min_interval_hours`` no-op. Designed to be called once at
        startup from long-lived entrypoints (CLI, gateway, cron scheduler).

        When *sessions_dir* is provided, on-disk transcript files
        (``.json`` / ``.jsonl`` / ``request_dump_*``) for pruned sessions
        are removed as part of the same sweep (issue #3015).

        Never raises. On any failure, logs a warning and returns a dict
        with ``"error"`` set.

        Returns a dict with keys:
          - ``"skipped"`` (bool) — true if within min_interval_hours of last run
          - ``"pruned"`` (int)   — number of sessions deleted
          - ``"vacuumed"`` (bool) — true if VACUUM ran
          - ``"error"`` (str, optional) — present only on failure
        Fr   )r  prunedrM  last_auto_prunei  Tr  )r  rd  rm  rM  zstate.db VACUUM failed: %sNzDstate.db auto-maintenance: pruned %d session(s) older than %d days%sz	 + VACUUMr   z$state.db auto-maintenance failed: %sr'  )r  r  rN  r   r   r)  r9  r   r   r   r|  r,   r   )rh  rj  rk  r9  rd  r  last_rawr   last_tsrm  r   s              r'   maybe_auto_prune_and_vacuumz%SessionDB.maybe_auto_prune_and_vacuum.&  s   4 .3aU!S!S*	'}}%677H)++C #HooGW}'9D'@@@,0y)% A ":.   D (( .) )  F  &F8  F&1**FKKMMM)-F:&&  F F FNN#?EEEEEEEEF
 MM+SXX666zzZ"#)*#5=KK2	    	' 	' 	'NNA3GGG!#hhF7OOOOOO	'
 se   *D/ !A D/ A*'D/ )A**'D/ B, +D/ ,
C6CD/ CAD/ /
E09-E++E0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  )rh   r  r   r  s     r'   r  z&SessionDB.request_handoff.<locals>._do&  s5    ,,Q :& C <!##r/   r  )rh  r  r   r  s    `` r'   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'  )rb  re   r   r   )rh  r  r  r0   s       r'   get_handoff_statezSessionDB.get_handoff_state&  s    	*$$- C
 ,,..C t_- 23_-  
  	 	 	44	s   2A A 
AAc                     	 | j                             d          }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.
        zNSELECT * FROM sessions WHERE handoff_state = 'pending' ORDER BY started_at ASCc                 ,    g | ]}t          |          S r;   r  r
  s     r'   r^   z3SessionDB.list_pending_handoffs.<locals>.<listcomp>&  s    444DGG444r/   )rb  re   rf   r   )rh  r  s     r'   list_pending_handoffszSessionDB.list_pending_handoffs&  se    
	*$$* C
 54S\\^^4444 	 	 	III	s   7: 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  )rh   r  r  s     r'   r  z$SessionDB.claim_handoff.<locals>._do&  s/    ,,= C
 <!##r/   r  r  s    ` r'   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    r'   r  z'SessionDB.complete_handoff.<locals>._do&  s-    LL4    r/   Nr  r  s    ` r'   complete_handoffzSessionDB.complete_handoff&  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 = ?rX  r  )rh   r'  r  s    r'   r  z#SessionDB.fail_handoff.<locals>._do&  s8    LL1ttj)    r/   Nr  )rh  r  r'  r  s    `` r'   fail_handoffzSessionDB.fail_handoff&  s>    	 	 	 	 	 	 	C     r/   )NF)r   N)NNNNNNNNNNNN)T)r  )NNr   )r  )r   r   Nr   r   r   NNNNNNNNr   F)rK  N)F)NNNrv  r   Fr   TFFFNNF)rv  r   )NNNNNNNNNNNNFNNNNN)FNr   )r	  )r	  r   r^  )FFF)rv  F)NNNrv  r   NF)rv  T)Nrv  r   )NNr   FFFNr  )r$  NN)r$  ri  TN)rw  
__module____qualname____doc__r  r  r  r  r  r  r  r  r  r  r   r  rx  staticmethodr   r   r  r  Cursorr  r  r  r  r  r  r   r  r  r  r,   r   r  r  r   
Connectionrt   r  r   r  r  r  r  r   r  rD  rC  r	   r   r  r  r  r  r  r  r"  r!  r(  r,  r+  r7  rS  rc  rn  rf  r  r  r  r  r  r  r  r
   r  r  r  r{  r  r  r  r  r  r  rN  r  r  r  r	  r  r  r  r  r&  r)  r+  r/  r2  r5  r;  rT  rM  r`  rc  rm  rp  rs  r}  r  r  r  r  r  r  r  r  r  r  r  r  	frozensetr  r  __annotations__classmethodr  r  r  r  r  r  r
  r  r.  r1  ri  r9  rD  rG  rK  rO  rS  r]  r   rw  r~  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  rQ  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  rk  r  r  r  r  r  rb   r  r   r#  r)  r  r|  r7  r>  rC  rF  rJ  rM  rP  rU  r]  r_  ra  re  rc  r  r+  r9  rq  rt  rz  r}  r  r  r  r;   r/   r'   r  r     s/           !#  $
 '-$!' /.n n n n n n nd (@ T    \ 
7+C 
 
 
 
 \
 &'. &T & & & \&2
W-E 
$ 
 
 
 

'*B 
t 
 
 
 
	GN 	t 	 	 	 	t, t, t, t,l 7> d    \ K7> Kc K K K \K  !%
 
 

 
 
	
 
 
 \
,  !%
 
 

 
 
	
 
 
 \
Bw~ 3 8TX>    "  	
 
   6D
g()1,-D
 
D
 D
 D
 D
L 2G,A 2d 2 2 2 \2/G,A /d / / / /bG G G G<    " " "Z "" VHT#s(^$< V V V V$&O &O &O &OZ )($ ( ( ( (TB$ B B B BV
Vc3h(@ 
V 
V 
V 
V,d , , , ,\O O O O4%$ %$ %$ %$b 3  3  3  3  3Dd    --S -- -- -- --d CG	G3 G3 G3 hS#X'7'=>?G3 	G3
 
c3hG3 G3 G3 G3R (# ($sDcN7J2K ( ( ( \(T* *D * * * *Xp p pt '+!!% !Q! Q!Q! Q! 	Q!
 38nQ! Q! Q! Q! Q! Q! Q! Q! Q! Q! Q!  
!Q! Q! Q! Q!f c       -! -! -!-! 	-!
 -! -! -! -! -! -! -! 
-! -! -! -!^! !s !t !t ! ! ! !( BD! ! !!,/!;>!	! ! ! !: 8:! ! !CH~!14!	! ! ! !. <> A A AS A$sCx. A A A A 8:! ! ! I!14!	! ! ! !$ #' 	$' $' $' 3-$' 	$'
 
d38n	$' $' $' $'V $(!%0" 0" 0" 0" 	0"
 C=0" #0" 
#0" 0" 0" 0"d/n/	/ / / /j "&%)!%#'#'?* ?* ?* ?* #	?*
 c]?* #?* C=?* C=?* 
$sCx.	!?* ?* ?* ?*B!c !s !t ! ! ! !$! ! ! ! ! ! .=/ //'*/	/ / / /d W[$! $!$!$'$!58$!PS$!	$! $! $! $!L!tCH~ !$ ! ! ! !2  $	   }	
 
   2#
#
 
$sCx.	!#
 #
 #
 #
JS T    (# #    0!# !s !t ! ! ! !C C    >!C ! !PT ! ! ! !N #	   	
 
   B #	V VV V 	V
 
V V V Vp3      4Ic Ihsm I I I I*  $	! !! ! }	!
 
! ! ! !&!s !3 !4 ! ! ! !!s !3 !4 ! ! ! !( '+! ! !! 	!
 ! sm! 
! ! ! !B !""# !.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*# *(4S>2J * * * *c hsm    :!(C !(C !( !( !( !(F=c =hsm = = = =J !*	?*; < </3x}333
-c 
- 
- 
- [
-
 
d 
tDQTVYQYNG[ 
 
 
 
8 %)!&!")-%*!&# "b bb cb 	b
 b b b b #'b #b b b b b b  
d38n	!b b b bN	 	@ @@ @ 	@
 
d38n	@ @ @ @D% % %4 %T\]abegjbj]kTl % % % %^ ' 6c  6c  6  6  6 [ 6D c c    ["  !!%!%%)#'#',0%)&*59+F( F(F( F( 	F(
 F( F( F( F( F( F( F( F(  #F( !F( !F(  !F(" %SM#F($ %F(& c]'F(( sm)F(* #4S>2+F(, 
-F( F( F( F(T 6: .  .  . .(+ .69 .IL ."4S>2 . 
 .  .  .  .D_*S _*DcSVhDX _*]bcfhkck]l _* _* _* _*J "	.! .!.! tCH~&.! 	.!
 
.! .! .! .!`1 1 1 1 1 12(2(37S#X3G2(	2( 2( 2( 2(h(((+(:=(	( ( ( (F "'#0 00 0 }	0
 0 
d38n	0 0 0 0l 	L
 L
L
 L
 	L

 
c3hL
 L
 L
 L
d 0Ev
 v
v
 v
 	v

 v
 U38_-v
 
c3hv
 v
 v
 v
pW<C W<C W< W< W< W<x #(!&#(7
 7
7
  7
 	7

 !7
 
d38n	7
 7
 7
 7
z	6 t 	t
  t !t 
d38n	t t t tl/./.	tDcN#T$sCx.%99	:/. /. /. /.b'
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(# ( ( ( ( ( (6 !&	4 44 4 	4
 
d38n	4 4 4 4t L!C L!C L! L! L! \L!^ (c (d ( ( ( \( C D    \ Gc Gc G G G [G c d    [" A A A A A \A( , $(%)!%M@ M@ M@M@ 	M@
 M@ M@ CyM@ cM@ #YM@ M@ M@ 
$tCH~&	'M@ M@ M@ M@d $(%)!%!&. .. Cy. c	.
 #Y. . . . . 
d38n	. . . .`3 3    2 $(%)!%!&i ii Cyi c	i
 #Yi i i i i 
d38n	i i i i` "'-1/3+/G' G' G'G' G'
 G'  S	*G' "$s),G' d3i(G' 
d38n	G' G' G' G'X !%	-2 -2-2 -2 	-2
 
d38n	-2 -2 -2 -2b 	"< "<"< "< 	"<
 
d38n	"< "< "< "<T !"!&#!&%)8( 8(8( 8( 	8(
 8( 8( 8( c8( 
8( 8( 8( 8(t	( 	( 	(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1 1$sCx.1I 1 1 1 1 $sCx.9Q    (
 
 
T#s(^0D 
 
 
 
 6C 6 6 6 6 6 \6 L3 Ls Lx} L L L \L$ c huo    \ E3 Es Ex} E E E \E  s S     \ S S    \ S c # $sCx.    \E(T#s(^(< E(c3h E( E( E( E(N

! 
! 
! 
! 
! 
! HTN  PT    \: (,' '' tn' 
	' ' ' 'X (,( (( tn( 
	( ( ( (Z (,Q Q#YQ tnQ 
	Q Q Q Qf(c ( ( ( (6 (,A AtnA 
A A A AF  +/)- $$($($(&*&*#'$("&!%!%#'%)$($($($((,(,-u- u- u- u-  u- 	u-
 SMu- SMu- SMu- smu- smu- 4.u- SMu- 3-u- #u- #u- C=u-  c]!u-" SM#u-$ SM%u-& 5/'u-( 5/)u-* !+u-, !-u-. 
sDy	/u- u- u- \u-r ,0< <!%< <
 
d38n	< < < << ,0 !% 
 
   8 ,.'+	G G!%G G tn	G 
G G G GVHC HHSM H H H H KO! ! !!"!/7/G!	! ! ! !6b! b! b! b!R .28</! /! /! /! 	/!
 %TN/! (0~/! 
/! /! /! /!j  $	! ! ! ! 	!
 
! ! ! !B s t    $* * 	*
 
$sCx.	!* * * *(+ + 
d38n		+ + + +** * 
$sCx.	!	* * * *0K  K  	K 
 
K  K  K  K j #>! >! >! >! 	>!
 >! >! >! >! 
>! >! >! >!@     6 J J J J 	J
 J 
d38n	J J J Jd OK
c 
d 
 
 
 
%c % % % %NS    >$ $ $ $ $P !"$'+G GG  G 	G
 tnG 
c3hG G G Gj(# ( ( ( ( ( (&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  r   Nc                     || _         d S r   )_db)rh  r  s     r'   rx  zAsyncSessionDB.__init__&  s    r/   r   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     r'   
_offloadedz.AsyncSessionDB.__getattr__.<locals>._offloaded&  s:       *4A$AAA&AAAAAAAAAr/   )r  r  callable)rh  r   r  r  s      @r'   __getattr__zAsyncSessionDB.__getattr__&  sM    tx&&~~ 	K	B 	B 	B 	B 	B r/   )r  r  r   N)rw  r  r  r  rx  r,   r  r;   r/   r'   r  r  &  sO         Z  Z         r/   r  )r8   )rC   )r   r   )jr  r  r  loggingr   r  r  r   r   rk  r  pathlibr   agent.memory_managerr   agent.message_sanitizationr   hermes_constantsr   typingr   r   r	   r
   r   r   r   r   ImportError	getLoggerrw  r   compiler   r,   r  r(   r.   r7   r<   rB   rK   rM   rL   r  rP   rm   rs   rt   rj  rJ  rI  r  r   rw   r  rl  r   ra   rx   r   ry   r   r  r   r   r   r   r   r   r  r   r   r   ri  r   r   r   r   r   r   r   r   r   r   r  r   r   r  r"  r;  rg  rz  r2  r3  r  r  r  r  rF  rN  r  r  r  r  r  r;   r/   r'   <module>r     sW        				  				  



            1 1 1 1 1 1 ; ; ; ; ; ; , , , , , , F F F F F F F F F F F F F F F F F FMMMM   FFF 
	8	$	$","*-G"H"H /S /T / / / /d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4 0  \9J9Q9QTW9Q9X9X[[[   c    6$s) 6S	 6 6 6 6@S	 d3i     GCLL!/##j0  
  6  #' (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'*<     > %) 5/	   0#    $ ? ? ?

? ? 		? ? ? ?D 

    		       F  
	   :S y T    V  %(CEE S ) ) )%y~'' 	S} 	S 	S 	S 	S 	S4 D    T htn    0{t { { { { {| =A e e eD eT eT#s(^ e e e eP[
B @+v6t  ( T  $ 8$ 8 8 8 8    '"4     J2 6yB! yB! yB! yB! yB! yB! yB! yB!xE         s   A A&%A&