
    sjH                        U d Z ddlZddlZddlZddlmZmZ ddlmZ ddl	m
Z
mZmZmZmZmZmZmZmZmZmZmZmZ  ej        d          Zdaee         ed<   defd	Z G d
 d          ZdS )u  Schema creation, column reconciliation, and FTS DDL management for SessionDB.

Mixin contract: this is a plain mixin class consumed by
``hermes_state.SessionDB``. It defines no ``__init__`` and no state of its
own; methods access the host's attributes (``self._conn``, ``self.db_path``,
``self._execute_write`` and other SessionDB methods) established by
``SessionDB.__init__``. It must never import hermes_state (cycle) — shared
module-level constants live in hermes_state_common.
    N)DictOptionalget_hermes_home)DEFERRED_INDEX_SQLFTS_CJK_STALE_KEYFTS_STALE_KEYFTS_SQLFTS_STORAGE_VERSIONFTS_TRIGRAM_SQLLEGACY_FTS_SQLLEGACY_FTS_TRIGRAM_SQL
SCHEMA_SQLSCHEMA_VERSION_FTS_CJK_TRIGGERS_FTS_TRIGGERS_ephemeral_child_sqlhermes_state_READ_PROBE_STATEMENTSreturnc                      t           Wt                              t                    } t	          d t          |                                           D                       a t           S )ud  SELECT statements that fail iff a live store is behind SCHEMA_SQL.

    Read-only opens skip ``_reconcile_columns()`` by design (no DDL against
    another profile's live DB), so a store created before a schema addition
    keeps 500ing on read paths until something opens it writable. Callers
    that heal on staleness (see ``_open_session_db_at_path`` in
    ``hermes_cli/web_server.py``) run these probes right after a read-only
    open: any missing table raises "no such table" and any missing column
    raises "no such column", both at prepare time.

    Derived from SCHEMA_SQL — the same source of truth the writable
    reconciler diffs against — so a column added there is covered here
    automatically. A hand-maintained probe list went stale within days of
    shipping (it never learned ``sessions.last_activity_at``, so the sidebar
    served an empty session list after `hermes update` until the user's
    first message forced a writable open).

    Each statement is ``LIMIT 0``: column resolution happens at prepare
    time, so the probe reads zero rows. Column references are qualified
    with the table name — an unqualified double-quoted identifier that
    fails to resolve silently degrades to a string literal (SQLite's
    double-quoted-string misfeature), which would make the probe pass on
    exactly the stale store it exists to catch.
    Nc              3      K   | ]P\  }d                      d                    fd|D                                           dd                    V  QdS )zSELECT {} FROM "{}" LIMIT 0, c              3      K   | ]B}d                                          dd          |                    dd                    V  CdS )z	"{}"."{}""""N)formatreplace).0coltables     7/home/agent/.hermes/hermes-agent/hermes_state_schema.py	<genexpr>z9schema_read_probe_statements.<locals>.<genexpr>.<genexpr>H   sj           &&c400#++c42H2H          r   r   N)r   joinr   )r   colsr!   s     @r"   r#   z/schema_read_probe_statements.<locals>.<genexpr>F   s       '
 '
 t *00		      $	     c4(( '
 '
 '
 '
 '
 '
r$   )r   SessionSchemaMixin_parse_schema_columnsr   tuplesorteditems)tabless    r"   schema_read_probe_statementsr-   *   sc    4 %#99*EE!& '
 '
  &fllnn55'
 '
 '
 "
 "
 "!r$   c            	       b   e Zd ZdZdej        ddfdZdej        defdZdej        ddfdZ	e
dej        defd            Ze
d	ee         defd
            Zdej        defdZdej        defdZdej        ddfdZe
dddej        deddfd            Ze
dddej        deddfd            Zdej        dedee         fdZdej        dedef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ej        ddfdZdej        ddfdZd Zdej        ddfdZdS )r'   u>   See module docstring — mixin for SessionDB (Schema cluster).cursorr   Nc                    	 |                     d                                          }n# t          j        $ r Y dS w xY w|D ]}t	          |t          j                  r|d         n|d         }t	          |t          j                  r|d         n|d         }	 |                     ||          }|                     d||f           # t          j        $ r'}t                              d|           Y d}~ dS d}~ww xY wdS )	u  Move inline prompt snapshots into the shared content-addressed table.

        Contention-safe by design: a ``database is locked`` (or any other
        ``OperationalError``) mid-loop returns instead of raising. Partial
        migration is safe — the legacy ``system_prompt`` column is kept as a
        read fallback for unmigrated rows, and the next schema init picks up
        the remainder. Letting the error propagate aborted schema init
        entirely, left the version below 25, and made every subsequent
        ``SessionDB.__init__`` re-enter this migration against the same
        contended DB (enterprise field report, 2026-08-14: gateway watchdog
        crash loop).
        zFSELECT id, system_prompt FROM sessions WHERE system_prompt IS NOT NULLNidr   system_prompt   zMUPDATE sessions SET system_prompt_hash = ?, system_prompt = NULL WHERE id = ?zv25 prompt dedupe paused after contention (%s); unmigrated rows keep the legacy inline prompt and the next schema init resumes the migration.)	executefetchallsqlite3OperationalError
isinstanceRow_store_system_promptloggerwarning)selfr/   rowsrow
session_idpromptprompt_hashexcs           r"   _dedupe_legacy_system_promptsz0SessionSchemaMixin._dedupe_legacy_system_promptsX   sQ   	>>2  hjj D ' 	 	 	FF	  	 	C&0gk&B&BNTAJ-7W[-I-IUS))sSTvF"77GG# !*-	    +   > 	   	 	s$   '* ==.CC>C99C>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)r4   r6   r7   _is_fts5_unavailable_error_warn_fts5_unavailable)r=   r/   rC   s      r"   _sqlite_supports_fts5z(SessionSchemaMixin._sqlite_supports_fts5   s    	NNWXXXNN?@@@4' 	 	 	22377 '',,,55555		s   *. A3+A..A3c                     |                      |           t          D ]0}	 |                    d|            # t          j        $ r Y -w xY wd S )NDROP TRIGGER IF EXISTS )_drop_fts_triggersr   r4   r6   r7   )r=   r/   triggers      r"   _drop_all_fts_triggersz)SessionSchemaMixin._drop_all_fts_triggers   su    '''( 	 	GBBBCCCC+   	 	s   9A
Ac                    d                     d t          D                       }|                     d| dt                                                    }t	          t          |t          j                  s|d         n|d                   S )N,c              3      K   | ]}d V  dS ?N r   _s     r"   r#   z8SessionSchemaMixin._fts_trigger_count.<locals>.<genexpr>   s"      ;;;;;;;;r$   zGSELECT COUNT(*) FROM sqlite_master WHERE type = 'trigger' AND name IN ()r   )r%   r   r4   fetchoneintr8   r6   r9   )r/   placeholdersr?   s      r"   _fts_trigger_countz%SessionSchemaMixin._fts_trigger_count   s    xx;;];;;;;nnC3?C C C
 
 (**	 	
 C!=!=I3q663q6JJJr$   sqlc                     | sdS d                     |                                                                           }d|v rdS d|v S )z?True when trigger SQL is missing AFTER UPDATE OF (still broad).F zAFTER UPDATE OF zAFTER UPDATE ON )r%   splitupper)r[   compacts     r"   #_fts_update_trigger_needs_narrowingz6SessionSchemaMixin._fts_update_trigger_needs_narrowing   sQ      	5((399;;''--//((5!W,,r$   c                    |                      |          }d}|st          | d          r|dz  }d                    d |D                       }|                    d| d|                                          }g }|D ]}t          |t          j                  s|d         n|d	         }t          |t          j                  s|d
         n|d         }	|                     |	          r|	                    |           |sdS |D ]}|                    d|            |r9| 
                    |dt                     | 
                    |dt                     n| 
                    |dt                     | 
                    |dt                     d|v r	 |                     |           n># t           $ r1 |                     |           t$                              d            w xY w|                     |          s/|                     |           t$                              d           t$                              dt/          |                     t/          |          S )a  Replace broad AFTER UPDATE FTS triggers with AFTER UPDATE OF variants.

        ``CREATE TRIGGER IF NOT EXISTS`` will not replace an existing broad
        trigger, so installs that already created ``AFTER UPDATE ON messages``
        would keep firing on every messages row touch (status/compaction
        writes included). Inspect ``sqlite_master``, drop any still-broad
        UPDATE triggers, and re-apply the current DDL constants.

        No FTS rebuild: content correctness was already gated by WHEN clauses
        on modern installs; OF only skips unnecessary trigger evaluation.

        Returns the number of triggers dropped (0 when already converged).
        )messages_fts_updatemessages_fts_trigram_update_ensure_fts_cjk_schemamessages_fts_cjk_updater   c              3      K   | ]}d V  dS rQ   rS   rT   s     r"   r#   zHSessionSchemaMixin._migrate_broad_fts_update_triggers.<locals>.<genexpr>   s"       ; ; ; ; ; ; ; ;r$   zHSELECT name, sql FROM sqlite_master WHERE type = 'trigger' AND name IN (rV   r   namer3   r[   rJ   messages_ftsmessages_fts_trigramrg   z2CJK FTS re-ensure after UPDATE OF migration failedzeCJK FTS UPDATE trigger missing or still broad after UPDATE OF migration; marked stale and unavailablezPMigrated %d broad FTS UPDATE trigger(s) to AFTER UPDATE OF (no rebuild required))_db_has_legacy_inline_ftshasattrr%   r4   r5   r8   r6   r9   ra   append_ensure_fts_schemar   r   r
   r   re   	Exception)_quarantine_cjk_after_update_of_migrationr;   	exception_cjk_update_trigger_is_narrowedr<   infolen)
r=   r/   legacy_layoutupdate_namesrY   r>   to_dropr?   ri   r[   s
             r"   "_migrate_broad_fts_update_triggersz5SessionSchemaMixin._migrate_broad_fts_update_triggers   s   " 66v>>
  	9/G!H!H 	988Lyy ; ;l ; ; ;;;~~C3?C C C
 
 (**	 	
  	% 	%C!+C!=!=N3q663v;D *3 < <L#a&&#e*C77<< %t$$$ 	1 	= 	=D NN;T;;<<<<  	##FNNKKK##.0F    ##FNGDDD##.   )G33//7777    BB6JJJ$$L    ;;FCC BB6JJJNNL  
 	$LL	
 	
 	

 7||s   F. .;G)c                     |                     dd                                          }|sdS t          |t          j                  s|d         n|d         }|                     |           S )z>True when messages_fts_cjk_update exists with AFTER UPDATE OF.zASELECT sql FROM sqlite_master WHERE type = 'trigger' AND name = ?rf   Fr   r[   )r4   rW   r8   r6   r9   ra   )r=   r/   r?   r[   s       r"   rs   z2SessionSchemaMixin._cjk_update_trigger_is_narrowed  ss    nn2(
 
 (**	 	
  	5&sGK88Hc!ffc%j;;C@@@@r$   c                 0   d| _         	 |                     t          d|           n,# t          $ r t                              dd           Y nw xY w	 |                    d           d	S # t          $ r  t                              dd           Y d	S w xY w)
a  Fail-closed after dropping CJK UPDATE during OF migration.

        Clears availability, persists ``fts_cjk_stale``, and drops any
        residual broad/partial CJK UPDATE trigger so a later open cannot
        ``CREATE TRIGGER IF NOT EXISTS`` a gap without rebuild.
        F1r/   z*Could not persist CJK FTS stale breadcrumbT)exc_infoz.DROP TRIGGER IF EXISTS messages_fts_cjk_updatez;Could not drop residual CJK UPDATE trigger after quarantineN)_fts_cjk_availableset_metar   rp   r;   debugr4   )r=   r/   s     r"   rq   z<SessionSchemaMixin._quarantine_cjk_after_update_of_migration  s     #(	MM+SM@@@@ 	 	 	LL<      	
	NNKLLLLL 	 	 	LLM       	s!   ' &AAA+ +&BBTinclude_trigramr   c                    |                      d           |r|                      d           |                      d           d S )Nz8INSERT INTO messages_fts(messages_fts) VALUES('rebuild')zHINSERT INTO messages_fts_trigram(messages_fts_trigram) VALUES('rebuild')zVDELETE FROM state_meta WHERE key IN ('fts_rebuild_high_water', 'fts_rebuild_progress')r4   r/   r   s     r"   _rebuild_fts_indexesz'SessionSchemaMixin._rebuild_fts_indexes'  s`     	QRRR 	NNZ   	A	
 	
 	
 	
 	
r$   c                    |                      d           |                      d           |sdS |                      d           |                      d           dS )ag  Rebuild the LEGACY inline FTS indexes (pre-v23) from messages.

        Used only to repair a legacy DB whose triggers degraded under an
        earlier no-FTS5 runtime. Inline tables have no external-content
        'rebuild' source, so we DELETE + reinsert the concatenated content
        the legacy triggers produced. Never touches the v23 shape.
        zDELETE FROM messages_ftszINSERT INTO messages_fts(rowid, content) SELECT id, COALESCE(content, '') || ' ' || COALESCE(tool_name, '') || ' ' || COALESCE(tool_calls, '') FROM messagesNz DELETE FROM messages_fts_trigramzINSERT INTO messages_fts_trigram(rowid, content) SELECT id, COALESCE(content, '') || ' ' || COALESCE(tool_name, '') || ' ' || COALESCE(tool_calls, '') FROM messagesr   r   s     r"   _rebuild_legacy_fts_indexesz.SessionSchemaMixin._rebuild_legacy_fts_indexes>  sy     	1222	
 	
 	
  	F9:::	
 	
 	
 	
 	
r$   
table_namec                 r   	 |                     d| d           dS # t          j        $ r}|                     |          rF|                     |          r|                     |           n|                     |           Y d }~d S dt          |                                          v rY d }~dS  d }~ww xY w)NzSELECT * FROM z LIMIT 0Tzno such tableF)	r4   r6   r7   rF   _is_trigram_unavailable_error_warn_trigram_unavailablerG   strlower)r=   r/   r   rC   s       r"   _fts_table_probez#SessionSchemaMixin._fts_table_probe`  s    	NN@J@@@AAA4' 	 	 	..s33  55c:: 52237777//444ttttt#c((.."2"222uuuuu	s!    B6AB1#B10B11B6legacyc                H   	 |                      |d          }n# t          j        $ r d}Y nw xY w|du }d                    d t          D                       }|r|dz  }|dz  }|dz  }|r t
          }|r
|t          z  }|dz   }|r|d	z  }n$t          }|r
|t          z  }|d
z   }|r|dz  }|dz  }d|z   |z   dt           dz   dz   }	 |
                    |           n# t          j        $ r}		 | j                                         n# t          j        $ r Y nw xY w|                     |           | j                                         t                               d|	           Y d}	~	dS d}	~	ww xY wd| _        d| _        || _        t                               d           dS )zAAtomically rebuild stale base/trigram indexes and resume syncing.rk   T c              3   "   K   | ]
}d | dV  dS )rJ   ;NrS   )r   rL   s     r"   r#   z8SessionSchemaMixin._recover_stale_fts.<locals>.<genexpr>{  s?       
 
5<0g000
 
 
 
 
 
r$   z*DROP TABLE IF EXISTS messages_fts_trigram;z-DROP VIEW IF EXISTS messages_fts_trigram_src;z"DROP TABLE IF EXISTS messages_fts;a   
                INSERT INTO messages_fts(rowid, content)
                SELECT id,
                       COALESCE(content, '') || ' ' ||
                       COALESCE(tool_name, '') || ' ' ||
                       COALESCE(tool_calls, '')
                FROM messages;
            az  
                    DELETE FROM messages_fts_trigram;
                    INSERT INTO messages_fts_trigram(rowid, content)
                    SELECT id,
                           COALESCE(content, '') || ' ' ||
                           COALESCE(tool_name, '') || ' ' ||
                           COALESCE(tool_calls, '')
                    FROM messages;
                z9INSERT INTO messages_fts(messages_fts) VALUES('rebuild');zIINSERT INTO messages_fts_trigram(messages_fts_trigram) VALUES('rebuild');zWDELETE FROM state_meta WHERE key IN ('fts_rebuild_high_water', 'fts_rebuild_progress');zBEGIN IMMEDIATE;z$DELETE FROM state_meta WHERE key = 'z';zCOMMIT;zfAutomatic rebuild of stale FTS indexes failed (%s); canonical writes remain enabled with FTS detached.NFzVRebuilt stale state.db FTS indexes from canonical messages and restored sync triggers.)r   r6   DatabaseErrorr%   r   r   r   r
   r   r	   executescript_connrollbackErrorrM   commitr;   error
_fts_stale_fts_enabled_trigram_availabler<   )
r=   r/   r   trigram_statusr   drop_sql
schema_sqlrebuild_sqlrecovery_sqlrC   s
             r"   _recover_stale_ftsz%SessionSchemaMixin._recover_stale_ftsq  s   	"!226;QRRNN$ 	" 	" 	" "NNN	" )D077 
 
@M
 
 
 
 
  	EDDHCC88 %	'J 544
$ ( K  	    !J .o-
$KK  ) FK  G]FFFG 	 		  ....$ 	 	 	
##%%%%=    ''///JLLE  
 55555	    "1&	
 	
 	
 tsC    --C E0-DE+DE+DAE++E0r   c                 <   ddl }ddl}d}|                    |                     d                                                    }	 ddlm}  |            dz  dz  }|                    |                    d                    }t          |t                    rv|                    d          |k    r]t          |                    d	          t                    r5|d	         }t          d
 |                                D                       r|S n# t          $ r Y nw xY w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                    |          ||<   ||	|
<   	 |                                 n# |                                 w xY w|	 ddl}ddl}|j                            dd           |                    t9          |j                  d          \  }}|                    |dd          5 }|                    ||	d|           ddd           n# 1 swxY w Y   |                    ||           n# t          $ r Y nw xY w|	S )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.

        The parse result is memoized on disk keyed by a hash of the DDL:
        executing SCHEMA_SQL (FTS5 virtual tables included) in the scratch
        DB costs ~85ms on every startup, but the output is a pure function
        of the DDL text, which only changes when the shipped code changes.
        Reconciliation itself (diffing the LIVE database) still runs every
        startup — only the reference-side parse is cached. A corrupt or
        stale cache degrades to recomputation.
        r   Nutf-8r   cachezschema_columns.jsonencodingschema_hashr,   c              3      K   | ]D}t          |t                    o*t          d  |                                D                       V  EdS )c              3   @   K   | ]}t          |t                    V  d S N)r8   r   )r   vs     r"   r#   zESessionSchemaMixin._parse_schema_columns.<locals>.<genexpr>.<genexpr>  s,      FF1Jq#..FFFFFFr$   N)r8   dictallvalues)r   r&   s     r"   r#   z;SessionSchemaMixin._parse_schema_columns.<locals>.<genexpr>  sj          tT** GFFFFFFF     r$   z:memory:zNSELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'PRAGMA table_info("")r3      r            zNOT NULLzDEFAULT r]   T)parentsexist_okz.schema_columns.)dirprefixw)r   r,   ) hashlibjsonsha256encode	hexdigesthermes_constantsr   loads	read_textr8   r   getr   r   rp   r6   connectr   r4   r5   rn   r%   closeostempfileparentmkdirmkstempr   fdopendumpr   )r   _hashlib_json
cache_pathr   r   blobr,   reftable_columnstblr&   r?   col_namecol_typenotnulldefaultpkparts_os	_tempfilefdtmpfhs                           r"   r(   z(SessionSchemaMixin._parse_schema_columns  s   * 	#"""
ooj&7&7&@&@AAKKMM	888888(**W47LLJ;;z33W3EEFFD4&&"HH]++{::txx11488 ; h   !'     "
 "M 	 	 	D	 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""+*. IIKKKKCIIKKKK!    ,,,,!''t'DDD#++J-..7I ,  C ZZC'Z:: bJJ(3}MMr                 C,,,,   sW   C	D 
DD6C4I   IA(L K*L *K..L 1K.2L 
LLc           
      X   |                      t                    }|                                D ]y\  }}	 |                    d| d                                          }n# t
          j        $ r Y Dw xY wt                      }|D ]C}t          |t          t          f          r|d         n|d         }|                    |           D|                                D ]\  }	}
|	|vr|	                    dd          }	 |                    d| d| d	|
            ?# t
          j        $ rw}t          |                                          }d
|v r"t                              d||	|           Y d}~d|v sd|v r t                              d||	|           Y d}~d}~ww xY wɐ{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   r3   ri   r   r   zALTER TABLE "z" ADD COLUMN "z" zduplicate columnzreconcile %s.%s: %sNlockedbusyz;reconcile %s.%s failed; store remains behind SCHEMA_SQL: %s)r(   r   r+   r4   r5   r6   r7   setr8   r)   listaddr   r   r   r;   r   r<   )r=   r/   expectedr   declared_colsr>   	live_colsr?   ri   r   r   	safe_namerC   messages                 r"   _reconcile_columnsz%SessionSchemaMixin._reconcile_columns)  s8    --j99)1)9)9 3	 3	%J~~8*888 (**  +   I $ $!+C%!?!?Ps1vvS[d####&3&9&9&;&; % %"(9,, ( 0 0d ; ;I"]J]]i]]S[]]    #3   "%c((.."2"2-88 #LL 5z8S   %HHHH#w..&G2C2C "
 -.8(C       7 -%3	 3	s0   +A""A43A4?DF$-AF4&FF$c                    	 |                     d                                          }n# t          j        $ r Y dS w xY w|sdS d fdt	          fd|D             fd          D             }|dd	gk    rdS t
                              d
|           |                     d           |                     d           |                     d           |                     d           dS )u  Rebuild ``gateway_routing`` when its PRIMARY KEY predates scoping.

        Early builds of the routing-index migration (#59203) created the
        table with ``session_key TEXT PRIMARY KEY`` and no ``scope`` column.
        ``_reconcile_columns()`` ADDs the missing ``scope`` column on those
        databases, but SQLite cannot ALTER a primary key, so the shipped
        composite ``PRIMARY KEY (scope, session_key)`` never lands.  On such
        tables every write path is broken:

        * ``save_gateway_routing_entry`` fails with "ON CONFLICT clause does
          not match any PRIMARY KEY or UNIQUE constraint" (its upsert targets
          the composite key), and
        * ``replace_gateway_routing_entries`` fails with "UNIQUE constraint
          failed: gateway_routing.session_key" whenever the same session_key
          exists under a different scope — the exact isolation the composite
          key exists to provide.

        Each failed save logs a warning and falls back to sessions.json,
        so a legacy-shaped table produces endless per-save warning spam.
        Rebuild it once, preserving rows.  On a session_key collision across
        scopes (possible while the PK was wrong) the newest row wins.
        z$PRAGMA table_info("gateway_routing")Nc                 Z    t          | t          t          f          r| |         n| |         S r   r8   r)   r   r?   idxri   s      r"   _colz9SessionSchemaMixin._heal_gateway_routing_pk.<locals>._col  '    )#t}==L3s883t9Lr$   c                 *    g | ]} |d d          S )r3   ri   rS   r   rr   s     r"   
<listcomp>z?SessionSchemaMixin._heal_gateway_routing_pk.<locals>.<listcomp>  s7     
 
 
 DAv
 
 
r$   c              3   6   K   | ]} |d d          |V  dS )r   r   NrS   r   s     r"   r#   z>SessionSchemaMixin._heal_gateway_routing_pk.<locals>.<genexpr>  s6      55qDDAt$4$45555555r$   c                      | dd          S )Nr   r   rS   )r   r   s    r"   <lambda>z=SessionSchemaMixin._heal_gateway_routing_pk.<locals>.<lambda>  s    dd1a.. r$   )keyscopesession_keyz]gateway_routing has legacy primary key %r; rebuilding with composite (scope, session_key) keyz?ALTER TABLE gateway_routing RENAME TO gateway_routing_legacy_pkzCREATE TABLE gateway_routing (
    scope TEXT NOT NULL DEFAULT '',
    session_key TEXT NOT NULL,
    entry_json TEXT NOT NULL,
    updated_at REAL NOT NULL,
    PRIMARY KEY (scope, session_key)
)zINSERT OR REPLACE INTO gateway_routing (scope, session_key, entry_json, updated_at) SELECT COALESCE(scope, ''), session_key, entry_json, updated_at FROM gateway_routing_legacy_pk ORDER BY updated_at ASCz$DROP TABLE gateway_routing_legacy_pk)r4   r5   r6   r7   r*   r;   rt   )r=   r/   r>   pk_colsr   s       @r"   _heal_gateway_routing_pkz+SessionSchemaMixin._heal_gateway_routing_pkl  sr   .	>>6 hjj D ' 	 	 	FF	 	F	M 	M 	M
 
 
 
5555D555....  
 
 
 w...F1	
 	
 	

 	M	
 	
 	
 		
 	
 	
 	E	
 	
 	
 	=>>>>>s   '+ >>c                    	 |                     d                                          }n# t          j        $ r Y dS w xY w|sdS d fd|D             }d|v rdS t                              dt          |                     |                     d           	 |                     d           |                     d	           |                     d
           |                     d           |                     d           |                     d           n7# t          j        $ r%}t                              d|           Y d}~nd}~ww xY w|                     d           dS # |                     d           w xY w)u:  Rebuild ``session_model_usage`` when its PRIMARY KEY lacks ``task``.

        Installs whose ``state.db`` reached ``schema_version >= 22`` before
        the ``task`` dimension was added carry a 5-column PRIMARY KEY
        ``(session_id, model, billing_provider, billing_base_url,
        billing_mode)``.  ``_reconcile_columns()`` ADDs the ``task`` column
        as a bare nullable, but SQLite cannot ALTER a primary key, so the
        shipped composite 6-column key never lands.  The version-gated v22
        rebuild is unreachable on those installs (``current_version < 22``
        is already false), so every upsert in ``_record_model_usage()``
        fails with "ON CONFLICT clause does not match any PRIMARY KEY or
        UNIQUE constraint" — aborting the enclosing write transaction and
        silently zeroing all token *and* cost accounting (#73823).

        Idempotent; runs unconditionally on every open, same pattern as
        :meth:`_heal_gateway_routing_pk` above.  On healthy databases the
        PRAGMA check short-circuits and this is a no-op.
        z(PRAGMA table_info("session_model_usage")Nc                 Z    t          | t          t          f          r| |         n| |         S r   r   r   s      r"   r   z=SessionSchemaMixin._heal_session_model_usage_pk.<locals>._col  r   r$   c                 D    h | ]} |d d           |dd          S )r   r   r3   ri   rS   r   s     r"   	<setcomp>zBSessionSchemaMixin._heal_session_model_usage_pk.<locals>.<setcomp>  sJ     
 
 
#$Q40@0@
DAv
 
 
r$   taskzdsession_model_usage has legacy primary key %r (missing task); rebuilding with composite 6-column keyzPRAGMA foreign_keys=OFFzGALTER TABLE session_model_usage RENAME TO session_model_usage_legacy_pka_  CREATE TABLE session_model_usage (
    session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
    model TEXT NOT NULL,
    billing_provider TEXT NOT NULL DEFAULT '',
    billing_base_url TEXT NOT NULL DEFAULT '',
    billing_mode TEXT NOT NULL DEFAULT '',
    task TEXT NOT NULL DEFAULT '',
    api_call_count INTEGER NOT NULL DEFAULT 0,
    input_tokens INTEGER NOT NULL DEFAULT 0,
    output_tokens INTEGER NOT NULL DEFAULT 0,
    cache_read_tokens INTEGER NOT NULL DEFAULT 0,
    cache_write_tokens INTEGER NOT NULL DEFAULT 0,
    reasoning_tokens INTEGER NOT NULL DEFAULT 0,
    estimated_cost_usd REAL NOT NULL DEFAULT 0,
    actual_cost_usd REAL NOT NULL DEFAULT 0,
    cost_status TEXT,
    cost_source TEXT,
    first_seen REAL,
    last_seen REAL,
    PRIMARY KEY (session_id, model, billing_provider, billing_base_url, billing_mode, task)
)a  INSERT OR IGNORE INTO session_model_usage (
                       session_id, model, billing_provider, billing_base_url,
                       billing_mode, task, api_call_count, input_tokens,
                       output_tokens, cache_read_tokens, cache_write_tokens,
                       reasoning_tokens, estimated_cost_usd, actual_cost_usd,
                       cost_status, cost_source, first_seen, last_seen
                   )
                   SELECT session_id, model,
                          COALESCE(billing_provider, ''),
                          COALESCE(billing_base_url, ''),
                          COALESCE(billing_mode, ''),
                          COALESCE(task, ''),
                          api_call_count, input_tokens,
                          output_tokens, cache_read_tokens, cache_write_tokens,
                          reasoning_tokens, estimated_cost_usd, actual_cost_usd,
                          cost_status, cost_source, first_seen, last_seen
                   FROM session_model_usage_legacy_pkz(DROP TABLE session_model_usage_legacy_pk]CREATE INDEX IF NOT EXISTS idx_session_model_usage_session ON session_model_usage(session_id)VCREATE INDEX IF NOT EXISTS idx_session_model_usage_model ON session_model_usage(model)z'session_model_usage PK heal skipped: %szPRAGMA foreign_keys=ON)r4   r5   r6   r7   r;   rt   r*   r   )r=   r/   r>   r   rC   r   s        @r"   _heal_session_model_usage_pkz/SessionSchemaMixin._heal_session_model_usage_pk  s   &	>>: hjj D ' 	 	 	FF	 	F	M 	M 	M
 
 
 
(,
 
 
 WF57OO	
 	
 	
 	0111?	5NN:   NN  6 NN9  & NNEFFFNN5   NN0    ' 	I 	I 	ILLBCHHHHHHHH	I NN344444FNN34444s=   '+ >>A>D E' E(EE' EE' 'E>c                    | j                                         }|                    t                     |                     |           |                     |           |                     |           	 |                    d           n7# t          j	        $ r%}t                              d|           Y d}~nd}~ww xY w|                    t                     	 |                    d           n# t          j	        $ r Y nw xY w|                     |          }d}|                    dt          f                                          du| _        | j        r|                     |           |s|                     |           |                    d           |                                }||                    dt&          f           nvt)          |t          j                  r|d	         n|d
         }|dk     rct&          dk    rX|rT|                     |d          }|du r5|                     |dt0                    r|                    d           n
d}n|d}nd}|dk     rt&          dk     r	 |dk     rQ	 |                    dt3          d                      |                    d           n# t          j	        $ r Y nw xY w|dk     rI	 |                     |           n2# t6          $ r%}t                              d|           Y d}~nd}~ww xY w|dk     r,	 |                    d           n# t          j	        $ r Y nw xY w|dk     r	 |                    d                                          d
         }|s~|                    d           |                    d           |                    d           |                    d           |                    d           |                    d            n7# t          j	        $ r%}t                              d!|           Y d}~nd}~ww xY w|dk     r/|r-|                     |          r|                     d"d#|$           |d%k     r|                     |           |r|                     |          s{|                    d&                                          T|                     |          s?|                      |          s*|                     d'tC          tD                    |$           |t&          k     r |r|r|                    d(t&          f           d)}		 |                    |	           n# t          j#        $ r~ 	 |                    d*           t          $                    d+|j%                   |                    |	           n/# t          j&        $ r t          '                    d,           Y nw xY wY nt          j	        $ r Y nw xY w|r|                     |          }
| j        rE| (                    ||
-          r| )                    |           nMd| _*        d| _+        d| _,        n6|
r| -                    |          t]          t^                    k     }|                     |d.t`                    | _*        | j*        r<|                     |dtb                    }|| _+        |r| 2                    ||/           n| -                    |          t]          t^                    k     }|                     |d.tf                    | _*        | j*        rQ|                     |dt0                    }|| _+        |r| 4                    ||/           | )                    |           tk          | d0d          r| 6                    |           | j         7                                 dS )1a  Create tables and FTS if they don't exist, reconcile columns.

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

        The schema_version table is retained for future data migrations
        (transforming existing rows) which cannot be handled declaratively.
        zCREATE INDEX IF NOT EXISTS idx_messages_platform_msg_id ON messages(session_id, platform_message_id) WHERE platform_message_id IS NOT NULLz/idx_messages_platform_msg_id create skipped: %sNz3UPDATE messages SET active = 1 WHERE active IS NULLTz.SELECT 1 FROM state_meta WHERE key = ? LIMIT 1z*SELECT version FROM schema_version LIMIT 1z/INSERT INTO schema_version (version) VALUES (?)versionr   
   rk   FzkINSERT INTO messages_fts_trigram(rowid, content) SELECT id, content FROM messages WHERE content IS NOT NULL         zUPDATE sessions SET model_config = json_set(COALESCE(model_config, '{}'), '$._delegate_from', parent_session_id) WHERE parent_session_id IS NOT NULL AND json_extract(COALESCE(model_config, '{}'), '$._delegate_from') IS NULL AND sessionsa  UPDATE sessions SET model_config = json_set(COALESCE(model_config, '{}'), '$._delegate_from', '__orphaned__') WHERE parent_session_id IS NULL AND json_extract(COALESCE(model_config, '{}'), '$._delegate_from') IS NULL AND json_extract(COALESCE(model_config, '{}'), '$._branched_from') IS NULL AND title IS NULL AND message_count <= 25 AND EXISTS (SELECT 1 FROM messages m             WHERE m.session_id = sessions.id AND m.role = 'tool') AND NOT EXISTS (SELECT 1 FROM sessions ch                 WHERE ch.parent_session_id = sessions.id)   z)v18 gateway metadata backfill skipped: %s   a  INSERT OR IGNORE INTO session_model_usage (
                               session_id, model, billing_provider,
                               billing_base_url, billing_mode,
                               api_call_count, input_tokens,
                               output_tokens, cache_read_tokens,
                               cache_write_tokens, reasoning_tokens,
                               estimated_cost_usd, actual_cost_usd,
                               cost_status, cost_source, first_seen, last_seen
                           )
                           SELECT id, COALESCE(model, 'unknown'),
                                  COALESCE(billing_provider, ''),
                                  COALESCE(billing_base_url, ''),
                                  COALESCE(billing_mode, ''),
                                  COALESCE(api_call_count, 0),
                                  COALESCE(input_tokens, 0),
                                  COALESCE(output_tokens, 0),
                                  COALESCE(cache_read_tokens, 0),
                                  COALESCE(cache_write_tokens, 0),
                                  COALESCE(reasoning_tokens, 0),
                                  COALESCE(estimated_cost_usd, 0),
                                  COALESCE(actual_cost_usd, 0),
                                  cost_status, cost_source,
                                  started_at, COALESCE(ended_at, started_at)
                           FROM sessions
                           WHERE COALESCE(input_tokens, 0)
                                 + COALESCE(output_tokens, 0)
                                 + COALESCE(cache_read_tokens, 0)
                                 + COALESCE(cache_write_tokens, 0)
                                 + COALESCE(reasoning_tokens, 0) > 0   z\SELECT COUNT(*) FROM pragma_table_info('session_model_usage') WHERE name = 'task' AND pk > 0zAALTER TABLE session_model_usage RENAME TO session_model_usage_v21a  CREATE TABLE session_model_usage (
                                   session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
                                   model TEXT NOT NULL,
                                   billing_provider TEXT NOT NULL DEFAULT '',
                                   billing_base_url TEXT NOT NULL DEFAULT '',
                                   billing_mode TEXT NOT NULL DEFAULT '',
                                   task TEXT NOT NULL DEFAULT '',
                                   api_call_count INTEGER NOT NULL DEFAULT 0,
                                   input_tokens INTEGER NOT NULL DEFAULT 0,
                                   output_tokens INTEGER NOT NULL DEFAULT 0,
                                   cache_read_tokens INTEGER NOT NULL DEFAULT 0,
                                   cache_write_tokens INTEGER NOT NULL DEFAULT 0,
                                   reasoning_tokens INTEGER NOT NULL DEFAULT 0,
                                   estimated_cost_usd REAL NOT NULL DEFAULT 0,
                                   actual_cost_usd REAL NOT NULL DEFAULT 0,
                                   cost_status TEXT,
                                   cost_source TEXT,
                                   first_seen REAL,
                                   last_seen REAL,
                                   PRIMARY KEY (session_id, model, billing_provider, billing_base_url, billing_mode, task)
                               )a  INSERT INTO session_model_usage (
                                   session_id, model, billing_provider, billing_base_url,
                                   billing_mode, task, api_call_count, input_tokens,
                                   output_tokens, cache_read_tokens, cache_write_tokens,
                                   reasoning_tokens, estimated_cost_usd, actual_cost_usd,
                                   cost_status, cost_source, first_seen, last_seen
                               )
                               SELECT session_id, model, billing_provider, billing_base_url,
                                      billing_mode, '', api_call_count, input_tokens,
                                      output_tokens, cache_read_tokens, cache_write_tokens,
                                      reasoning_tokens, estimated_cost_usd, actual_cost_usd,
                                      cost_status, cost_source, first_seen, last_seen
                               FROM session_model_usage_v21z"DROP TABLE session_model_usage_v21r  r  z+v22 session_model_usage rebuild skipped: %sfts_optimize_availabler|   r}      zESELECT 1 FROM state_meta WHERE key = 'fts_rebuild_high_water' LIMIT 1fts_storage_versionz%UPDATE schema_version SET version = ?zfCREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_title_unique ON sessions(title) WHERE title IS NOT NULLag  UPDATE sessions AS older
                       SET title = NULL
                       WHERE title IS NOT NULL
                         AND EXISTS (
                             SELECT 1 FROM sessions AS newer
                             WHERE newer.title = older.title
                               AND newer.rowid > older.rowid
                         )zFCleared %d duplicate session title(s) while restoring the unique indexzICould not repair duplicate session titles; unique title index not created)r   rj   r   r   )8r   r/   r   r   r   r   r  r4   r6   r7   r;   r   r   rH   r	   rW   r   rM   rK   r   r8   r9   r   ro   r   r   -_backfill_gateway_metadata_from_sessions_jsonrp   rl   r   rD   _has_fts_trash'_fts_external_index_empty_with_messagesr   r   IntegrityErrorr<   rowcountr   rr   r   re   r   r   r   rZ   ru   r   r   r   r   r
   r   getattrry   r   )r=   r/   rC   fts5_availablefts_migrations_completer?   current_version_fts_trigram_exists	legacy_pktitle_index_sql
legacy_ftstriggers_need_repairtrigram_enableds                r"   _init_schemazSessionSchemaMixin._init_schema-  s	    ""$$Z((( 	'''
 	%%f--- 	))&111	QNN8   
 ' 	Q 	Q 	QLLJCPPPPPPPP	Q
 	/000	NNE    ' 	 	 	D	 33F;;"& ..<
 
 (**D! ? 	0 ''/// 	, ##F+++
 	CDDDoo;NNA!   
 1;30L0LXc)nnRUVWRXO
 ##"(<(< " 4*.*?*? 6+ +' +e3322"$:O  < #NN!]   
 7<33,427/.3+##(;(; ##NNB  4J??	B B   NN
T    /   D##
SFFvNNNN  S S S
 LL!LcRRRRRRRRS ##!NNH   > /   D##7U &9! ! hjj!$I % 0'jkkk$  . ?   'KLLLA   <   / U U ULL!NPSTTTTTTTTU##: " Pd&D&DV&L&L PMM":CMOOO## 226::: 66v>> NNC  (**% ++F33	%
 DDVLL% )3/B+C+CF      .00+ 1" 1 ;#%  9 		NN?++++% 	 	 		 	 	 \O   ////=     5    
 ' 	 	 	D	  D	@ 77??J 18**6**EE 4 //7777(-D%.3D+.3D++ '8++F33c-6H6HH % %)$;$;NN% %! $ &*&=&= 68N' 'O />D++ 88"O 9   
 ++F33c-6H6HH % %)$;$;NG% %! $ 8&*&=&= 6' 'O />D++ 11",; 2    //777 t^U33 @77???
s   4B
 
B>B99B>C2 2DD;:J6 6KKK( (
L2LL!L7 7M	M	B-P P5P00P5U W;.A
V98W;9)W%"W;$W%%W;*W;:W;c                 ,   t                      dz  dz  }|                                sdS t          |dd          5 }t          j        |          }ddd           n# 1 swxY w Y   t          |t                    sdS |                                D ]~\  }}t          |          	                    d          st          |t                    s>|
                    d          }|sV|
                    d	          }|                    d
|
                    d          p|t          |t                    r|pi 
                    d          nd|
                    d          t          |t                    r|pi 
                    d          nd|
                    d          t          |t                    rt          j        |          nd|
                    d          s|
                    d          rdndt          |          f           dS )uv  One-time v18 backfill of gateway metadata from sessions.json.

        Existing gateway sessions predate the display_name / origin_json /
        expiry_finalized columns; copy what sessions.json knows so consumers
        can switch to state.db without losing pre-migration sessions.
        Only fills NULL columns — never overwrites data written by newer code.
        r  zsessions.jsonNr   r   r   rU   r@   originai  UPDATE sessions
                   SET session_key = COALESCE(session_key, ?),
                       chat_id = COALESCE(chat_id, ?),
                       chat_type = COALESCE(chat_type, ?),
                       thread_id = COALESCE(thread_id, ?),
                       display_name = COALESCE(display_name, ?),
                       origin_json = COALESCE(origin_json, ?),
                       expiry_finalized = CASE
                           WHEN COALESCE(expiry_finalized, 0) = 0 AND ? = 1 THEN 1
                           ELSE expiry_finalized
                       END
                   WHERE id = ?r   chat_id	chat_type	thread_iddisplay_nameexpiry_finalizedmemory_flushedr3   r   )r   existsopenr   loadr8   r   r+   r   
startswithr   r4   dumps)	r=   r/   sessions_filefdatar   entryr@   r'  s	            r"   r  z@SessionSchemaMixin._backfill_gateway_metadata_from_sessions_json  sB    ())J6H##%% 	F-w777 	 19Q<<D	  	  	  	  	  	  	  	  	  	  	  	  	  	  	 $%% 	F**,, 	 	JC3xx""3'' z%/F/F <00J YYx((FNN# IIm,,35?5M5MWV\r&&y111SWIIk**7A&$7O7OYV\r&&{333UYIIn--*4VT*B*BLDJv&&&#566\%))DT:U:U\AA[\
OO	   	 	s   AA"%A")__name__
__module____qualname____doc__r6   CursorrD   boolrH   rM   staticmethodrX   rZ   r   r   ra   ry   rs   rq   r   r   r   r   r   r(   r   r   r  r%  r  rS   r$   r"   r'   r'   U   si       HH'GN 't ' ' ' 'R	GN 	t 	 	 	 	W^      K7> Kc K K K \K 
-# 
-4 
- 
- 
- \
-S SC S S S Sj
Agn 
A 
A 
A 
A 
An	   4  !%
 
 

 
 
	
 
 
 \
,  !%
 
 

 
 
	
 
 
 \
Bw~ 3 8TX>    "[ [D [T [ [ [ [z X# X$sDcN7J2K X X X \XtA AD A A A AFG?w~ G?$ G? G? G? G?Rv57> v5d v5 v5 v5 v5p^ ^ ^@/n/	/ / / / / /r$   r'   )r:  loggingr   r6   typingr   r   r   r   hermes_state_commonr   r   r	   r
   r   r   r   r   r   r   r   r   r   	getLoggerr;   r   r)   __annotations__r-   r'   rS   r$   r"   <module>rC     st        ! ! ! ! ! ! ! ! , , , , , ,                             $ 
	>	*	* +/  . . .("e (" (" (" ("Vg g g g g g g g g gr$   