
    ,j"             	          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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  ej        e          Zd;ded	e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          Z" e            dz  Z#dZ$dZ%da&ee         e'd<    ej(                    Z) e*            Z+e*e         e'd<    ej(                    Z,dZ-dee         d	dfdZ.d	ee         fdZ/d=d!ed	efd"Z0d#ej1        d	ee         fd$Z2dd%d#ej1        d&ed	efd'Z3d&ed(e4d	dfd)Z5d*Z6 e*            Z7e*e         e'd+<    ej(                    Z8d(e9d	e:fd,Z;d-e	d	e:fd.Z<d-e	d	ee	         fd/Z=d-e	d	ee         fd0Z>d1d2d-e	d3e:d	eeef         fd4Z?d5Z@d6ZAd7ZBd8ZC G d9 d:          ZDdS )>a}  
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)get_hermes_home)AnyCallableDictListOptionalTupleTypeVarmodel_configcolreturnc                     d|  dS )Nzjson_extract(COALESCE(z, '{}'), '$._delegate_from') )r   s    1/home/ubuntu/.hermes/hermes-agent/hermes_state.py_delegate_from_jsonr       s    GCGGGG    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)zEXISTS (SELECT 1 FROM sessions p        WHERE p.id = {a}.parent_session_id        AND p.end_reason = 'compression'        AND {a}.started_at >= p.ended_at)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.r   (z(.parent_session_id IS NOT NULL AND NOT (z) AND NOT (z)))_BRANCH_CHILD_SQLformat_COMPRESSION_CHILD_SQL)r   branchcompressions      r   _ephemeral_child_sqlr!   :   sc    %%%..F(//%/88K	%E 	% 	%	% 	% 	% 	% 	%r   
parent_idsc                 z   t                      }t                      d |D             }|rd                    dt          |          z            }|                     d| d| d| d| d	||z             }fd	|                                D             }                    |           |t                    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                     g | ]}||S r   r   .0sids     r   
<listcomp>z/_collect_delegate_child_ids.<locals>.<listcomp>O   s    111S1111r   ,?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   )r&   rowfounds     r   r(   z/_collect_delegate_child_ids.<locals>.<listcomp>W   s+    UUU#c$iu>T>TCI>T>T>Tr   )r   setjoinlenexecutefetchallupdatelist)connr"   dffrontierphcursorr/   s         @r   _collect_delegate_child_idsr<   E   s    
		BeeE11z111H
 XXcCMM)**ER E Eb E E)+E E35E E Ex
 

 VUUU):):UUUX   ;;r   c                    t          | |          }|rsd                    dt          |          z            }|                     d| d|           |                     d| d|           |                     d| d|           |S )Nr)   r*   *DELETE FROM messages WHERE session_id IN (r   IUPDATE sessions SET parent_session_id = NULL WHERE parent_session_id IN ("DELETE FROM sessions WHERE id IN ()r<   r1   r2   r3   )r7   r"   idsr:   s       r   _delete_delegate_childrenrB   \   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   )zlocking protocolznot authorized_last_init_error_wal_fallback_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_lockrF   )rN   s    r   _set_last_init_errorrQ      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).
    )rF   r   r   r   get_last_init_errorrS      s
     r   Session database not availableprefixc                     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).
    . c              3   D   K   | ]}|                                 v V  d S Nlower)r&   markercauses     r   	<genexpr>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): )rS   any_WAL_INCOMPAT_MARKERS)rU   hintr^   s     @r   format_session_db_unavailablerd      sq      !!E |||D

G
G
G
G1F
G
G
GGG \[&&&t&&&&r   r7   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)r3   fetchonesqlite3OperationalError
isinstancebytesdecodeUnicodeDecodeErrorstrstripr\   )r7   r.   modes      r   _on_disk_journal_moderr      s    ll011::<<#   tt
{tq6D$ 	;;w''DD! 	 	 	44	(,(83t99??""$$$dBs   '* =="A8 8
BBdb_labelrt   c                   	 |                      d                                          }|r|d         dk    rdS n# t          j        $ r Y nw xY w	 |                      d           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.

    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.
    rf   r   walzPRAGMA journal_mode=WALc              3       K   | ]}|v V  	d S rZ   r   )r&   r]   rN   s     r   r_   z*apply_wal_with_fallback.<locals>.<genexpr>  s'      EEV6S=EEEEEEr   zPRAGMA journal_mode=DELETENdelete)
r3   rh   ri   rj   ro   r\   ra   rb   rr   _log_wal_fallback_once)r7   rt   current_modeexcexistingrN   s        @r   apply_wal_with_fallbackr}      s-   6||$9::CCEE 	LOu445#   .///u#   #hhnnEEEE/DEEEEE 	(..ux---1222xxxxxs(   5; AAA( (C?7A=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   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_lockrG   addloggerwarning)rt   r{   s     r   ry   ry     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	/
 	    s   AAA
A
)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 rZ   ro   r\   )r&   r]   r{   s     r   r_   z(is_malformed_db_error.<locals>.<genexpr>Z  s8      RRfvS)))RRRRRRr   )rk   ri   DatabaseErrorra   _MALFORMED_SCHEMA_MARKERS)r{   s   `r   is_malformed_db_errorr   Q  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)ro   _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_namenamecopy2exists	Exceptionr   r   )r   r   r   stampbackup_pathsuffixsidecarr{   s           r   _backup_db_filer   l  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          j        t          |           d          }	 |                    d                                           |                    d                                          }d |D             }|r1d                    |dd                   |                                 S |                    d                                           	 |                                 dS # t           j        $ r-}t          |          cY d}~|                                 S d}~ww xY w# |                                 w xY w)	zProbe 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.
    Nisolation_levelrf   zPRAGMA integrity_checkc                     g | ]D}|t          |d                                                    dk    /t          |d                    ES )r   okr   r&   rs     r   r(   z%_db_opens_cleanly.<locals>.<listcomp>  sE    OOO!qOS1YY__5F5F$5N5NC!II5N5N5Nr   z;    zSELECT COUNT(*) FROM sessions)	ri   connectro   r3   rh   r4   r1   closer   )r   r7   rowsproblemsr{   s        r   _db_opens_cleanlyr     s5    ?3w<<>>>D*++44666||455>>@@OOtOOO 	+99Xbqb\** 	

 	455>>@@@ 	

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

 	

s6   A8C0 2'C0 0D,?D'D,D/ 'D,,D/ /ET)backupr   c                   ddddd}t          |           } |                                 s
|  d|d<   |S |r%t          |           }|rt          |          nd|d<   	 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 )ax  Repair a state.db whose ``sqlite_master`` schema is malformed.

    Handles the "duplicate object definition" / malformed-schema class where
    even ``PRAGMA`` statements fail. Tries least-destructive recovery first
    and escalates:

      1. **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.
      2. **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   r   r   z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=OFFTr   dedup_schemar   zRstate.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   ro   ri   r   r3   r4   commitr   r   r   r   r   r   )r   r   reportbpathr7   dupestype_r   _countkeepr{   reasons               r   repair_state_db_schemar     si   * 	 F 7mmG>> $555w >((.3 =E


}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*	
 	
 	

 Msh   #E. BD 	E. D448E. .F"=FF"&#J 
A(I 2J I:J J K	-KK	ac	  
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,
    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,
    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,
    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,
    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
);

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

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 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);
zh
CREATE INDEX IF NOT EXISTS idx_messages_session_active
    ON messages(session_id, active, timestamp);
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            %          e 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dej        ddfdZdej        de	fdZedej        ddfd            Zedej        defd            Zedej        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ddZd Zedede ee eef         f         fd            Z!dej        ddfdZ"d Z#	 	 	 	 	 	 dd ed!ed"ed#e ee$f         d$ed%ed&ed'eddfd(Z%d ed!edefd)Z&d ed*eddfd+Z'd eddfd,Z(d ed'eddfd-Z)	 dd ed/ed0e*de	fd1Z+d ed/eddfd2Z,d edee         fd3Z-	 dd ed4ed"ee         ddfd5Z.d ed$eddfd6Z/d ed"eddfd7Z0	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 dd ed9ed:ed"ed;ed<ed=ed>ee*         d?ee*         d@ee         dAee         dBee         dCee         dDee         dEee         dFedGe	ddf$dHZ1	 	 dd ed!ed"edefdJZ2ddKdLdefdMZ3defdNZ4d edee ee$f                  fdOZ5dPedee         fdQZ6dRZ7edSee         dee         fdT            Z8d edSede	fdUZ9d edee         fdVZ:d edWe	de	fdXZ;dSedee ee$f                  fdYZ<dSedee         fdZZ=d[edefd\Z>d edee         fd]Z?	 	 	 	 	 	 	 	 	 	 	 dd!ed`e@e         daedbedce	ddedee	dfe	dge	dhe	diede@e ee$f                  fdjZA	 	 ddkedaedbede@e ee$f                  fdlZBd edee ee$f                  fdmZCdnZDeEdoe$de$fdp            ZFeEdoe$de$fdq            ZG	 	 	 	 	 	 	 	 	 	 	 	 	 dd edredoedsedte$duedvedwedxedyedze$d{e$d|e$d}ed~e	def dZHd ede@e ee$f                  ddfdZI	 dd ede	de@e ee$f                  fdZJ	 dd ededede ee$f         fdZK	 	 	 dd ededededeeLedf                  de ee$f         fdZMd edefdZN	 	 dd ede	de	de@e ee$f                  fdZOd ede@e         fdZPede@e ee$f                  de ee$f         de	fd            ZQd edede ee$f         fdZRd ededefdZS	 	 dd edaede	de@e ee$f                  fdZTededefd            ZUedede	fd            ZVedede	fd            ZWeEdedefd            ZX	 	 	 	 	 	 	 ddede@e         d`e@e         de@e         daedbedede	de@e ee$f                  fdZY	 	 ddedaedge	de@e ee$f                  fdZZ	 	 	 dd!edaedbede@e ee$f                  fdZ[	 	 	 	 	 	 dd!eddedge	dhe	de	d`e@e         defdZ\dd edefdZ]d edee ee$f                  fdZ^dd!ede@e ee$f                  fdZ_d eddfdZ`edKee         d eddfd            Za	 dd edKee         de	fdZb	 dd edKee         de	fdZc	 dde@e         dKee         defdZddefdZe	 ddKee         defdZf	 	 	 dded!edKee         defdZgdedee         fdZhdededdfdZiddZjdddded%edee	         dee	         ddf
dZkd_ddede	ddfdZlded%ede	fdÄZmdededee ee$f                  fdńZndede@e ee$f                  fdƄZod edee ee$f                  fdǄZpddɜdeded%eded ededdfd̄Zqd ede	fd̈́ZrddϜded%edaede@e ee$f                  fdЄZsdZtdede	fdӄZudefdԄZvdefdՄZw	 	 	 	 ddedede	dKee         de ee$f         f
dڄZxd edede	fd܄Zyd edee ee$f                  fd݄Zzde@e ee$f                  fdބZ{d ede	fd߄Z|d eddfdZ}d ededdfdZ~dS )	SessionDBz
    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?2   NFr   	read_onlyc                 v    |pt            _        | _        t          j                     _        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# t,          $ r Y nw xY wt/           j                  }|                    d          s  |             Y d }~d S d }~ww xY w# t,          $ r,}t3          t5          |          j         d|             d }~ww xY w)Nr   Fzfile:z?mode=roT      ?)uricheck_same_threadtimeoutr   )parentsexist_okc                     t          j        t           j                  ddd            _        t           j         j        _        t           j        d            j                            d            	                                 d S )NFr   )r   r   r   rD   rs   zPRAGMA foreign_keys=ON)
ri   r   ro   r   _connRowrow_factoryr}   r3   _init_schemaselfs   r   _connect_and_initz-SessionDB.__init__.<locals>._connect_and_init  s    $_%%&+   %)  
 *1
&'
ZHHHH
""#;<<<!!#####r   u`   state.db schema is malformed (%s) — attempting automatic repair (a backup copy is made first).r   r`   )DEFAULT_DB_PATHr   r   	threadingLock_lock_write_count_fts_enabled_fts_unavailable_warnedr   ri   r   r   r   parentmkdirr   r   r   r   r   r   r   r   getrQ   type__name__)r   r   r   r   r{   r   s   `     r   __init__zSessionDB.__init__  s'   1/"^%%
!',$
O	  %_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 4
C   E??E: D0/E:0
D=:E:<D==7E:4F :E??F 
F8'F33F8r{   r   c                 T    t          |                                           }d|v od|v S )Nzno such modulefts5r   )r{   errs     r   _is_fts5_unavailable_errorz$SessionDB._is_fts5_unavailable_error  s+    #hhnn3&86S=8r   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))r   r   r   r   r   )r   r{   s     r   _warn_fts5_unavailablez SessionDB._warn_fts5_unavailable	  sQ    !' 	F'+$% L	
 	
 	
 	
 	
r   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)r3   ri   rj   r   r   )r   r;   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                 v    t           D ]0}	 |                     d|            # t          j        $ r Y -w xY wd S )NzDROP TRIGGER IF EXISTS )_FTS_TRIGGERSr3   ri   rj   )r;   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 )Nr)   c              3      K   | ]}d V  dS r*   Nr   r&   _s     r   r_   z/SessionDB._fts_trigger_count.<locals>.<genexpr>,  s"      ;;;;;;;;r   zGSELECT COUNT(*) FROM sqlite_master WHERE type = 'trigger' AND name IN (r   r   )r1   r   r3   rh   intrk   ri   r   )r;   placeholdersr.   s      r   _fts_trigger_countzSessionDB._fts_trigger_count*  s    xx;;];;;;;nnC3?C C C
 
 (**	 	
 C!=!=I3q663q6JJJr   c                     dD ]}|                      d|            |                      d           |                      d           d S )Nmessages_ftsmessages_fts_trigramzDELETE FROM INSERT INTO messages_fts(rowid, content) SELECT id, COALESCE(content, '') || ' ' || COALESCE(tool_name, '') || ' ' || COALESCE(tool_calls, '') FROM messagesINSERT INTO messages_fts_trigram(rowid, content) SELECT id, COALESCE(content, '') || ' ' || COALESCE(tool_name, '') || ' ' || COALESCE(tool_calls, '') FROM messagesr3   )r;   
table_names     r   _rebuild_fts_indexeszSessionDB._rebuild_fts_indexes4  sq    B 	8 	8JNN6*667777	
 	
 	
 		
 	
 	
 	
 	
r   r   c                    	 |                     d| d           dS # t          j        $ r_}|                     |          r|                     |           Y d }~d S dt          |                                          v rY d }~dS  d }~ww xY w)NzSELECT * FROM  LIMIT 0Tzno such tableF)r3   ri   rj   r   r   ro   r\   )r   r;   r   r{   s       r   _fts_table_probezSessionDB._fts_table_probeI  s    		NN@J@@@AAA4' 	 	 	..s33 ++C000ttttt#c((.."2"222uuuuu	s     B*B#BBBddlc                     |                      ||          }|dS 	 |                    |           dS # t          j        $ r6}|                     |          s |                     |           Y d }~dS d }~ww xY w)NFT)r   executescriptri   rj   r   r   )r   r;   r   r   statusr{   s         r   _ensure_fts_schemazSessionDB._ensure_fts_schemaU  s     &&vz::>5
	   %%%4' 	 	 	22377 '',,,55555		s   3 A8+A33A8fnc                 *   d}t          | j                  D ]f}	 | 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|                                  |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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.
        NzBEGIN IMMEDIATE   r   lockedbusyz$database is locked after max retries)range_WRITE_MAX_RETRIESr   r   r3   r   BaseExceptionrollbackr   r   _CHECKPOINT_EVERY_N_WRITES_try_wal_checkpointri   rj   ro   r\   randomuniform_WRITE_RETRY_MIN_S_WRITE_RETRY_MAX_Stimesleep)r   r  last_errattemptresultr{   err_msgjitters           r   _execute_writezSessionDB._execute_writej  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,,...+   c((..**w&&&G*;*;"H!81!<<<!' 3 3" " 
6***   
'22
 
 	
s|   C5B/)A('B/(
B3BB
B	BB	BB/#C5/B3	3C56B3	7;C55E<A,E76E77E<c                 .   	 | 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 Y dS w xY w)u  Best-effort TRUNCATE WAL checkpoint.  Never raises.

        Flushes committed WAL frames back into the main DB file and
        truncates the WAL file to zero bytes.  Keeps the WAL from
        growing unbounded when many processes hold persistent
        connections.

        PASSIVE checkpoint was previously used here, but it never
        truncates the WAL file — the file stays at its high-water
        mark until an explicit TRUNCATE is called (which only
        happened inside the infrequent vacuum()).

        TRUNCATE may block writers briefly while checkpointing, but
        _try_wal_checkpoint is called off the hot path (every 50
        writes) and already runs under ``self._lock``, so the
        additional hold time is negligible.
        PRAGMA wal_checkpoint(TRUNCATE)r  r   z(WAL checkpoint: %d/%d pages checkpointed   N)r   r   r3   rh   r   debugr   )r   r  s     r   r  zSessionDB._try_wal_checkpoint  s    $	  ++5 (**   fQi!mmLLBq	6!9                     	 	 	DD	s5   B A#A9,B 9A==B  A=B 
BBc                     | j         5  | j        rL	 | j                            d           n# t          $ r Y n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.
        r  N)r   r   r3   r   r   r   s    r   r   zSessionDB.close  s     Z 	" 	"z "J&&'HIIII    D
  """!
	" 	" 	" 	" 	" 	" 	" 	" 	" 	" 	" 	" 	" 	" 	" 	" 	" 	"s,   A),A)
9A)9#A))A-0A-
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.
        z:memory:zNSELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'PRAGMA table_info("")r  r  rX   r         zNOT NULLNzDEFAULT  )ri   r   r   r3   r4   appendr1   r   )r  reftable_columnstblcolsr.   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.
        r   r!  r  r   """zALTER TABLE "z" ADD COLUMN "z" zreconcile %s.%s: %sN)r0  
SCHEMA_SQLitemsr3   r4   ri   rj   r0   rk   tupler6   r   replacer   r  )r   r;   expectedr   declared_colsr   	live_colsr.   r   r*  r+  	safe_namer{   s                r   _reconcile_columnszSessionDB._reconcile_columns  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                 H   | j                                         }|                    t                     |                     |           	 |                    d           n7# t          j        $ r%}t          	                    d|           Y d}~nd}~ww xY w|                    t                     |                     |          }d}|s|                     |           |                    d           |                                }||                    dt          f           n+t          |t          j                  r|d         n|d         }|d	k     rX|rT|                     |d
          }|du r5|                     |d
t&                    r|                    d           n
d}n|d}nd}|dk     r|r|                     |           dD ]g}	 |                    d|            # t          j        $ r:}|                     |          s |                     |           d}d}Y d}~ nd}~ww xY w|re|                     |dt,                    rG|                     |d
t&                    r+|                    d           |                    d           nd}nd}|dk     r,	 |                    d           n# t          j        $ r Y nw xY w|dk     rQ	 |                    dt/          d                      |                    d           n# t          j        $ r Y nw xY w|t          k     r|r|                    dt          f           	 |                    d           n# t          j        $ r Y nw xY w|r|                     |          t3          t4                    k     }	|                     |dt,                    | _        | j        r5|                     |d
t&                    }
|
r|	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: %sNTz*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   r   zDROP TABLE IF EXISTS r   r   r      z3UPDATE messages SET active = 1 WHERE active IS NULLrE   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%UPDATE schema_version SET version = ?zfCREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_title_unique ON sessions(title) WHERE title IS NOT NULL)r   r;   r   r4  r<  r3   ri   rj   r   r  DEFERRED_INDEX_SQLr   r   rh   SCHEMA_VERSIONrk   r   r   r  FTS_TRIGRAM_SQLr   r   FTS_SQLr!   r   r2   r   r   r   r   )r   r;   r{   fts5_availablefts_migrations_completer.   current_version_fts_trigram_exists_tbltriggers_need_repairtrigram_enableds              r   r   zSessionDB._init_schema#  sx    ""$$Z((( 	'''	QNN8   
 ' 	Q 	Q 	QLLJCPPPPPPPP	Q
 	/000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+## " *4++F333 H 	" 	"""NN+I4+I+IJJJJ&7 " " "#'#B#B3#G#G & % 77<<<-2N6;3!EEEEE" & < !33FNGTT< $ 7 7 &(>! !< #NN!0   #NN!0    7<3.3+##NNM    /   D##NNB  4J??	B B   NN
T    /   D//4K/;#%  	NN=    ' 	 	 	D	  	6 $(#:#:6#B#BSEWEW#W  $ 7 7PW X XD
   6"&"9"92O# # # 6'; 6--f555
sf   
A   B/BBG++H4:/H//H4(J> >KK:L L'&L'M* *M<;M<
session_idsourcemodelr   system_promptuser_idparent_session_idcwdc	                 X    fd}	|                      |	           dS )z)Shared INSERT OR IGNORE for session rows.c                     |                      drt          j                  nd t          j                    f	           d S )NzINSERT OR IGNORE INTO sessions (id, source, user_id, model, model_config,
                   system_prompt, parent_session_id, cwd, started_at)
                   VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?))r3   jsondumpsr  )	r7   rT  rP  r   rS  rN  rO  rQ  rR  s	    r   _doz*SessionDB._insert_session_row.<locals>._do  sd    LL9 0<FDJ|,,,$!%IKK
	    r   Nr  )
r   rN  rO  rP  r   rQ  rR  rS  rT  rY  s
    ```````` r   _insert_session_rowzSessionDB._insert_session_row  sb    	 	 	 	 	 	 	 	 	 	 	 	" 	C     r   c                 $     | j         ||fi | |S )z4Create a new session record. Returns the session_id.r[  )r   rN  rO  kwargss       r   create_sessionzSessionDB.create_session  s%      V>>v>>>r   
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 NULL)r3   r  )r7   r`  rN  s    r   rY  z"SessionDB.end_session.<locals>._do(  s8    LL4j*5    r   NrZ  )r   rN  r`  rY  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   r7   rN  s    r   rY  z%SessionDB.reopen_session.<locals>._do2  s+    LLU    r   NrZ  r   rN  rY  s    ` r   reopen_sessionzSessionDB.reopen_session0  s8    	 	 	 	 	
 	C     r   c                 L    rsdS fd}|                      |           dS )z?Persist the session working directory when a frontend knows it.Nc                 8    |                      df           d S )Nz(UPDATE sessions SET cwd = ? WHERE id = ?r   )r7   rT  rN  s    r   rY  z)SessionDB.update_session_cwd.<locals>._do>  s#    LLCc:EVWWWWWr   rZ  )r   rN  rT  rY  s    `` r   update_session_cwdzSessionDB.update_session_cwd9  sX     	 	F	X 	X 	X 	X 	X 	X 	C     r        r@holderttl_secondsc                 
   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)uz  Try to atomically acquire the compression lock for ``session_id``.

        Returns ``True`` on success (caller now owns the lock and must
        release via :meth:`release_compression_lock`).  Returns ``False``
        if another holder already owns a non-expired lock — the caller
        MUST NOT proceed with compression in that case (its rotation would
        race against the holder's, splitting the session lineage).

        Expired locks (``expires_at < now``) are reclaimed transparently:
        the stale row is deleted and the new holder acquires it. This
        prevents a crashed compressor from permanently blocking the
        session.

        Implementation: single-transaction DELETE-expired + INSERT-or-IGNORE,
        followed by a SELECT to confirm we got the row. SQLite serialises
        writes, so the whole sequence is atomic against other writers.
        Fc                    |                      df           |                      df           |                      df                                          }|d uo-t          |t          j                  r|d         n|d         k    S )NzEDELETE FROM compression_locks WHERE session_id = ? AND expires_at < ?ziINSERT OR IGNORE INTO compression_locks (session_id, holder, acquired_at, expires_at) VALUES (?, ?, ?, ?)z9SELECT holder FROM compression_locks WHERE session_id = ?rm  r   )r3   rh   rk   ri   r   )r7   r.   
expires_atrm  r   rN  s     r   rY  z3SessionDB.try_acquire_compression_lock.<locals>._dos  s    LL:S!   LL& VS*5	   ,,K  hjj  d? !+C!=!=IH3q6( r   z+try_acquire_compression_lock(%s) failed: %sN)r  boolr  ri   Errorr   r   )r   rN  rm  rn  rY  r{   rq  r   s    ``   @@r   try_acquire_compression_lockz&SessionDB.try_acquire_compression_lockW  s    .  	5ikk;&
	 	 	 	 	 	 	 	.		++C00111} 	 	 	NN=C   55555	s   !A BA==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 )NzADELETE FROM compression_locks WHERE session_id = ? AND holder = ?r   )r7   rm  rN  s    r   rY  z/SessionDB.release_compression_lock.<locals>._do  s0    LL6V$    r   z'release_compression_lock(%s) failed: %s)r  ri   rs  r   r   )r   rN  rm  rY  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 >= ?rm  r   )r  r   r3   rh   rk   ri   r   )r   rN  r   r.   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   )r7   rP  rz  rN  s    r   rY  z*SessionDB.update_session_meta.<locals>._do  s0    LL_"E:6    r   NrZ  )r   rN  rz  rP  rY  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   )r7   rN  rQ  s    r   rY  z+SessionDB.update_system_prompt.<locals>._do  s.    LLD
+    r   NrZ  )r   rN  rQ  rY  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   )r7   rP  rN  s    r   rY  z+SessionDB.update_session_model.<locals>._do  s.    LL<
#    r   NrZ  )r   rN  rP  rY  s    `` r   update_session_modelzSessionDB.update_session_model  s>    	 	 	 	 	 	
 	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billing_modeapi_call_countabsolutec                     |                      |d|           |rdnd|||||||	|	|
||||||||f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)rP  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 = ?c                 4    |                                 d S rZ   r   )r7   paramssqls    r   rY  z*SessionDB.update_token_counts.<locals>._doI  s    LLf%%%%%r   N)r[  r  )r   rN  r  r  rP  r  r  r  r  r  r  r  r  r  r  r  r  r  rY  r  r  s                      @@r   update_token_countszSessionDB.update_token_counts  s    B 	  Ye DDD )	##CC*#C* #
&	& 	& 	& 	& 	& 	&C     r   r  c                 (     | j         ||fd|i| |S )zHEnsure a session row exists (INSERT OR IGNORE). Accepts optional kwargs.rP  r]  )r   rN  rO  rP  r^  s        r   ensure_sessionzSessionDB.ensure_sessionM  s,     	! VKK5KFKKK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-   )rk   r6  r6   r   s     r   r(   zESessionDB.prune_empty_ghost_sessions.<locals>._do.<locals>.<listcomp>g  s7    SSS:a%77D1Q44QtWSSSr   r)   r*   r@   r   r3   r4   r1   r2   )r7   r   rA   r   cutoffs       r   rY  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_filesr2   )r   r  rY  removed_idsr'   r  s        @r   prune_empty_ghost_sessionsz$SessionDB.prune_empty_ghost_sessionsX  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  r3   rowcount)r7   r   r  r  s      r   rY  z=SessionDB.finalize_orphaned_compression_sessions.<locals>._do  s9    )++C\\( f+ F. ?"r   r   )r  r  )r   rY  r  s     @r   &finalize_orphaned_compression_sessionsz0SessionDB.finalize_orphaned_compression_sessionsv  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 = ?Nr   r   r3   rh   dictr   rN  r;   r.   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   \_zSSELECT id FROM sessions WHERE id LIKE ? ESCAPE '\' ORDER BY started_at DESC LIMIT 2c                     g | ]
}|d          S r,   r   r&   r.   s     r   r(   z0SessionDB.resolve_session_id.<locals>.<listcomp>  s    >>>Ss4y>>>r   Nr  r   )r  r7  r   r   r3   r4   r2   )r   r  exactescapedr;   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d   titlec                 R   | sdS 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]rX   zB[\u200b-\u200f\u2028-\u202e\u2060-\u2069\ufeff\ufffc\ufff9-\ufffb]z\s+r$  zTitle too long (z chars, max r   )resubrp   r2   r   MAX_TITLE_LENGTH
ValueError)r  cleaneds     r   sanitize_titlezSessionDB.sanitize_title  s      	4
 &<b%HH &Q
 
 &g..4466 	4w<<)444Z3w<<ZZY=WZZZ   r   c                 r    |                                fd}|                     |          }|dk    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).
        c                     rI|                      df          }|                                }|rt          d d|d                    |                      df          }|j        S )Nz3SELECT id FROM sessions WHERE title = ? AND id != ?zTitle 'z' is already in use by session r-   z*UPDATE sessions SET title = ? WHERE id = ?)r3   rh   r  r  )r7   r;   conflictrN  r  s      r   rY  z(SessionDB.set_session_title.<locals>._do  s     
IJ'  "??,, $X%XXQUXX   \\<
# F ?"r   r   )r  r  )r   rN  r  rY  r  s    ``  r   set_session_titlezSessionDB.set_session_title  sV     ##E**	# 	# 	# 	# 	# 	#" &&s++!|r   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.z'SELECT title FROM sessions WHERE id = ?Nr  r   r   r3   rh   r  s       r   get_session_titlezSessionDB.get_session_title  s    Z 	$ 	$Z''9J= F //##C		$ 	$ 	$ 	$ 	$ 	$ 	$ 	$ 	$ 	$ 	$ 	$ 	$ 	$ 	$
  #,s7||,r  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'
                      AND child.started_at >= parent.ended_at
                  ),
                  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'
                      AND child.started_at >= parent.ended_at
                  ),
                  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())r3   r  rh   )r7   r;   r  r  rN  s      r   rY  z+SessionDB.set_session_archived.<locals>._do   sq    \\> Zh)=A>A! !FD H8a<<<<(:;;DDFFqIOr   r   rZ  )r   rN  r  rY  r  s    ``  r   set_session_archivedzSessionDB.set_session_archived  sB    &	 &	 &	 &	 &	 &	N &&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  )r   r  r;   r.   s       r   get_session_by_titlezSessionDB.get_session_by_titleJ  s    Z 	$ 	$Z''85( F //##C		$ 	$ 	$ 	$ 	$ 	$ 	$ 	$ 	$ 	$ 	$ 	$ 	$ 	$ 	$
  )tCyyyT)r  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).
        r  r  r  r  r   r  zaSELECT id, title, started_at FROM sessions WHERE title LIKE ? ESCAPE '\' ORDER BY started_at DESC #%Nr   r-   )r  r7  r   r   r3   r4   )r   r  r  r  r;   numbereds         r   resolve_session_by_titlez"SessionDB.resolve_session_by_titleS  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  r  r  r  r  r   r  zESELECT title FROM sessions WHERE title = ? OR title LIKE ? ESCAPE '\'r  c                     g | ]
}|d          S )r  r   r  s     r   r(   z7SessionDB.get_next_title_in_lineage.<locals>.<listcomp>  s    BBBGBBBr   Nz^.* #(\d+)$z #)
r  matchgroupr7  r   r   r3   r4   maxr   )
r   r  r  baser  r;   r|   max_numtms
             r   get_next_title_in_lineagez#SessionDB.get_next_title_in_lineagep  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                     |}t          d          D ]`}| j        5  | j                            d||f          }|                                }ddd           n# 1 swxY w Y   ||c S |d         }a|S )a  Walk the compression-continuation chain forward and return the tip.

        A compression continuation is a child session where:
        1. The parent's ``end_reason = 'compression'``
        2. The child was created AFTER the parent was ended (started_at >= ended_at)

        The second condition distinguishes compression continuations from
        delegate subagents or branch children, which can also have a
        ``parent_session_id`` but were created while the parent was still live.

        Returns the session_id of the latest continuation in the chain, or the
        input ``session_id`` if it isn't part of a compression chain (or if the
        input itself doesn't exist).
        r  zSELECT id FROM sessions WHERE parent_session_id = ?   AND started_at >= (      SELECT ended_at FROM sessions       WHERE id = ? AND end_reason = 'compression'  ) ORDER BY started_at DESC LIMIT 1Nr-   )r  r   r   r3   rh   )r   rN  currentr   r;   r.   s         r   get_compression_tipzSessionDB.get_compression_tip  s      s 	  	 A ( (++7 g&	 	 oo''( ( ( ( ( ( ( ( ( ( ( ( ( ( ( {$iGGs   2AA	 A	   Texclude_sourceslimitoffsetinclude_childrenmin_message_countproject_compression_tipsorder_by_last_activeinclude_archivedarchived_onlyid_queryc                    g }g }|s?|                     t                     |                     t          d           d           |r*|                     d           |                     |           |rMd                    d |D                       }|                     d| d           |                    |           |dk    r*|                     d	           |                     |           |
r|                     d
           n|	s|                     d           |rdd                    |           nd}|pd                                                                }|rt|}g }|rWd}d|                    dd                              dd                              dd          z   dz   }|g}|r| d| nd| }d| d| d}||z   |z   ||gz   }n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.
        s.model_config IS NULLs.source = ?r)   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 (r   r   s.message_count >= ?s.archived = 1s.archived = 0zWHERE  AND rX   znEXISTS (SELECT 1 FROM chain cq        WHERE cq.root_id = s.id          AND LOWER(cq.cur_id) LIKE ? ESCAPE '\')r  r  r  r  r   r  zr
                WITH RECURSIVE chain(root_id, cur_id) AS (
                    SELECT s.id, s.id FROM sessions s a  
                    UNION ALL
                    SELECT c.root_id, child.id
                    FROM chain c
                    JOIN sessions parent ON parent.id = c.cur_id
                    JOIN sessions child ON child.parent_session_id = c.cur_id
                    WHERE parent.end_reason = 'compression'
                      AND child.started_at >= parent.ended_at
                ),
                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 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,
                    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 ?
            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
                zY
                ORDER BY s.started_at DESC
                LIMIT ? OFFSET ?
            N_preview_raw<   ...preview_effective_last_activer`  r    r-   )r-   ended_atr`  message_counttool_call_countr  last_activer  rP  rQ  rT  _lineage_root_id)r%  _LISTABLE_CHILD_SQLr   r1   extendrp   r\   r7  r   r   r3   r4   r  popr2   r   r  _get_session_rich_row)"r   rO  r  r  r  r  r  r  r  r  r  r  where_clausesr  r   	where_sql	id_needleouter_where	id_params	id_clauselike_patternqueryr;   r   rB  r.   r   rawtext	projectedtip_idtip_rowmergedr   s"                                     r   list_sessions_richzSessionDB.list_sessions_rich  s   R  	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/***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	 `	+ $K#%I I  ''f55==c5IIQQRUW\]]^ 
 *N	6?Yy22y222EYiEYEY '7@' 'H I' ' 'ET f_y0E6?BFF   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<< $$Q''' a 3 3C
 g~~&-cls-.tW)*  (((( Hs   >0H::H>H>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  rX   r  r  r  )chrordr   r   r3   r4   r  r  rp   r2   r%  )r   r  r  r  rU   	prefix_hir  r;   r   runsr.   r   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                 |   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 )zFetch a single session with the same enriched columns as
        ``list_sessions_rich`` (preview + last_active). Returns None if the
        session doesn't exist.
        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.id = ?
        Nr  rX   r  r  r  )r   r   r3   rh   r  r  rp   r2   )r   rN  r  r;   r.   r   r  r  s           r   r  zSessionDB._get_session_rich_row  s   
  Z 	$ 	$Z''
}==F//##C	$ 	$ 	$ 	$ 	$ 	$ 	$ 	$ 	$ 	$ 	$ 	$ 	$ 	$ 	$  	4IIeeNB''--// 	ss8DCHHrMM55rBAiLLAiLs   1AAAz json:contentc                     |(t          |t          t          t          t          f          r|S 	 | j        t          j        |          z   S # t          t          f$ r 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.
        )
rk   ro   rl   r   float_CONTENT_JSON_PREFIXrW  rX  	TypeErrorr  clsr!  s     r   _encode_contentzSessionDB._encode_content	  st     ?j3sE2JKK?N	 +dj.A.AAA:& 	  	  	 w<<	 s   A
 
 A-,A-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)rk   ro   
startswithr$  rW  loadsr2   JSONDecodeErrorr%  r   r   r&  s     r   _decode_contentzSessionDB._decode_content*	  s     gs## 	(:(:3;S(T(T 	z'#c.F*G*G*H*H"IJJJ()4   +    s   -A 2BBrole	tool_name
tool_callstool_call_idtoken_countfinish_reason	reasoningreasoning_contentreasoning_detailscodex_reasoning_itemscodex_message_itemsplatform_message_idobservedc                   	
 |rt          j        |          nd|rt          j        |          nd|rt          j        |          nd|rt          j        |          nd|                     |          d|&t          |t                    rt          |          nd	
fd}|                     |          S )a  
        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.
        Nr   r  c                     |                      dt          j                    	
rdndf          }|j        }dk    r|                      df           n|                      df           |S )Na|  INSERT INTO messages (session_id, role, content, tool_call_id,
                   tool_calls, tool_name, timestamp, token_count, finish_reason,
                   reasoning, reasoning_content, reasoning_details, codex_reasoning_items,
                   codex_message_items, platform_message_id, observed)
                   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 = ?)r3   r  	lastrowid)r7   r;   msg_idcodex_items_jsoncodex_message_items_jsonr3  num_tool_callsr:  r9  r4  r5  reasoning_details_jsonr.  rN  stored_contentr2  r1  tool_calls_jsonr/  s      r   rY  z%SessionDB.append_message.<locals>._dom	  s    \\N " #IKK!%*$,'!(AAq! F2 %F !!M#Z0    XM   Mr   )rW  rX  r(  rk   r6   r2   r  )r   rN  r.  r!  r/  r0  r1  r2  r3  r4  r5  r6  r7  r8  r9  r:  rY  r?  r@  rA  rB  rC  rD  s    `` ` `````   `` @@@@@@r   append_messagezSessionDB.append_message8	  sP   B !+DJ()))&* 	 %/DJ,---*. 	 #-DJ*+++(, 	! 5?H$*Z000D --g66 !0::t0L0LSS___RSN(	 (	 (	 (	 (	 (	 (	 (	 (	 (	 (	 (	 (	 (	 (	 (	 (	 (	 (	 (	T ""3'''r   messagesc                 D      fd}                      |           dS )a   Atomically replace every message 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.
        c                    |                      df           |                      df           t          j                    }d}d}D ])}|                    dd          }|                    d          }|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 }|rt          j        |          nd }|                    d          p|                    d          }|                      d|                    |                    d                    |                    d          ||                    d          ||                    d          |                    d          |dk    r|                    d          nd |dk    r|                    d          nd |
||||                    d          rdndf           |dz  }|)|t          |t                    rt          |          ndz  }|dz  }+|                      d||f           d S )N)DELETE FROM messages WHERE session_id = ?GUPDATE sessions SET message_count = 0, tool_call_count = 0 WHERE id = ?r   r.  r  r0  	assistantr6  r7  r8  r9  
message_ida  INSERT INTO messages (session_id, role, content, tool_call_id,
                       tool_calls, tool_name, timestamp, token_count, finish_reason,
                       reasoning, reasoning_content, reasoning_details, codex_reasoning_items,
                       codex_message_items, platform_message_id, observed)
                       VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)r!  r1  r/  r2  r3  r4  r5  r:  r  gư>zGUPDATE sessions SET message_count = ?, tool_call_count = ? WHERE id = ?)	r3   r  r   rW  rX  r(  rk   r6   r2   )r7   now_tstotal_messagestotal_tool_callsrN   r.  r0  r6  r7  r8  rB  r?  r@  rD  platform_msg_idrF  r   rN  s                  r   rY  z'SessionDB.replace_messages.<locals>._do	  s   LL;j]   LLY  
 Y[[FN  9 9wwvy11 WW\22
DHKDWDWCGG,?$@$@$@]a!8<8K8KCGG3444QU & 7;k6I6ICGG1222t $
 6GPDJ0111D ' :OXDJ4555TX ! 8KTDJ2333PT ) =G"P$*Z"8"8"8D GG122Kcggl6K6K   R #,,SWWY-?-?@@//',,..00040C0C,,,8<8K8K 3444QU.(0' WWZ007a!  2 !#)$+5j$+G+GNJQ$ $LLY!1:>    r   NrZ  )r   rN  rF  rY  s   ``` r   replace_messageszSessionDB.replace_messages	  sL    J	 J	 J	 J	 J	 J	 J	X 	C     r   include_inactivec                 0   |rdnd}| j         5  | j                            d| d|f          }|                                }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.
        rX    AND active = 1z+SELECT * FROM messages WHERE session_id = ? ORDER BY idNr!  r0  zDFailed to deserialize tool_calls in get_messages, falling back to [])r   r   r3   r4   r  r-  r   rW  r+  r,  r%  r   r   r%  )	r   rN  rR  active_clauser;   r   r  r.   rN   s	            r   get_messageszSessionDB.get_messages	  s    /E4EZ 	% 	%Z''/ / / / F
 ??$$D	% 	% 	% 	% 	% 	% 	% 	% 	% 	% 	% 	% 	% 	% 	%  
	 
	Cs))CC!%!5!5c)n!E!EIww|$$ ++(,
3|3D(E(EC%%,i8 + + +NN#ijjj(*C%%%+ MM#s#   5AAA'C5C=<C=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)rY  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!  r0  zKFailed to deserialize tool_calls in get_messages_around, falling back to [])r   r   r3   rh   r4   r6   reversedr  r-  r   rW  r+  r,  r%  r   r   r%  r  r2   )r   rN  rX  rY  anchor_existsbefore_rows
after_rowsr   r  r.   rN   r[  r\  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   userrK  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   )rY  rY  )rY  r[  r\  bookend_startbookend_endNc                 t    g | ]4}|                     d           k    s|                     d          v 2|5S )r-   r.  )r   )r&   r  rX  keep_sets     r   r(   z/SessionDB.get_anchored_view.<locals>.<listcomp>
  sM       55;;"333quuV}}7P7P 7P7P7Pr   r-   r  rX   r)   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 (r   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!  r0  zIFailed to deserialize tool_calls in get_anchored_view, falling back to [])	r  r-  r   rW  r+  r,  r%  r   r   )r.   rN   r   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r[  r\  c                 &    g | ]} |          S r   r   r&   r   rm  s     r   r(   z/SessionDB.get_anchored_view.<locals>.<listcomp>
  s!    FFFahhqkkFFFr   c                 &    g | ]} |          S r   r   ro  s     r   r(   z/SessionDB.get_anchored_view.<locals>.<listcomp>
  s!    BBBAHHQKKBBBr   )ra  r0   r   r1   r6   r   r3   r4   r]  r   ro   r   )r   rN  rX  rY  rd  re  	primitivewindow_rowsfiltered_windowwindow_min_idwindow_max_idbookend_start_rowsbookend_end_rowsrole_clauserole_paramsrole_placeholdersrm  rj  s   ` `             @@r   get_anchored_viewzSessionDB.get_anchored_view`
  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 | j         5  	 | j                            d|f                                          }n# t          $ r |cY cddd           S w xY w||cddd           S |}|h}t          d          D ]}	 | j                            d|f                                          }n # t          $ r |cY c cddd           S w xY w||c cddd           S t          |d          r|d         n|d         }|r||v r|c cddd           S |                    |           	 | j                            d|f                                          }n # t          $ r |cY c cddd           S w xY w||c cddd           S |}	 ddd           n# 1 swxY w Y   |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 first descendant in the chain that has at least one message
        row. If the original session already has messages, or no descendant
        has any, 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.
        z3SELECT 1 FROM messages WHERE session_id = ? LIMIT 1N    z]SELECT id FROM sessions WHERE parent_session_id = ? ORDER BY started_at DESC, id DESC LIMIT 1keysr-   r   )r   r   r3   rh   r   r  hasattrr   )	r   rN  r.   r  seenr   	child_rowchild_idmsg_rows	            r   resolve_resume_session_idz#SessionDB.resolve_resume_session_id
  sZ   $  	Z )	# )	#"j((IM  (**   " " "!!!)	# )	# )	# )	# )	# )	# )	# )	#"!)	# )	# )	# )	# )	# )	# )	# )	# !G9D2YY # #& $
 2 2D !
	! !
 hjj I ! & & &%%%%%3)	# )	# )	# )	# )	# )	# )	# )	#0&$%%%7)	# )	# )	# )	# )	# )	# )	# )	#8 /6i.H.HZ9T??iXYl &8t#3#3%%%=)	# )	# )	# )	# )	# )	# )	# )	#> """&"j00M!  hjj G ! & & &%%%%%M)	# )	# )	# )	# )	# )	# )	# )	#J&&#OOQ)	# )	# )	# )	# )	# )	# )	# )	#R #3#!)	# )	# )	# )	# )	# )	# )	# )	# )	# )	# )	# )	# )	# )	# )	#T s   F".=F"A	F"AF"+F".B21F"2C>F"CF"$)F"F"0.EF"E<+F";E<<F"F""F&)F&include_ancestorsc                    |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   g }|D ]}	|                     |	d	                   }
|	d
         dv r6t          |
t                    r!t          |
                                          }
|	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|                    |           |S )aH  
        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`.
        rX   rT  r)   c              3      K   | ]}d V  dS r   r   r   s     r   r_   z9SessionDB.get_messages_as_conversation.<locals>.<genexpr>-  s"      #=#=AC#=#=#=#=#=#=r   zSELECT role, content, tool_call_id, tool_calls, tool_name, finish_reason, reasoning, reasoning_content, reasoning_details, codex_reasoning_items, codex_message_items, platform_message_id, observed FROM messages WHERE session_id IN (r   rU  Nr!  r.  >   rc  rK  r.  r!  r1  r/  r0  zKFailed to deserialize tool_calls in conversation replay, falling back to []r9  rL  r:  TrK  r3  r4  r5  r6  z=Failed to deserialize reasoning_details, falling back to Noner7  zAFailed to deserialize codex_reasoning_items, falling back to Noner8  z?Failed to deserialize codex_message_items, falling back to None)_session_lineage_root_to_tipr   r1   r   r3   r6  r4   r-  rk   ro   r   rp   rW  r+  r,  r%  r   r   #_is_duplicate_replayed_user_messager%  )r   rN  r  rR  session_idsrV  r   r   rF  r.   r!  rN   s               r   get_messages_as_conversationz&SessionDB.get_messages_as_conversation  sw    "l 	H;;JGGK.E4EZ 		 		88#=#=#=#=#===L:%%/ 7C/ / !	/ / /
 k""  hjj 		 		 		 		 		 		 		 		 		 		 		 		 		 		 		  6	! 6	!C**3y>::G6{333
7C8P8P3*73399;;v;7;;C>" :&).&9N#; 4#&{#3K <  ++(,
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    sZ   A!BBB<E5FFH""5II&J5J<;J<K&&5LL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 = ?r~  rS  r   )r0   r   r  r   r%  r   r3   rh   r  r6   r]  )r   rN  chainr  r  r   r.   s          r   r  z&SessionDB._session_lineage_root_to_tipq  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rN   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.  rc  Fr!  TrK  r0  )r   rk   ro   r]  )rF  rN   r!  prevs       r   r  z-SessionDB._is_duplicate_replayed_user_message  s    776??f$$5'')$$'3'' 	w 	5X&& 	 	Dxx6))dhhy.A.AW.L.Lttxx;..DHHY4G4G.488T`KaKa.uuur   target_message_idc                    | j         5  | j                            df                                          }ddd           n# 1 swxY w Y   |t	          d d           t          |          }|                    d          dk    r)t	          d|                    d          d d	          |                     |                    d
                    |d
<   g }fd}|                     |          }| j         5  | j                            df                                          }ddd           n# 1 swxY w Y   |r|d         |d         nd}t          |          ||dS )u  Soft-delete all messages with id >= ``target_message_id`` in *session_id*.

        The target message itself becomes inactive as well so the caller
        can pre-fill it as the next user prompt without it appearing
        twice in the replayed transcript.  Rewound rows are kept on
        disk with ``active=0`` for audit / forensic inspection — use
        :meth:`get_messages` with ``include_inactive=True`` to see them.

        Returns a dict::

            {
                "rewound_count": int,    # number of rows newly flipped to active=0
                "target_message": dict,  # full row dict of the target
                "new_head_id":   int|None  # id of the last still-active row, or None
            }

        Raises ``ValueError`` if the target message does not exist in
        *session_id* or if its role is not ``"user"``.

        Always increments ``sessions.rewind_count`` — even when the
        target is already inactive — so the counter accurately reflects
        the number of rewind operations performed against the session.
        Idempotent on the ``active`` flag: re-rewinding past the same
        target is a no-op on row state but still bumps the counter.
        z6SELECT * FROM messages WHERE id = ? AND session_id = ?Nzmessage z not found in session r.  rc  z1rewind target must be a 'user' message (got role=z, id=r   r!  c                    |                      df          }d |                                D             }|r9d                    d |D                       }|                      d| d|           |                      df           |S )NzGSELECT id FROM messages WHERE session_id = ? AND id >= ? AND active = 1c                     g | ]
}|d          S r   r   r   s     r   r(   z<SessionDB.rewind_to_message.<locals>._do.<locals>.<listcomp>      333A1Q4333r   r)   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 (r   zMUPDATE sessions SET rewind_count = COALESCE(rewind_count, 0) + 1 WHERE id = ?)r3   r4   r1   )r7   r;   rA   r   rN  r  s       r   rY  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)
r   r   r3   rh   r  r  r   r-  r  r2   )	r   rN  r  r.   
target_rowrewoundrY  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   r)   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 (r   r  )r7   r;   rA   r   rN  r  s       r   rY  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   rZ  )r   rN  r  rY  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.
        rX   rT  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 r   r  rX   rk   r  r   r&   ps     r   r(   z7SessionDB.list_recent_user_messages.<locals>.<listcomp>'  sW       *+!!T**/0uuV}}/F/F EE&"%%/F/F/Fr   r$  c              3      K   | ]}||V  	d S rZ   r   r&   r  s     r   r_   z6SessionDB.list_recent_user_messages.<locals>.<genexpr>+  s'      ">">A">1">">">">">">r   [multimodal content]P   M   r  r-   	timestamp)r-   r  r  )r   r   r3   r   r4   r-  rk   r6   r1   rp   ro   splitr2   r%  )r   rN  r  rR  rV  r;   r   r  r.   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                 <   g dt           j        dt          ffd}t          j        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``)
        r  r   c                                          |                     d                     dt                    dz
   dS )Nr    Qr   )r%  r  r2   )r  _quoted_partss    r   _preserve_quotedz8SessionDB._sanitize_fts5_query.<locals>._preserve_quotedW  s?      ,,,73}--17777r   z"[^"]*"z[+{}():\"^]r$  z\*+*z(^|\s)\*z\1z(?i)^(AND|OR|NOT)\b\s*rX   z(?i)\s+(AND|OR|NOT)\s*$z\b(\w+(?:[._-]\w+)+)\bz"\1"r  r  )r  Matchro   r  rp   	enumerater7  )r  r  	sanitizediquotedr  s        @r   _sanitize_fts5_queryzSessionDB._sanitize_fts5_queryB  s-   & !	8 	8S 	8 	8 	8 	8 	8 	8 F:'7??	 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  chr  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  )r&   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   source_filterrole_filtersortc	           	      8     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k    r8|s5|                                }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             }n# t&          j        $ r g }Y nw xY wddd           n# 1 swxY w Y   nd% |                                D             p|g} g }!g }"| D ]i}|                    d&d'                              d(d)                              d*d+          }#|!                    d,           |"d(|# d(d(|# d(d(|# d(gz  }"jd-d.                    |!           dg}$|K|$                    dd
                    d/ |D                        d           |"                    |           |K|$                    dd
                    d0 |D                        d           |"                    |           |rK|$                    dd
                    d1 |D                        d           |"                    |           d2d                    |$           d3}%|"                    ||g           | d4         g|"z   }" j        5   j                            |%|"          }&d5 |&                                D             }ddd           n# 1 swxY w Y   n~ j        5  	  j                            ||          }'d6 |'                                D             }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 ]5}(	  j        5   j                            d7|(d8         |(d8         f          })g }*|)                                D ]}+|+d9         },                     |,          }-t          |-t,                    rBd: |-D             }.d                    d; |.D                                                       }/|/pd<}0nt          |-t                    r|-}0nd=}0|*                    |+d>         |0dd?         d@           	 ddd           n# 1 swxY w Y   |*|(dA<   !# t.          $ r	 g |(dA<   Y 3w xY w|D ]}(|(                    d9d           |S )Ba  
        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``) rows are excluded by default. Pass
        ``include_inactive=True`` to search every row.
        )newestoldestNr  zORDER BY m.timestamp DESC, rankr  zORDER BY m.timestamp ASC, rankzORDER BY rankzmessages_fts MATCH ?zm.active = 1r)   c              3      K   | ]}d V  dS r   r   r   s     r   r_   z,SessionDB.search_messages.<locals>.<genexpr>  s"      *F*F13*F*F*F*F*F*Fr   zs.source IN (r   c              3      K   | ]}d V  dS r   r   r   s     r   r_   z,SessionDB.search_messages.<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_   z,SessionDB.search_messages.<locals>.<genexpr>  s"      (B(B(B(B(B(B(B(Br   zm.role IN (r  a  
            SELECT
                m.id,
                m.session_id,
                m.role,
                snippet(messages_fts, 0, '>>>', '<<<', '...', 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 z
            z&
            LIMIT ? OFFSET ?
        r2  c                 j    g | ]/}|                                 d v                    |          -|0S >   ORANDNOT)upperr  r&   r  r   s     r   r(   z-SessionDB.search_messages.<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   N)r  r  s     r   r_   z,SessionDB.search_messages.<locals>.<genexpr>  sE       ! !+,""Q&! ! ! ! ! !r   r   >   r  r  r  r3  r$  zmessages_fts_trigram MATCH ?c              3      K   | ]}d V  dS r   r   r   s     r   r_   z,SessionDB.search_messages.<locals>.<genexpr>2  s"      =Y=Yac=Y=Y=Y=Y=Y=Yr   c              3      K   | ]}d V  dS r   r   r   s     r   r_   z,SessionDB.search_messages.<locals>.<genexpr>5  s"      A_A_!#A_A_A_A_A_A_r   c              3      K   | ]}d V  dS r   r   r   s     r   r_   z,SessionDB.search_messages.<locals>.<genexpr>8  s"      ;U;UAC;U;U;U;U;U;Ur   a  
                    SELECT
                        m.id,
                        m.session_id,
                        m.role,
                        snippet(messages_fts_trigram, 0, '>>>', '<<<', '...', 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 z
                    z6
                    LIMIT ? OFFSET ?
                c                 ,    g | ]}t          |          S r   r  r  s     r   r(   z-SessionDB.search_messages.<locals>.<listcomp>T  s    "N"N"N499"N"N"Nr   c                 >    g | ]}|                                 d v|S r  )r  r  s     r   r(   z-SessionDB.search_messages.<locals>.<listcomp>[  s6     ! ! !wwyy(<<< <<<r   r  r  r  r  r   r  z`(m.content LIKE ? ESCAPE '\' OR m.tool_name LIKE ? ESCAPE '\' OR m.tool_calls LIKE ? ESCAPE '\')r   z OR c              3      K   | ]}d V  dS r   r   r   s     r   r_   z,SessionDB.search_messages.<locals>.<genexpr>i  s"      >Z>Zqs>Z>Z>Z>Z>Z>Zr   c              3      K   | ]}d V  dS r   r   r   s     r   r_   z,SessionDB.search_messages.<locals>.<genexpr>l  s"      B`B`13B`B`B`B`B`B`r   c              3      K   | ]}d V  dS r   r   r   s     r   r_   z,SessionDB.search_messages.<locals>.<genexpr>o  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  r  s     r   r(   z-SessionDB.search_messages.<locals>.<listcomp>  s    KKKStCyyKKKr   c                 ,    g | ]}t          |          S r   r  r  s     r   r(   z-SessionDB.search_messages.<locals>.<listcomp>  s    FFFStCyyFFFr   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(   z-SessionDB.search_messages.<locals>.<listcomp>  sW     * * *67#-a#6#6*;<55==F;R;R !"fb 1 1;R;R;Rr   c              3      K   | ]}||V  	d S rZ   r   r  s     r   r_   z,SessionDB.search_messages.<locals>.<genexpr>  s'      +G+G!Q+GA+G+G+G+G+G+Gr   r  rX   r.     r  context)r   rp   r  rk   ro   r\   r%  r1   r  r  r  r  ra   r  r7  r   r   r3   r4   ri   rj   r-  r6   r   r  )1r   r  r  r  r  r  r  r  rR  	sort_normorder_by_sqlr  r  source_placeholdersexclude_placeholdersrz  r	  r  is_cjk	raw_query	cjk_count_tokens_for_check_any_short_cjktokensr/  toktrigram_query	tri_where
tri_paramstri_sql
tri_cursorr  non_op_tokenstoken_clauseslike_paramsesc
like_wherelike_sqllike_cursorr;   r  
ctx_cursorcontext_msgsr   r  r  r  r  r  s1   `                                                r   search_messageszSessionDB.search_messages  s   F   	I 	EKKMM 	I))%00 	I
 dC   	

**,,I 444 	I   <LL("";LL*L 00w 	1  000$"%((*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** {	GC((..00I	22I! ! ! !$??,,! ! ! ! ! ! ! !0A! ! !  N A~~n~ #**! I ICyy{{&:::S))))S3;;sD+A+A%AC%GHHHH #;<	$1?
' 5$$^444 ,$$%\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Z O OO%)Z%7%7%L%L
 #O"N
8K8K8M8M"N"N"N #3 % % %"$%O O O O O O O O O O O O O O O! !(00! ! ! !!  [  !#$&( H HC++dF33;;CGGOOPSUZ[[C!((}    JJJJ
C


JJJJ#GGKK?&++m"<"<???@
 ,%%&]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 GG!Z//V<<F
 GFFOO4E4EFFFGG	 /   IIG G G G G G G G G G G G G G G G G G G G G G 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   5R7Q1R1RRRRRR6:Z<<[ [ \;\,\;\+\;*\++\;;\?\?a)C7aa)a	a)a	a))a<;a<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.
        rX   r   r"  T)r  r  r  r  r  r.   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-   rX   r  c                 :    g | ]}||                                 S r   r[   )r&   values     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 rZ   r   r&   r  needles     r   r_   zASessionDB.search_sessions_by_id.<locals>.score.<locals>.<genexpr>  s'      ;;u5F?;;;;;;r   r   c              3   B   K   | ]}|                               V  d S rZ   )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  )ro   r   ra   )r.   rA   
normalizedr  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   )r&   r   r.   s      r   r(   z3SessionDB.search_sessions_by_id.<locals>.<listcomp>  s    1113111r   N)
rp   r\   r  r  r   ro   r   r   sortedr  )r   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  r  s     r   r(   z-SessionDB.search_sessions.<locals>.<listcomp>#  s    ;;;#DII;;;r   N)r   r   r3   r4   )r   rO  r  r  select_with_last_activer;   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           |                    |           |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  r)   c              3      K   | ]}d V  dS r   r   r   s     r   r_   z*SessionDB.session_count.<locals>.<genexpr>M  r  r   r  r   r   r  r  r  z WHERE r  rX   zSELECT COUNT(*) FROM sessions sN)	r%  r  r   r1   r  r   r   r3   rh   )r   rO  r  r  r  r,  r  r  r  r   r	  r;   s               r   session_countzSessionDB.session_count)  s   .  	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/***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   8FFF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  )r   rN  r;   s      r   r   zSessionDB.message_count^  s    Z 	( 	( M++H:-  ++,KLL??$$Q'	( 	( 	( 	( 	( 	( 	( 	( 	( 	( 	( 	( 	( 	( 	( 	( 	( 	(s   AA((A,/A,c                 n    |                      |          }|sdS |                     |          }i |d|iS )z8Export a single session with all its messages as a dict.NrF  )r  rW  )r   rN  sessionrF  s       r   export_sessionzSessionDB.export_sessionm  sJ    "":.. 	4$$Z000'0:x000r   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 )rO  r  r-   rF  )r+  rW  r%  )r   rO  rB  resultsr2  rF  s         r   
export_allzSessionDB.export_allu  sq    
 ''vV'DD 	> 	>G((77HNN<g<z8<<====r   c                 <    fd}|                      |           dS )z9Delete all messages for a session and reset its counters.c                 d    |                      df           |                      df           d S )NrI  rJ  r   rf  s    r   rY  z%SessionDB.clear_messages.<locals>._do  sJ    LL;j]   LLY    r   NrZ  rg  s    ` r   clear_messageszSessionDB.clear_messages  s8    	 	 	 	 	 	C     r   c                 "   | dS dD ]2}| | | z  }	 |                     d           ## t          $ r Y /w xY w	 |                     d| d          D ])}	 |                     d           # t          $ r Y &w xY wdS # t          $ r Y dS w xY w)aH  Remove on-disk transcript files for a session.

        Cleans up ``{session_id}.json``, ``{session_id}.jsonl``, and any
        ``request_dump_{session_id}_*.json`` files left by the gateway.
        Silently skips files that don't exist and swallows OSError so a
        filesystem hiccup never blocks a DB operation.
        N)z.jsonz.jsonlT)
missing_okrequest_dump_z_*.json)unlinkOSErrorglob)r  rN  r   r  s       r   r  zSessionDB._remove_session_files  s    F) 	 	F*6f666AD))))   	!&&'Jz'J'J'JKK  HHH----   D 
  	 	 	DD	sA   *
77B  A.-B  .
A;8B  :A;;B   
BBc                     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 = ?rI  !DELETE FROM sessions WHERE id = ?T)r3   rh   r  rB   )r7   r;   removed_delegate_idsrN  s     r   rY  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  r  rr  )r   rN  r  rY  deleteddelegate_idrC  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   r3   r  )r7   r;   rN  s     r   rY  z.SessionDB.delete_session_if_empty.<locals>._do  s1    \\  F ?Q&&r   rD  )r   rN  r  rY  rE  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   )rk   ro   r%   s     r   	<setcomp>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 )
Nr)   r*   z%SELECT id FROM sessions WHERE id IN (r   c                     g | ]
}|d          S r,   r   r  s     r   r(   z:SessionDB.delete_sessions.<locals>._do.<locals>.<listcomp>,  s    ???cD	???r   r   r?   r>   r@   )r1   r2   r3   r4   r  rB   )r7   r   r;   r|   existing_placeholdersrC  r  
unique_idss        r   rY  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   )r6   r  r  )	r   r  r  rY  countr'   rC  r  rR  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  )r   r;   s     r   count_empty_sessionszSessionDB.count_empty_sessionsN  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   r  s     r   rN  z?SessionDB.delete_empty_sessions.<locals>._do.<locals>.<setcomp>      BBB3t9BBBr   r   r)   r*   r?   r   rI  rB  r3   r4   r1   r2   r6   r%  )r7   r;   r  r   r'   r  s        r   rY  z,SessionDB.delete_empty_sessions.<locals>._do  s   \\# F CB0A0ABBBK q88C#k*:*:$:;;LLL?/;? ? ?[!!   # 	( 	(
 ?#   @3&III""3''''{###r   )r  r  )r   r  rY  rS  r'   r  s        @r   delete_empty_sessionszSessionDB.delete_empty_sessionsg  se    : "$	$ 	$ 	$ 	$ 	$> ##C(( 	: 	:C&&|S9999r   Z   older_than_daysc                     t          j                     |dz  z
  g fd}|                     |          }D ]}|                     ||           |S )a  Delete sessions older than N days. Returns count of deleted sessions.

        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  c                    r|                      df          }n|                      df          }d |                                D             }|sdS d                    dt          |          z            }|                      d| dt	          |                     |D ]E}|                      d	|f           |                      d
|f                               |           Ft          |          S )NzkSELECT id FROM sessions
                       WHERE started_at < ? AND ended_at IS NOT NULL AND source = ?zESELECT id FROM sessions WHERE started_at < ? AND ended_at IS NOT NULLc                     h | ]
}|d          S r,   r   r  s     r   rN  z8SessionDB.prune_sessions.<locals>._do.<locals>.<setcomp>  rZ  r   r   r)   r*   r?   r   rI  rB  r[  )r7   r;   r  r   r'   r  r  rO  s        r   rY  z%SessionDB.prune_sessions.<locals>._do  s4    
WV$  [I  CB0A0ABBBK q 88C#k*:*:$:;;LLL?/;? ? ?[!!   # ( (H3&QQQ@3&III""3''''{###r   )r  r  r  )	r   r^  rO  r  rY  rS  r'   r  r  s	     `    @@r   prune_sessionszSessionDB.prune_sessions  s     % 78!#	$ 	$ 	$ 	$ 	$ 	$ 	$> ##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   )r   r   r3   rh   rk   ri   r   )r   r   r.   s      r   get_metazSessionDB.get_meta  s    Z 	 	*$$<sf hjj 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 ;4)#w{;;Gs7||QGs   /AA
Ar  c                 @    fd}|                      |           dS )z0Write a value to the state_meta key/value store.c                 8    |                      df           d S )NgINSERT INTO state_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.valuer   )r7   r   r  s    r   rY  zSessionDB.set_meta.<locals>._do  s0    LLHe    r   NrZ  )r   r   r  rY  s    `` r   set_metazSessionDB.set_meta  >    	 	 	 	 	 	 	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);
                rd  ) 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  rB     rX   CASCADENr   r  s     r   r_   zHSessionDB.apply_telegram_topic_migration.<locals>._do.<locals>.<genexpr>4  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);
                        rh  )rm  2)r   r3   rh   ro   isdigitr   r4   ra   )r7   r  rI  fk_rowsneeds_rebuilds        r   rY  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   NrZ  )r   rY  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_topicschat_idrv  rw  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_intk  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
                )r3   ro   )r7   r{  rw  rx  rv  r   rR  s    r   rY  z1SessionDB.enable_telegram_topic_mode.<locals>._dop  sf    LL LLLLG.//G9::    r   N)ru  r  r
   rr  r   r  )r   rx  rR  rv  rw  rY  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)NzOUPDATE telegram_dm_topic_mode SET enabled = 0, updated_at = ? WHERE chat_id = ?z8DELETE FROM telegram_dm_topic_bindings WHERE chat_id = ?)r3   r  ro   ri   rj   )r7   rx  r~  s    r   rY  z2SessionDB.disable_telegram_topic_mode.<locals>._do  s    (Y[[#g,,/  
 " LLRW     
 +   s   AA# #A65A6NrZ  )r   rx  r~  rY  s    `` r   disable_telegram_topic_modez%SessionDB.disable_telegram_topic_mode  s>     	 	 	 	 	 	 	C     r   c                   | j         5  	 | j                            dt          |          t          |          f                                          }n!# t
          j        $ r Y ddd           dS w xY w	 ddd           n# 1 swxY w Y   |dS t          |t
          j                  r|d         n|d         }t          |          S )zDReturn whether Telegram DM topic mode is enabled for this chat/user.z
                    SELECT enabled FROM telegram_dm_topic_mode
                    WHERE chat_id = ? AND user_id = ?
                    NFenabledr   )
r   r   r3   ro   rh   ri   rj   rk   r   rr  )r   rx  rR  r.   r  s        r   is_telegram_topic_mode_enabledz(SessionDB.is_telegram_topic_mode_enabled  s0   Z 
	 
		j(( \\3w<<0  (**  +   
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 ;5$.sGK$@$@L#i..c!fG}}5   BA	ABA2#B1A22BB	B	thread_idc                @   | 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r   r   r3   ro   rh   ri   rj   r  )r   rx  r  r.   s       r   get_telegram_topic_bindingz$SessionDB.get_telegram_topic_binding  s    Z 
	 
		j(( \\3y>>2  (**  +   
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	 
	  )tCyyyT)r  c                   | j         5  	 | j                            dt          |          f                                          }n## t
          j        $ r g cY cddd           S w xY w	 ddd           n# 1 swxY w Y   d |D             S )zAll Telegram DM topic bindings for one chat, newest first.

        Read-only; returns [] if the bindings table doesn't exist yet
        (does not trigger the topic-mode migration).
        zSSELECT * FROM telegram_dm_topic_bindings WHERE chat_id = ? ORDER BY updated_at DESCNc                 ,    g | ]}t          |          S r   r  r  s     r   r(   zCSessionDB.list_telegram_topic_bindings_for_chat.<locals>.<listcomp>  s    ***cS		***r   )r   r   r3   ro   r4   ri   rj   )r   rx  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 = ?
                    Nr  r   rN  r.   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auto)managed_modesession_keyr  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 = ?
                rx  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
                )r3   rh   rk   ri   r   ro   r  )r7   existing_sessionlinked_chatlinked_threadrx  r  r   rN  r  r  rR  s       r   rY  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)ru  r  ro   r  )	r   rx  r  rR  r  rN  r  rY  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)r   r   r3   ro   rh   ri   rj   r  s      r   #is_telegram_session_linked_to_topicz-SessionDB.is_telegram_session_linked_to_topic@  s     Z 	 	
j((
 __&  (**  +   	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 $r  r?  )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  rX   r  r  r  )r   r   r3   ro   r   r4   ri   rj   r  r  rp   r2   r%  )	r   rx  rR  r  r   rB  r.   r2  r  s	            r   (list_unlinked_telegram_sessions_for_userz2SessionDB.list_unlinked_telegram_sessions_for_userV  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.zSELECT 1 FROM r   TF)r   r3   ri   rj   )r   r   s     r   _fts_table_existszSessionDB._fts_table_exists  sS    	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   zINSERT INTO r   z) VALUES('optimize')r  zFTS optimize failed for %s: %sN)	r   _FTS_TABLESr  r   r3   ri   rj   r   r   )r   	optimizedr(  r{   s       r   optimize_ftsz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   d}	 |                                  }n2# t          $ r%}t                              d|           Y d}~nd}~ww xY w| j        5  	 | j                            d           n# t          $ r Y n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  r   )r  r   r   r   r   r   r3   )r   r  r{   s      r   vacuumzSessionDB.vacuum  s%   . 		I))++II 	I 	I 	INNBCHHHHHHHH	I Z 	) 	)
""#DEEEE   Jx(((	) 	) 	) 	) 	) 	) 	) 	) 	) 	) 	) 	) 	) 	) 	) sJ    
AAAB%A/.B%/
A<9B%;A<<B%%B),B)   retention_daysmin_interval_hoursr  c                    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   )skippedprunedvacuumedlast_auto_prunei  Tr  )r^  r  r  r  zstate.db VACUUM failed: %sNzDstate.db auto-maintenance: pruned %d session(s) older than %d days%sz	 + VACUUMrX   z$state.db auto-maintenance failed: %sr   )re  r  r#  r%  r  rb  r  r   r   r   ri  ro   info)r   r  r  r  r  r  last_rawr   last_tsr  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platformc                 <    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   rJ  )r7   curr  rN  s     r   rY  z&SessionDB.request_handoff.<locals>._doY  s5    ,,Q :& C <!##r   rZ  )r   rN  r  rY  s    `` r   request_handoffzSessionDB.request_handoffS  s8    
	$ 
	$ 
	$ 
	$ 
	$ 
	$ ""3'''r   c                     	 | j                             d|f          }|                                }|sdS |d         |d         |d         dS # t          $ r Y dS w xY w)zRead the current handoff state for a session.

        Returns ``{"state", "platform", "error"}`` or None if the session has
        no handoff record.
        zPSELECT handoff_state, handoff_platform, handoff_error FROM sessions WHERE id = ?Nhandoff_statehandoff_platformhandoff_error)stater  r   )r   r3   rh   r   )r   rN  r  r.   s       r   get_handoff_statezSessionDB.get_handoff_statef  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   )r   r3   r4   r   )r   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   rJ  )r7   r  rN  s     r   rY  z$SessionDB.claim_handoff.<locals>._do  s/    ,,= C
 <!##r   rZ  rg  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   rf  s    r   rY  z'SessionDB.complete_handoff.<locals>._do  s-    LL4    r   NrZ  rg  s    ` r   complete_handoffzSessionDB.complete_handoff  s8    	 	 	 	 	 	C     r   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 = ?i  r   )r7   r   rN  s    r   rY  z#SessionDB.fail_handoff.<locals>._do  s8    LL1ttj)    r   NrZ  )r   rN  r   rY  s    `` r   fail_handoffzSessionDB.fail_handoff  rj  r   )NF)r   N)NNNNNN)rl  rZ   )r   r   Nr   r   r   NNNNNNNNr   F)r  N)NNr  r   Fr   TFFFN)r  r   )NNNNNNNNNNNNF)F)r#  )r#  r   rb  )FF)r  F)NNNr  r   NF)r  T)Nr  r   )Nr   FFFN)r]  NN)r]  r  TN)r   
__module____qualname____doc__r  r  r  r  r   rr  r   staticmethodri   rj   r   r   Cursorr   r   r   r   r   ro   r
   r   r  r   
ConnectionrC   r  r  r   r   r0  r<  r   r   r[  r_  rc  rh  rk  r#  rt  rw  ry  r}  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r	   r  r  r  r$  classmethodr(  r-  rE  rQ  rW  ra  r   r{  r  r  r  r  r  r  r  r  r  r  r  r  r'  r+  r/  r   r3  r6  r9  r  rG  rK  rT  rV  r\  rb  re  ri  ru  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          !#X X X X X X Xx 9(@ 9T 9 9 9 \9
'*B 
t 
 
 
 
	GN 	t 	 	 	 	 7> d    \ K7> Kc K K K \K 
W^ 
 
 
 
 \
(
w~ 
3 
8TX> 
 
 
 
  	
 
   *2
7+=*>*A!B 2
q 2
 2
 2
 2
h   >" " " (# ($sDcN7J2K ( ( ( \(T* *D * * * *XR R Rx '+!!%! !! ! 	!
 38n! ! ! ! ! 
! ! ! !> c     !c !s !t ! ! ! !$! ! ! ! ! !!S !s !t ! ! ! !D #	< << < 	<
 
< < < <|3      4Ic Ihsm I I I I*  $	! !! ! }	!
 
! ! ! !&!s !3 !4 ! ! ! !!s !3 !4 ! ! ! !" !""# !.2+/%)%))-*.*.&*%a! a!a! a! 	a!
 a! a!  a! a! %UOa! "%a! c]a! c]a! "#a! #3-a! #3-a!  sm!a!" #a!$ %a!& 
'a! a! a! a!L  		 		 	 		 
	 	 	 	   7G  SV        <%- %- %- %- %-N*c *htCH~.F * * * *s x}    8 )hsm ) ) ) ) \)VC      :-C -HSM - - - -2s 2d 2t 2 2 2 2h*# *(4S>2J * * * *c hsm    :!(C !(C !( !( !( !(F"c "hsm " " " "L %)!&!")-%*!&#l ll cl 	l
 l l l #'l #l l l l 
d38n	l l l lb 	@ @@ @ 	@
 
d38n	@ @ @ @D! !c3h8P ! ! ! !V ' c  c       [ * c c    ["  !!%!%%)#'#'!_( _(_( _( 	_(
 _( _( _( _( _( _( _( _(  #_( !_( !_(  !_(" 
#_( _( _( _(BT!3 T!$tCH~:N T!SW T! T! T! T!n 9>! !!15!	d38n	! ! ! !N 	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?C ?C ? ? ? ?H #(!&	V VV  V 	V
 
d38n	V V V Vp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 7!C 7!C 7! 7! 7! \7!t (c (d ( ( ( \( C D    \ Gc Gc G G G [G $(%)!%!&u uu Cyu c	u
 #Yu u u u u 
d38n	u u u ut	 !%	-2 -2-2 -2 	-2
 
d38n	-2 -2 -2 -2b 	"< "<"< "< 	"<
 
d38n	"< "< "< "<T !"!&#!&%)3( 3(3( 3( 	3(
 3( 3( c3( 
3( 3( 3( 3(j	( 	( 	(s 	( 	( 	( 	(1 1$sCx.1I 1 1 1 1
 
 
T#s(^0D 
 
 
 

! 
! 
! 
! 
! 
! 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J  "'+	5 55 5 tn	5
 
5 5 5 5rHC HHSM H H H H!C ! ! ! ! ! !b! b! b! b!R .28</! /! /! /! 	/!
 %TN/! (0~/! 
/! /! /! /!j  $	! ! ! ! 	!
 
! ! ! !B s t    $* * 	*
 
$sCx.	!* * * *(+ + 
d38n		+ + + +** * 
$sCx.	!	* * * *@ #>! >! >! >! 	>!
 >! >! >! >! 
>! >! >! >!@     6 J J J J 	J
 J 
d38n	J J J Jb ;Kc d    %c % % % %N$ $ $ $ $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   )r   )r   )rT   )Er  rW  loggingr  r  ri   r   r  pathlibr   agent.memory_managerr   hermes_constantsr   typingr   r   r   r	   r
   r   r   	getLoggerr   r   ro   r   r   r   r   r  r!   r<   rB   rC   r   rD  rb   rF   __annotations__r   rP   r0   rG   r   r   rQ   rS   rd   r  rr   r}   r   ry   r   r   r   r	  rr  r   r   r   r   r   r4  rC  rF  rE  r   r   r   r   <module>r     s        				             1 1 1 1 1 1 , , , , , , F F F F F F F F F F F F F F F F F F		8	$	$H HS Hc H H H H4 0  \9J9Q9QTW9Q9X9X[[[   c    $s) S	    .S	 d3i     GCLL!/##j0$  #' (3- & & &&	((  (+suu CH , , ,*IN,, hsm     "Xc]    ' '# 'UX ' ' ' '.C 2 Cx} C C C C0 0 0 0

0 0 		0 0 0 0fS y T    V  %(CEE S ) ) )%y~'' 	S} 	S 	S 	S 	S 	S4 D    T htn    0t     , =A ` ` `D `T `T#s(^ ` ` ` `FQ
n 
:6X@! X@! X@! X@! X@! X@! X@! X@! X@! X@!r   